P3.9.1 — Fork Trigger Hook (ι handoff stub) — Execution Packet
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
Contract: p3-9-1-fork-hook-contract.md
Owner: T3 executor
Date: 2026-05-13
§1. Change set
§1.1 New file: src/domains/consensus/fork-hook.ts
Single-file module. ESM. Named exports only. No npm deps.
Structural skeleton:
// 1. JSDoc header (purpose, references, P3.9.1 spec citations)
// 2. ForkReason type alias
// 3. ForkTriggerEvent interface (readonly fields)
// 4. ForkHookHandler type alias
// 5. ForkHookRegistry class
// - private handlers_: ForkHookHandler[]
// - register(h): pushes
// - fire(event): sequential await with per-handler try/catch
// - clear(): replaces handlers_ with []
// - handlers(): returns handlers_ as readonly view
// 6. noOpHandler const (intentional no-op)
// 7. defaultRegistry const (singleton, pre-registers noOpHandler)
Estimated LOC: ~70 source + ~50 JSDoc = ~120 total.
§1.2 New file: src/__tests__/domains/consensus/fork-hook.test.ts
ESM test suite. Mirrors the import pattern from src/__tests__/domains/consensus/vrf-stub.test.ts. Fixture: a single sampleEvent() factory that returns a populated ForkTriggerEvent with deterministic Buffers (no randomBytes, no Date.now()).
Structural skeleton:
import { /* … */ } from '../../../domains/consensus/fork-hook.js';
function sampleEvent(): ForkTriggerEvent { /* fixed bigints + filled Buffers */ }
describe('ForkHookRegistry — registration', () => {
test('T1 register + fire delivers payload to handler', () => { /* … */ });
test('T8 handlers() returns appended list', () => { /* … */ });
test('T12 register permits duplicates', () => { /* … */ });
});
describe('ForkHookRegistry — fire', () => {
test('T2 zero handlers, fire is no-op', () => { /* … */ });
test('T3 three handlers run in order', () => { /* … */ });
test('T4a sync throw in handler-1 does not skip 2-3', () => { /* … */ });
test('T4b rejected Promise in handler-1 does not skip 2-3', () => { /* … */ });
test('T9 async handler awaited before next', () => { /* … */ });
});
describe('ForkHookRegistry — clear', () => {
test('T5 clear() empties handlers', () => { /* … */ });
test('T13 clear() is idempotent', () => { /* … */ });
test('T14 clear → register → fire works', () => { /* … */ });
});
describe('defaultRegistry + noOpHandler', () => {
test('T6 noOpHandler does not crash with valid event', () => { /* … */ });
test('T10 defaultRegistry has noOpHandler pre-registered', () => { /* … */ });
});
describe('ForkTriggerEvent payload shape', () => {
test('T7 round_id is bigint; divergent_roots is Buffer[]; reason is ForkReason; rule_version_hash is Buffer; timestamp_logical is bigint', () => { /* … */ });
test('T11 Phase 3 emits only CONSENSUS_SPLIT (fixture documents this contract)', () => { /* … */ });
});
14 test blocks (T1–T14) matching the audit §6 plan. ~150 LOC total.
§2. Detailed module body
/**
* Colibri — Phase 3 θ Consensus — Fork Trigger Hook (P3.9.1).
*
* ι handoff stub. Defines the surface that ι (Phase 5 State Fork) will
* subscribe to when θ exhausts view-change attempts without reaching
* quorum. ι itself is NOT implemented here.
*
* Per docs/3-world/physics/laws/consensus.md §Interaction with ι:
*
* 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 ι.
*
* The "engine creates a fork via ι" call site is where this hook fires.
* The hook itself is a simple registry of handlers, invoked sequentially
* with per-handler try/catch so one buggy handler does not skip the
* others.
*
* Phase 3 only emits ForkReason='CONSENSUS_SPLIT'. The 'PARTITION_RECOVERY'
* variant exists in the type alias for Phase 5 forward compatibility but
* is never produced by any Phase 3 code path. The full ι vocabulary
* (CONSENSUS_SPLIT, PARTITION_RECOVERY, RULE_UPGRADE, EMERGENCY,
* EXPERIMENTAL — see docs/3-world/physics/laws/state-fork.md §50)
* widens this union later in Phase 5+.
*
* Pure module. No I/O. No DB writes. No randomness. No wall clock.
* No floats. The body contains none of the κ-determinism forbidden
* tokens (Math.*, Date.*, Math.random, floating-point literals).
*
* Canonical references:
* - docs/audits/p3-9-1-fork-hook-audit.md (Step 1)
* - docs/contracts/p3-9-1-fork-hook-contract.md (Step 2)
* - docs/packets/p3-9-1-fork-hook-packet.md (Step 3)
* - docs/3-world/physics/laws/consensus.md §Interaction with ι
* - docs/3-world/physics/laws/state-fork.md §Fork identity
* - docs/guides/implementation/task-prompts/p3.1-theta-consensus.md §P3.9.1
*/
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 {
private handlers_: ForkHookHandler[] = [];
register(h: ForkHookHandler): void {
this.handlers_.push(h);
}
async fire(event: ForkTriggerEvent): Promise<void> {
for (const h of this.handlers_) {
try {
await h(event);
} catch {
// Per contract I2/I3: handler errors are swallowed so one bad
// handler does not prevent later handlers from running. No log
// here — the host wires a ζ thought_record callback in a later
// ι slice if it wants error visibility.
}
}
}
clear(): void {
this.handlers_ = [];
}
handlers(): readonly ForkHookHandler[] {
return this.handlers_;
}
}
export const noOpHandler: ForkHookHandler = (_event) => {
// Intentional no-op. The default handler is here only so the
// defaultRegistry has at least one entry on module load — Phase 5 ι
// will replace this with a real fork-creation handler.
void _event;
};
export const defaultRegistry: ForkHookRegistry = new ForkHookRegistry();
defaultRegistry.register(noOpHandler);
Total: ~75 source lines + 38 JSDoc lines = 113 lines.
§3. Detailed test body
Sketch for the highest-signal blocks:
function sampleEvent(): ForkTriggerEvent {
return {
round_id: 42n,
divergent_roots: [Buffer.alloc(32, 0xa1), Buffer.alloc(32, 0xa2)],
reason: 'CONSENSUS_SPLIT',
rule_version_hash: Buffer.alloc(32, 0xb1),
timestamp_logical: 100n,
};
}
test('T1 register + fire delivers payload to handler', async () => {
const reg = new ForkHookRegistry();
let received: ForkTriggerEvent | null = null;
reg.register((e) => { received = e; });
const evt = sampleEvent();
await reg.fire(evt);
expect(received).not.toBeNull();
expect(received!.round_id).toBe(42n);
expect(received!.reason).toBe('CONSENSUS_SPLIT');
expect(received!.divergent_roots.length).toBe(2);
});
test('T3 three handlers run in order', async () => {
const reg = new ForkHookRegistry();
const calls: number[] = [];
reg.register(() => { calls.push(1); });
reg.register(() => { calls.push(2); });
reg.register(() => { calls.push(3); });
await reg.fire(sampleEvent());
expect(calls).toEqual([1, 2, 3]);
});
test('T4a sync throw in handler-1 does not skip 2-3', async () => {
const reg = new ForkHookRegistry();
const calls: number[] = [];
reg.register(() => { throw new Error('boom'); });
reg.register(() => { calls.push(2); });
reg.register(() => { calls.push(3); });
await expect(reg.fire(sampleEvent())).resolves.toBeUndefined();
expect(calls).toEqual([2, 3]);
});
test('T9 async handler awaited before next', async () => {
const reg = new ForkHookRegistry();
const order: string[] = [];
reg.register(async () => {
order.push('h1-start');
await Promise.resolve();
order.push('h1-end');
});
reg.register(() => { order.push('h2'); });
await reg.fire(sampleEvent());
expect(order).toEqual(['h1-start', 'h1-end', 'h2']);
});
test('T10 defaultRegistry has noOpHandler pre-registered', () => {
expect(defaultRegistry.handlers().length).toBeGreaterThanOrEqual(1);
expect(defaultRegistry.handlers()[0]).toBe(noOpHandler);
});
Remaining test blocks follow the same pattern — small, focused, and free of any side effect that would survive the test boundary.
§4. Verification gates
| Gate | Command | Expected |
|---|---|---|
| Build | npm run build |
exit 0, no TS errors |
| Lint | npm run lint |
exit 0, no ESLint errors |
| Tests | npm test |
3040 baseline + 14 new = ~3054 passing; 0 new failures |
Pre-existing 3 failures in src/__tests__/domains/reputation/tools.test.ts (better-sqlite3 prepare issue) are outside this slice’s scope; PR keeps them at 3 (does not introduce; does not fix).
§5. Risk assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
handlers() returns mutable array; consumer pushes externally |
LOW | Returned type is readonly ForkHookHandler[]; TS catches at compile time. Runtime hot-path returns internal ref for perf — documented in contract §2.4. |
| Async handler causes test flake | LOW | Tests use Promise.resolve() micro-task pattern; no real wall-clock awaits. |
defaultRegistry leaks across jest workers |
LOW | Each test uses new ForkHookRegistry(). T10 reads but does not mutate. |
| Test count exceeds packet target (+10-15) | LOW | 14 tests planned; one block under upper bound. |
| ESLint complains about empty catch block | MEDIUM | Comment explaining the intentional swallow; if linter still complains, name the caught value as _err or use eslint-disable for that one line. |
| Forbidden token in module body | LOW | Visual audit + κ self-scan if needed; the body is small and has no arithmetic / time / random ops. |
§6. Implementation steps
| # | Action | Output |
|---|---|---|
| 4.1 | Create src/domains/consensus/fork-hook.ts per §2 |
New file |
| 4.2 | Create src/__tests__/domains/consensus/fork-hook.test.ts per §3 |
New file |
| 4.3 | npm run build — confirm TS compiles cleanly |
exit 0 |
| 4.4 | npm run lint — confirm ESLint clean |
exit 0 |
| 4.5 | npm test — confirm new tests pass and no regression |
3040 + 14 new passing |
| 4.6 | Commit feat(p3-9-1-fork-hook): ForkHookRegistry + ι handoff stub |
Commit SHA |
§7. Exit criteria for Step 3
- File-level change set declared (2 new files; 0 edits)
- Module body sketched in detail (§2)
- Test plan sketched in detail (§3)
- Verification gates and expected outcomes pinned (§4)
- Risks enumerated with mitigations (§5)
- Step-by-step implementation order (§6)
Packet approved (self-gated per T3 contract). Proceed to Step 4 (implement).