P4.4.1 Escalation FSM — Behavioral Contract (Step 2)

Task: P4.4.1 — Escalation FSM (4-result + 3 invariant mappings) Worktree: feature/p4-4-1-escalation-fsm Base SHA: 41226615 Audit: docs/audits/p4-4-1-escalation-fsm-audit.md

§1. Public surface

// src/domains/integrity/escalation.ts

export type EscalationResult = 'PASS' | 'WARN' | 'BLOCK' | 'HARD_BLOCK';

export type EscalationTarget = 'ζ' | 'operator_console' | 'π' | 'α';

export type EscalationContext = {
  readonly surface: 'rule_update' | 'admission_gate' | 'governance_intake' | 'other';
};

export type EscalationOutcome = {
  readonly result: EscalationResult;
  readonly target_axis: EscalationTarget;
  readonly event_id: string; // /^[a-f0-9]{64}$/ — deterministic from advisory.decision_hash + target_axis
};

export type EscalationDeps = {
  emitZeta(advisory: Advisory): string;     // returns event_id from caller's perspective (may differ from FSM's)
  emitOperator(advisory: Advisory): string;
  emitPi(advisory: Advisory): string;
  emitAlpha(advisory: Advisory): string;
};

export function escalate(
  advisory: Advisory,
  context: EscalationContext,
  deps: EscalationDeps,
): EscalationOutcome;

export const TARGET_FIELD_SEPARATOR: '|';

§2. Algorithm — 4-result FSM with invariant-mapping branching

escalate(advisory, context, deps):

  // Branch on advisory.result FIRST. WARN/PASS never raise to HARD.
  switch advisory.result:

    case 'PASS':
      deps.emitZeta(advisory)
      return { result: 'PASS', target_axis: 'ζ', event_id: derive(advisory, 'ζ') }

    case 'WARN':
      deps.emitOperator(advisory)
      deps.emitZeta(advisory)
      // operator_console is the primary; ζ is a side-log
      return { result: 'WARN', target_axis: 'operator_console', event_id: derive(advisory, 'operator_console') }

    case 'BLOCK':
      // Branch on advisory.check FIRST (so axiom_regression's always-HARD dominates), THEN on context.surface.
      if advisory.check === 'axiom_regression':
        // Always HARD regardless of context.
        deps.emitAlpha(advisory)
        return { result: 'HARD_BLOCK', target_axis: 'α', event_id: derive(advisory, 'α') }

      if advisory.check === 'circular_logic' AND context.surface === 'rule_update':
        // Invariant mapping 1.
        deps.emitAlpha(advisory)
        return { result: 'HARD_BLOCK', target_axis: 'α', event_id: derive(advisory, 'α') }

      if advisory.check === 'coercion_trap' AND context.surface === 'admission_gate':
        // Invariant mapping 2.
        deps.emitAlpha(advisory)
        return { result: 'HARD_BLOCK', target_axis: 'α', event_id: derive(advisory, 'α') }

      if advisory.check === 'axiom_drift' AND context.surface === 'governance_intake':
        // Invariant mapping 3 — BLOCK via π (NOT HARD per spec L1132-1133 of dispatch packet).
        deps.emitPi(advisory)
        return { result: 'BLOCK', target_axis: 'π', event_id: derive(advisory, 'π') }

      // Default BLOCK route — soft denial via π.
      deps.emitPi(advisory)
      return { result: 'BLOCK', target_axis: 'π', event_id: derive(advisory, 'π') }

where derive(advisory, target_axis):
  preimage = advisory.decision_hash + TARGET_FIELD_SEPARATOR + target_axis
  return sha256(preimage).hex()  // 64-char lowercase hex

§3. Invariants

§I1. 4-result enum.

EscalationResult is exactly ['PASS', 'WARN', 'BLOCK', 'HARD_BLOCK']. The literal token HARD_BLOCK is spelled with an underscore (per dispatch packet acceptance line “note underscore in HARD_BLOCK”).

§I2. 4-target enum.

EscalationTarget is exactly ['ζ', 'operator_console', 'π', 'α']. The Greek tokens use the proper Unicode codepoints (U+03B6 ZETA, U+03C0 PI, U+03B1 ALPHA), matching the spec line 1108-1112 of the dispatch packet.

§I3. Branch order — check first, then context.

For advisory.result === 'BLOCK', the FSM tests advisory.check === 'axiom_regression' BEFORE any context-based test. This guarantees axiom_regression always maps to HARD_BLOCK regardless of context.surface. A wrongly-ordered FSM (context first) would allow rule_update to short-circuit axiom_regression’s always-HARD invariant.

§I4. axiom_regression always HARD.

For every context.surface ∈ {rule_update, admission_gate, governance_intake, other}, an advisory with check === 'axiom_regression' and result === 'BLOCK' returns { result: 'HARD_BLOCK', target_axis: 'α' } and calls deps.emitAlpha.

§I5. axiom_drift is NOT axiom_regression.

axiom_drift is a distinct check value. It does NOT map to HARD via §I4. Its only HARD path would be a future invariant mapping — none ships in P4.4.1. In governance_intake context it returns BLOCK via π (mapping 3); elsewhere it falls through to the default BLOCK route.

§I6. HARD_BLOCK emits to α only.

Whenever result === 'HARD_BLOCK', exactly deps.emitAlpha(advisory) is called. No other emitter fires on the HARD_BLOCK path. In particular, emitPi is NOT called on HARD_BLOCK paths.

§I7. Deterministic event_id.

event_id is the 64-character lowercase hex SHA-256 of ${advisory.decision_hash}|${target_axis}. Two calls with byte-equal (advisory, context) produce byte-equal event_id. Determinism is preserved across processes, hosts, and locales (Node ≥ 20 SHA-256 is platform-uniform).

