P3.9.1 — Fork Trigger Hook (ι handoff stub) — Audit

Round: R89 θ Wave 5 Branch: feature/p3-9-1-fork-hook Worktree: .worktrees/claude/p3-9-1-fork-hook Base: origin/main @ b5f3ffd5 Owner: T3 executor Date: 2026-05-13


§1. Inventory — files in scope

Files to create

  1. src/domains/consensus/fork-hook.ts — handler registry + event type + default no-op.
  2. src/__tests__/domains/consensus/fork-hook.test.ts — register/fire/clear semantics + error isolation.

Files NOT to edit (declared OUT OF SCOPE by the dispatch packet)

File Why off-limits
src/domains/consensus/round-state.ts The fire-point integration into __broadcastMyViewChange / tick() is left to a future ι-specific PR. This slice ships the hook surface only.
any other consensus module Read-only consumption — no fork-creation, no fork_id, no state copy.

Files to read (background — no edits)

Surface Path Why read
Source prompt §P3.9.1 docs/guides/implementation/task-prompts/p3.1-theta-consensus.md:2395-2557 Authoritative scope + acceptance criteria
consensus §Interaction with ι docs/3-world/physics/laws/consensus.md:189-191 When θ creates a fork: partition or genuine split
state-fork §Fork identity docs/3-world/physics/laws/state-fork.md:20-45 Background only — Phase 5 ι implements fork_id = SHA-256(parent_fork_id ‖ divergence_event_id ‖ rule_version_hash ‖ reason). Not built here.
state-fork §The five fork reasons docs/3-world/physics/laws/state-fork.md:50 ι has 5 reasons (CONSENSUS_SPLIT, PARTITION_RECOVERY, RULE_UPGRADE, EMERGENCY, EXPERIMENTAL); P3.9.1 ships ONLY the first two in the type union, and Phase 3 only ever EMITS CONSENSUS_SPLIT. The remaining 3 widen the type later in Phase 5+.
Round FSM src/domains/consensus/round-state.ts:1-1082 Where the hook will eventually fire from (__broadcastMyViewChange, tick() timeout paths). Not modified in this slice.
Test fixture pattern src/__tests__/domains/consensus/vrf-stub.test.ts:1-80 Imports + Buffer fixture pattern reference
Test fixture pattern src/__tests__/domains/consensus/round-state.test.ts Sequential async-handler invocation pattern reference
Audit template docs/audits/p3-7-1-mcp-tools-audit.md:1-40 R89 θ Wave 4 audit format reference

§2. Concept — what θ promises, what ι owns, what this slice ships

§2.1 The handoff line

θ’s job is to drive the commit-reveal FSM to a quorum or fail predictably. When it fails predictably, ι is responsible for taking the divergent state and creating a fork so the network can continue while reconciliation happens later.

consensus.md:189-191:

When θ cannot reach quorum — either because an arbiter partition lasts too long or because the vote space genuinely splits — the engine creates a fork via ι. […] A fork is not a failure of consensus; it is consensus’s graceful mode for “we do not yet agree.”

The call site for “engine creates a fork via ι” is where this hook fires. The fork creation itself is ι’s job, deferred to Phase 5 per state-fork.md:205 (“First real ι activation target: R151+ (Phase 5)”).

§2.2 The five fork reasons (state-fork §50)

ι spec defines 5 fork-reason values: CONSENSUS_SPLIT, PARTITION_RECOVERY, RULE_UPGRADE, EMERGENCY, EXPERIMENTAL. Per dispatch packet “Common gotchas” §2556-2559:

  • P3.9.1 ships ONLY the first two in the type alias (ForkReason = "CONSENSUS_SPLIT" | "PARTITION_RECOVERY").
  • Phase 3 only EMITS CONSENSUS_SPLITPARTITION_RECOVERY requires partition-monitoring infrastructure that does not exist in Phase 3.
  • The type widens in Phase 5+ to include all 5.

