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).


Back to top

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

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