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., noMath.random, noMath.floor(Math.random()...), nocrypto.randomBytes, nocrypto.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
- Branch-order trap — §I3 + §I4 jointly close this. Test G2’s
axiom_regression + rule_updatecase fails if branching is reversed. - HARD_BLOCK semantics divergence — §I1 + §I14 close this. The FSM is the layer that introduces HARD_BLOCK;
Advisory.resultstays 3-valued. - Idempotency contract — §I8 + §I11 close this with the “FSM derives event_id, ignores emitter returns” choice.
axiom_driftvsaxiom_regressioncollapsing — §I4 + §I5 close this.- Emitter return values — §I11 documents the choice: FSM ignores them for
event_id. - WARN dual-emit — §I13 closes this.
- 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.