This is a deliberate narrowing: Phase 3’s hook surface must not promise more than the FSM can deliver today.

§2.3 When the hook fires (acceptance §P3.9.1)

Fires when: θ cannot reach quorum within 2 * T_timeout (i.e. one full round of view-change attempts exhausted)

Cross-reference: round-state.ts:174 declares T_timeout = 60_000n, i.e. 60s = 2 × T_round. 2 * T_timeout = 120_000n ms = 2 minutes. After two full leader-rotation cycles fail to land a quorum, θ surrenders and asks ι to fork.

round-state.ts already routes timeouts through __broadcastMyViewChange('timeout') (line 999, 1015). The fork-trigger condition — “view-change rotated through 2 * T_timeout worth of attempts and still no quorum” — is one layer above the FSM, owned by whatever harness drives tick(). P3.9.1 ships the registry that that harness will call; wiring is Phase 5+ ι work.

§2.4 Payload populated from RoundState (acceptance §P3.9.1)

Field Type Source from RoundState
round_id bigint RoundState.round_id()
divergent_roots Buffer[] Union of distinct merkle_root values from receivedReveals()
reason ForkReason Phase 3 always "CONSENSUS_SPLIT"
rule_version_hash Buffer From the cfg or first received Reveal’s vote.rule_version_hash — same value across all reveals in a single round per κ governance
timestamp_logical bigint RoundState.clock()

P3.9.1 itself does NOT extract this from RoundState — it just declares the shape. Extraction is the caller’s concern when wiring lands in Phase 5+.


§3. Architecture — registry pattern

§3.1 Why a registry (not a single callback)

Phase 5 ι will plug in the actual fork-creation handler. Phase 0 / 3 wants a no-op default. Tests will register sentinel handlers to verify behavior. The minimal shape that supports all three uses is a list of handlers iterated sequentially.

A single-callback API (e.g. setForkHandler(h)) would force tests to globally mutate the slot, which is fragile under parallel jest workers. A registry with new ForkHookRegistry() per test is the safer pattern.

§3.2 Sequential vs parallel handler invocation

Acceptance §P3.9.1: “Handlers run sequentially; one handler’s error does NOT prevent others from firing (try/catch each)”.

Sequential is the right choice:

  • Determinism — handler order is the registration order, which is a property tests can assert.
  • Error isolation per acceptance — easier to wrap each call in try/catch when the loop is sequential.
  • ι Phase 5 may want side-effect ordering (e.g. write fork row → notify gossip → emit ζ event) that depends on prior handlers having completed.

The cost — Phase 5 ι handler could block the FSM for the duration of its work — is acceptable: P3.9.1 is a stub and ι handler implementations can themselves dispatch async work if they need to.

§3.3 try/catch swallow vs propagate

The packet pseudocode (line 2465) explicitly says /* swallow; one handler should not stop others */. Errors are swallowed. The default no-op handler is a guaranteed-no-throw, so the swallow path is only exercised when a downstream ι handler misbehaves; in that case the system continues processing remaining handlers, which is the documented intent.

We do NOT log the swallowed error in P3.9.1 because:

  • No ζ thought_record callback is injected (the packet says “may log to ζ via injected thought_record callback at most” — i.e. optional, and the stub does not include the injection plumbing).
  • Logging via console.error would violate κ-determinism / pure-module hygiene that the rest of consensus follows.

A test exercises the throw-and-continue path explicitly.

§3.4 defaultRegistry singleton

The packet (line 2471-2473) calls for export const defaultRegistry = new ForkHookRegistry() with the no-op handler pre-registered. This is the singleton RoundState would wire when Phase 5 ι lands; for now it just exists so future wiring has a stable import target.

Tests use new ForkHookRegistry() per test to avoid cross-test leakage on the module-level singleton.


§4. Type-level surface (export list)

