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
src/domains/consensus/fork-hook.ts— handler registry + event type + default no-op.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_SPLIT—PARTITION_RECOVERYrequires 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.errorwould 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).