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 h produces 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_i in registration order:
    • h_i(event) is invoked.
    • If h_i returns a Promise, it is await-ed before h_{i+1} is invoked.
    • If h_i throws synchronously or its returned Promise rejects, the error is caught and discarded. The loop continues to h_{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) and fire(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 readonly modifier 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 as casts is undefined behavior; tests do not exercise this path.

Throws: Never.

§2.5 defaultRegistry and noOpHandler

  • defaultRegistry is a module-level ForkHookRegistry instance.
  • noOpHandler is registered on defaultRegistry at module load time.
  • defaultRegistry.handlers().length === 1 on 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 from state-fork.md:25).
  • Any allocation of fork state, branch state, or shadow merkle trees.
  • Any persistence to the mcp_consensus_votes table or any other DB write.
  • Any modification of src/domains/consensus/round-state.ts or 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-push in 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:

  • ForkTriggerEvent shape locked (§3.2)
  • ForkHookRegistry with register / 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 test pass — 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_id from the event.
  • Creating a fork row in mcp_consensus_forks (the table itself is Phase 5).
  • Replacing noOpHandler with a real fork-creation handler.
  • Wiring the registry into RoundState.__broadcastMyViewChange / tick() so a real round actually fires this hook.
  • Widening ForkReason to all 5 ι values.
  • Driving PARTITION_RECOVERY emissions 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 / handlers behavior pinned
  • defaultRegistry + noOpHandler semantics documented
  • Type contract on all five ForkTriggerEvent fields
  • 13 invariants enumerated
  • Out-of-scope items explicitly listed
  • Acceptance bullets cross-referenced to test plan

Step 2 complete. Proceed 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.