P3.9.1 — Fork Trigger Hook (ι handoff stub) — Contract
Round: R89 θ Wave 5
Branch: feature/p3-9-1-fork-hook
Worktree: .worktrees/claude/p3-9-1-fork-hook
Audit: p3-9-1-fork-hook-audit.md
Owner: T3 executor
Date: 2026-05-13
§1. Surface
Module exports from src/domains/consensus/fork-hook.ts:
export type ForkReason = 'CONSENSUS_SPLIT' | 'PARTITION_RECOVERY';
export interface ForkTriggerEvent {
readonly round_id: bigint;
readonly divergent_roots: readonly Buffer[];
readonly reason: ForkReason;
readonly rule_version_hash: Buffer;
readonly timestamp_logical: bigint;
}
export type ForkHookHandler = (event: ForkTriggerEvent) => void | Promise<void>;
export class ForkHookRegistry {
register(h: ForkHookHandler): void;
fire(event: ForkTriggerEvent): Promise<void>;
clear(): void;
handlers(): readonly ForkHookHandler[];
}
export const noOpHandler: ForkHookHandler;
export const defaultRegistry: ForkHookRegistry; // ships with noOpHandler pre-registered
The dispatch packet’s task line specifies divergent_roots: Buffer[] (mutable). The contract narrows this to readonly Buffer[] on the event to prevent handlers from mutating shared state in place; the event is the public payload, and downstream ι handlers must not mutate a sibling handler’s view of the world. The Buffers themselves remain mutable (Node.js Buffer has no immutable form short of copying), but the array reference is frozen at construction.
§2. Behavioral contract
§2.1 register(h)
Preconditions: None. h is any callable conforming to ForkHookHandler.
Postconditions:
- The handler is appended to the internal list at index
prev_length. handlers().length === prev_length + 1.handlers()[prev_length] === h(identity-preserving append).- Re-registering the same
hproduces two entries; the registry does not de-dup.
Throws: Never.
Idempotency: Not idempotent — each call adds an entry.
§2.2 fire(event)
Preconditions: event is a ForkTriggerEvent with all five fields present.
Postconditions:
- For each handler
h_iin registration order:h_i(event)is invoked.- If
h_ireturns a Promise, it isawait-ed beforeh_{i+1}is invoked. - If
h_ithrows synchronously or its returned Promise rejects, the error is caught and discarded. The loop continues toh_{i+1}.
- After all handlers have been awaited (or errored), the returned Promise resolves with
undefined.
Throws: Never — all handler errors are swallowed.
Idempotency: Each call invokes every registered handler exactly once. Calling fire(e) twice invokes each handler twice.
Order: Strict registration order. Handlers registered later run later.
§2.3 clear()
Preconditions: None.
Postconditions:
handlers().length === 0.- After
clear(),register(h2)andfire(e)work normally — the registry is not poisoned.
Throws: Never.
Idempotency: Calling clear() on an empty registry is a no-op (no throw, state unchanged at empty).
§2.4 handlers()
Preconditions: None.
Postconditions:
- Returns a readonly view of the current handler list.
- The returned reference reflects the live list at call time. (The contract does NOT require a defensive copy — the TypeScript
readonlymodifier conveys intent at compile time, and the runtime returns the internal array directly to avoid an allocation on every call.) - Mutating the returned value via
ascasts is undefined behavior; tests do not exercise this path.
Throws: Never.
§2.5 defaultRegistry and noOpHandler
defaultRegistryis a module-levelForkHookRegistryinstance.noOpHandleris registered ondefaultRegistryat module load time.defaultRegistry.handlers().length === 1on module load.defaultRegistry.handlers()[0] === noOpHandler.- Calling
defaultRegistry.fire(event)is a no-op modulo the await pause (returns a resolved Promise with no observable side effect).
Tests that need a clean registry MUST construct new ForkHookRegistry() and not touch defaultRegistry. T10 inspects defaultRegistry read-only and does NOT clear it.
§3. Type contract
§3.1 ForkReason
'CONSENSUS_SPLIT' | 'PARTITION_RECOVERY'. Per state-fork.md:50 the full ι vocabulary has five entries (CONSENSUS_SPLIT, PARTITION_RECOVERY, RULE_UPGRADE, EMERGENCY, EXPERIMENTAL); P3.9.1 narrows to two for the Phase 3 hook surface. Phase 5+ may widen this union without breaking downstream consumers of the narrower type (it’s a strict super-type extension).
Phase 3 emits only CONSENSUS_SPLIT. This is documented in §P3.9.1 source (“Phase 3 only fires CONSENSUS_SPLIT”). PARTITION_RECOVERY exists in the type for Phase 5 forward compatibility but is never produced by any Phase 3 code path.
§3.2 ForkTriggerEvent
| Field | Type | Origin | Notes |
|---|---|---|---|
round_id |
bigint |
RoundState.round_id() |
Immutable for the lifetime of the round; matches round-state.ts:445-446 |
divergent_roots |
readonly Buffer[] |
Union of distinct merkle_root values from RoundState.receivedReveals() |
Distinct meaning de-duplicated via Buffer.compare === 0. Caller’s job — P3.9.1 ships the type only |
reason |
ForkReason |
Phase 3: always 'CONSENSUS_SPLIT' |
Phase 5 widens vocabulary |
rule_version_hash |
Buffer |
Either cfg.my_vote_tuple.rule_version_hash or receivedReveals()[0].vote.rule_version_hash (identical by κ governance) |
Pinned for the round |
timestamp_logical |
bigint |
RoundState.clock() |
Strictly monotonic per round |
All fields are required. No nullable members. No defaults — the caller fully populates the event before calling fire.
§3.3 ForkHookHandler
type ForkHookHandler = (event: ForkTriggerEvent) => void | Promise<void>;
The return value is intentionally void | Promise<void> to support both sync and async handlers. fire() always awaits, so a sync handler is just an async handler that resolves immediately.
§4. Invariants
| # | Invariant | Enforcement |
|---|---|---|
| I1 | Calling fire(e) on an empty registry never throws |
Test T2 |
| I2 | A handler’s throw does not skip later handlers | Test T4 |
| I3 | A handler’s Promise rejection does not skip later handlers | Test T4 (variant) |
| I4 | Handlers run in registration order | Test T3 |
| I5 | Async handlers are awaited before the next handler runs | Test T9 |
| I6 | clear() empties the registry |
Test T5 |
| I7 | clear() then register then fire works normally |
Test T14 |
| I8 | The module body contains no Math.*, Date.*, Math.random, floats |
Visual + lint |
| I9 | The module declares no ι fork-creation logic (no fork_id, no state copy, no schema rows) |
Visual + audit declaration |
| I10 | defaultRegistry ships with exactly one handler (noOpHandler) |
Test T10 |
| I11 | The default noOpHandler does not throw on any valid event |
Test T6 |
| I12 | register() permits duplicates (no implicit de-dup) |
Test T12 |
| I13 | clear() is idempotent |
Test T13 |
§5. Forbidden behaviors
The contract explicitly forbids:
- Any computation of
fork_id(the SHA-256 over parent_fork_id‖divergence_event_id‖rule_version_hash‖reason fromstate-fork.md:25). - Any allocation of fork state, branch state, or shadow merkle trees.
- Any persistence to the
mcp_consensus_votestable or any other DB write. - Any modification of
src/domains/consensus/round-state.tsor any other existing consensus module. - Any
Math.*,Date.*,Math.random, or floating-point literal in the module body. - Any new npm dependency.
- Any use of
--no-verify,--amend, or--force-pushin git operations.
These constraints make this a true “handoff stub” — Phase 5 ι is the only consumer that completes the surface.
§6. Acceptance criteria
Headline criteria from §P3.9.1:
ForkTriggerEventshape locked (§3.2)ForkHookRegistrywithregister / fire / clear / handlers(§1, §2)- Default no-op handler registered (§2.5)
- Error in one handler doesn’t stop others (I2, I3)
- ι implementation NOT included — hook surface only (§5, I9)
npm run build && npm run lint && npm testpass — TBD at Step 5
Test mapping (§7 of audit):
| Test | Acceptance bullet |
|---|---|
| T1 register + fire delivers payload | “register + fire delivers payload” |
| T2 zero handlers no-throw | “0 handlers → fire is no-op” |
| T3 three handlers, all invoked in order | “3 handlers → all 3 invoked sequentially” |
| T4 handler-1 throws → 2 + 3 still fire | “handler-1 throws → handlers 2-3 still invoked” |
| T5 clear() removes all | “clear() removes all handlers” |
| T6 default no-op doesn’t crash | “default no-op doesn’t crash with valid event” |
| T7 payload shape matches type | “payload shape matches type” |
| T8 handlers() readonly view | adjunct |
| T9 async handler awaited | adjunct |
| T10 defaultRegistry has no-op pre-registered | adjunct |
| T11 Phase 3 only emits CONSENSUS_SPLIT (fixture documents) | adjunct |
| T12 register permits dupes | adjunct |
| T13 clear() idempotent | adjunct |
| T14 clear → register → fire works | adjunct |
§7. Out-of-scope items (deferred to Phase 5 ι)
- Computing
fork_idfrom the event. - Creating a fork row in
mcp_consensus_forks(the table itself is Phase 5). - Replacing
noOpHandlerwith a real fork-creation handler. - Wiring the registry into
RoundState.__broadcastMyViewChange/tick()so a real round actually fires this hook. - Widening
ForkReasonto all 5 ι values. - Driving
PARTITION_RECOVERYemissions from a partition monitor.
All seven items are explicit Phase 5 work per state-fork.md:205 (“First real ι activation target: R151+”).
§8. Exit criteria for Step 2
- Surface declared with exports
register / fire / clear / handlersbehavior pinneddefaultRegistry+noOpHandlersemantics documented- Type contract on all five
ForkTriggerEventfields - 13 invariants enumerated
- Out-of-scope items explicitly listed
- Acceptance bullets cross-referenced to test plan
Step 2 complete. Proceed to Step 3 (packet).