Symbol Kind Phase 3 emission
ForkReason type "CONSENSUS_SPLIT" \| "PARTITION_RECOVERY" — Phase 3 only emits CONSENSUS_SPLIT
ForkTriggerEvent type 5-field readonly shape
ForkHookHandler type (event: ForkTriggerEvent) => void \| Promise<void>
ForkHookRegistry class register / fire / clear / handlers
defaultRegistry const Module-level singleton w/ no-op pre-registered
noOpHandler const The default handler (intentional no-op)

All exports are named — no default export. Matches the rest of src/domains/consensus/.


§5. Constraints — forbidden tokens / κ determinism

From dispatch packet “Forbidden”:

Forbidden Applies to fork-hook.ts?
Math.* YES — must not appear
Date.* YES — must not appear
Math.random YES — must not appear
floats (literal 0.1 etc.) YES — must not appear
new npm deps YES — none added
--no-verify / --amend / --force-push YES — git hygiene
ι fork-creation logic YES — no fork_id computation, no state copy, no new schema rows
edits to round-state.ts YES — surface-only slice

The module body trivially avoids the first four since the only operations are array push, try/catch, and handler invocation; no arithmetic, no time reads, no randomness.


§6. Test surface plan

Acceptance §P3.9.1 + dispatch packet’s bullet list translates to seven targeted tests (one per behavior):

# Behavior tested Acceptance bullet
T1 register() then fire() delivers payload to handler “register + fire delivers payload”
T2 0 handlers → fire() resolves without throwing “0 handlers → fire is no-op”
T3 3 handlers → all 3 invoked sequentially in registration order “3 handlers → all 3 invoked sequentially”
T4 Handler-1 throws → handlers 2 + 3 still invoked “handler-1 throws → handlers 2-3 still invoked”
T5 clear() removes all handlers “clear() removes all handlers”
T6 Default no-op handler doesn’t crash on a valid event “default no-op doesn’t crash with valid event”
T7 Payload type-shape (compile-time + runtime structural check) “payload shape matches type”

Additional adjuncts (low cost, high signal):

# Behavior tested
T8 handlers() returns a read-only view of registered handlers (length + identity)
T9 Async handler (async () => …) is awaited before the next one runs
T10 defaultRegistry has the no-op pre-registered
T11 Phase 3 emits only CONSENSUS_SPLIT (test fixture documents this)
T12 register() allows re-registering the same handler (no de-dup)
T13 clear() is idempotent
T14 After clear(), registering and firing works normally

Target test count: 14 new it/test blocks in one suite, ~10-15 per dispatch-packet target. No table-driven blowup.


§7. Risk inventory

Risk Mitigation
Accidental ι implementation creeping in Explicit “no fork_id, no state copy, no schema rows, no edits to round-state.ts” in §1 and §5; final report confirms
Cross-test leakage via defaultRegistry Tests use fresh new ForkHookRegistry(); defaultRegistry only inspected in T10
defaultRegistry autoregistering no-op leaks across tests T10 documents this and the test never clear()s the singleton
handlers() mutability leak Returns readonly ForkHookHandler[] typed view; runtime check that the consumer can’t push
Forbidden token in body Module is small, audit by inspection; lint catches some
Pre-existing 3 reputation test failures bleed into delta Baseline = 3040 passing on b5f3ffd5; my PR must keep 3040 + ~14 new = 3054 ish

§8. Exit criteria for Step 1

  • Files to create identified (2)
  • Files NOT to edit declared with reasons
  • Concept narrative ties hook to consensus §Interaction with ι and state-fork §Fork identity
  • Phase 3 vs Phase 5 boundary explicit (only CONSENSUS_SPLIT emitted; no fork_id)
  • Registry pattern justified (sequential, try/catch swallow)
  • 14 test behaviors enumerated and mapped to acceptance bullets
  • κ-determinism / forbidden-token scope confirmed
  • Test baseline noted: 3040 passing on b5f3ffd5

Step 1 complete. Proceed to Step 2 (contract).


Back to top

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

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