§I8. Idempotency.

escalate(advisory, context, deps) is a pure function of (advisory, context) for its returned EscalationOutcome (the FSM ignores deps’ return values when computing event_id). The side-effect signature — which emitters fire — is also pure in (advisory, context). Calling the function N times with identical input fires the same emitters N times but each call returns identical outcome.

§I9. No upstream κ/λ state mutation.

The escalation module imports no κ/λ APIs. It does not touch RuleRegistry, mcp_reputation, admission.ts, or any other κ/λ source. μ is read-only; escalation is event-emit only.

§I10. Pure module.

No Date.*, no Math.random, no crypto.randomBytes, no process.env, no db.run / db.exec / db.prepare, no I/O. The only side effect is the four deps.emit* calls — those are caller-injected.

§I11. Emitter return values ignored for event_id.

The FSM does NOT trust deps.emitX(advisory) return values to construct event_id. The FSM derives event_id from (advisory.decision_hash, target_axis) independently. Emitters’ returns are reserved for the caller’s logging / event-stream wiring; the FSM contract does not depend on them.

§I12. Closed enum on EscalationContext.surface.

The four surfaces {rule_update, admission_gate, governance_intake, other} are the only legal values. Future surfaces (e.g. agent_election) would be a breaking change cascading through this FSM. Not in scope for P4.4.1.

§I13. WARN dual-emit; outcome.target_axis = operator_console.

On the WARN path BOTH emitOperator and emitZeta fire. The outcome’s target_axis is operator_console — the operator console is the primary surface; ζ is the side log. event_id is derived against operator_console, not against ζ.

§I14. No HARD_BLOCK from PASS/WARN results.

An advisory with result: 'PASS' or result: 'WARN' NEVER returns HARD_BLOCK regardless of check or context. The check-based escalation only fires inside the BLOCK branch.

§4. Mapping reference

Advisory Context Result Target Emitter fired
result=PASS (any check) any PASS ζ emitZeta
result=WARN (any check) any WARN operator_console emitOperator + emitZeta
result=BLOCK + check=axiom_regression any HARD_BLOCK α emitAlpha
result=BLOCK + check=circular_logic rule_update HARD_BLOCK α emitAlpha
result=BLOCK + check=circular_logic other BLOCK π emitPi
result=BLOCK + check=coercion_trap admission_gate HARD_BLOCK α emitAlpha
result=BLOCK + check=coercion_trap other BLOCK π emitPi
result=BLOCK + check=axiom_drift governance_intake BLOCK π emitPi
result=BLOCK + check=axiom_drift other BLOCK π emitPi

Note: axiom_drift + governance_intake and axiom_drift + other BOTH route through π (BLOCK). The mapping line is duplicated for clarity — it documents intent (governance intake is the canonical π consumer site) but the routing is identical.

§5. Determinism corpus

The escalation module obeys the determinism guardrails enforced by src/__tests__/domains/rules/determinism.test.ts (for κ files). μ does not strictly inherit that scanner, but P4.4.1’s test suite asserts the same shape:

  • No Date., no Math.random, no Math.floor(Math.random()...), no crypto.randomBytes, no crypto.randomUUID
  • No process.env
  • No db.run, db.exec, db.prepare
  • No setTimeout, setInterval
  • No async / await (function is sync-pure)
  • Imports allowed: node:crypto.createHash, types from ./schema.js

§6. Acceptance criteria

The 14 invariants above are the acceptance contract. Test groups (see Step 5 verification):

AC# Maps to Test group
AC#1 §I1 G1 (enum shape)
AC#2 §I2 G1 (enum shape)
AC#3 §I3 G2 (branch-order — axiom_regression + rule_update test)
AC#4 §I4 G3 (axiom_regression × 4 contexts)
AC#5 §I5 G4 (axiom_drift cases)
AC#6 §I6 G2 + G3 (HARD_BLOCK emitter assertion)
AC#7 §I7 G5 (event_id format + cross-process determinism)
AC#8 §I8 G6 (idempotency — call twice)
AC#9 §I9 G7 (static scanner — no κ/λ imports)
AC#10 §I10 G7 (static scanner — no Date/Math/db/env)
AC#11 §I11 G5 (emitters return mock garbage; event_id ignores it)
AC#12 §I12 TypeScript closed enum (compile-time)
AC#13 §I13 G2 (WARN test — emitOperator + emitZeta both fire)
AC#14 §I14 G2 (PASS/WARN with axiom_regression check still returns PASS/WARN)

§7. Risks revisited from audit

  1. Branch-order trap — §I3 + §I4 jointly close this. Test G2’s axiom_regression + rule_update case fails if branching is reversed.
  2. HARD_BLOCK semantics divergence — §I1 + §I14 close this. The FSM is the layer that introduces HARD_BLOCK; Advisory.result stays 3-valued.
  3. Idempotency contract — §I8 + §I11 close this with the “FSM derives event_id, ignores emitter returns” choice.
  4. axiom_drift vs axiom_regression collapsing — §I4 + §I5 close this.
  5. Emitter return values — §I11 documents the choice: FSM ignores them for event_id.
  6. WARN dual-emit — §I13 closes this.
  7. Test layout inconsistency — Resolved by following the dispatch packet (src/__tests__/domains/integrity/escalation.test.ts).

§8. Step exit criteria

  • Public surface frozen
  • Algorithm spec lines fixed
  • 14 invariants enumerated
  • Mapping table covers all 9 (advisory × context) cells
  • AC# matrix maps invariants to test groups
  • Risks from §audit closed

Contract step complete — proceeding to §Step 3 packet.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.