Execution packet — P3.2.1 Finality State Machine

Branch: feature/p3-2-1-finality-sm Worktree: .worktrees/claude/p3-2-1-finality-sm Base: origin/main @ 498e6ea5


1. File-by-file plan

src/domains/consensus/finality.ts — NEW

Sections:

  1. JSDoc header — module-purpose, pure-module guarantees, citations, forbidden-token self-scan declaration.
  2. Imports: type Vote from './messages.js', hasQuorum, voteGroupKey from ./quorum.js, canonicalize from ../rules/canonical.js.
  3. Error classes: FinalityRollbackError, PrematureExternalEffectError.
  4. Type exports: FinalityLevel, FINALITY_ORDER, Transition.
  5. Helper: levelIndex(level)FINALITY_ORDER.indexOf(level) wrapped for clarity.
  6. Helper: rewriteBuffersToHex(v) — local replica of messages.ts pre-pass; recursive walk.
  7. Helper: encodeEvidence(value) — Buffer-rewrite + κ canonicalize + Buffer.from(_, 'utf8').
  8. class FinalitySM with the public surface from the contract.
  9. Private helpers: __advance(target, epoch, evidence), __tryAdvanceFromPending, __tryAdvanceToQuorum, __tryAdvanceToHard.
  10. End-of-file marker.

Estimated LOC: 220-260.

src/__tests__/domains/consensus/finality.test.ts — NEW

Sections:

  1. JSDoc with traceability to contract AC#1-AC#25.
  2. Imports: types from messages.js, FinalitySM and exports from finality.js, readFileSync for self-scan.
  3. Helper: makeVote(...) — identical pattern to quorum.test.ts.
  4. Helper: signedTriple(rootTag, rvTag) — fingerprints.
  5. AC#1-AC#25 in describe blocks, organized:
    • “FINALITY_ORDER” (AC#1)
    • “constructor” (AC#2, AC#18, AC#19, AC#20)
    • “PENDING → SOFT” (AC#3, AC#24)
    • “SOFT → QUORUM” (AC#4, AC#5)
    • “QUORUM → HARD” (AC#6, AC#7, AC#8, AC#23)
    • “HARD → ABSOLUTE” (AC#9, AC#10, AC#11)
    • “monotonicity” (AC#12)
    • “requireExternalEffectsAllowed” (AC#13, AC#14)
    • “single arbiter n=1” (AC#15)
    • “transitions()” (AC#16, AC#17, AC#22)
    • “evidence determinism” (AC#25)
    • “corpus self-scan” (AC#21)

Estimated LOC: 480-560; tests: 28-35.


2. Build order

  1. Write finality.ts skeleton (types, errors, FINALITY_ORDER export).
  2. npm run build — surface zero.
  3. Implement class FinalitySM body.
  4. npm run build again — must pass strict TS.
  5. Write finality.test.ts.
  6. npm test -- --testPathPattern=consensus/finality — iterate until green.
  7. npm run build && npm run lint && npm test — full gate.

3. Risk register

Risk Mitigation
canonicalize exporting non-Buffer already exports string; we wrap with Buffer.from(_, 'utf8').
Vote round_id != FSM round_id contract says silently skip; test AC#24 documents.
Phase 3 corpus self-scan accidentally matches JSDoc text the AC#21 test reads source and strips block comments before regex; mirrors messages.test.ts AC#9 pattern.
Floats in test code avoided — all bigints.
Math.random accidentally introduced via test helper LCG pattern from quorum.test.ts reused — bigint-only.
Buffer comparison via === use Buffer.compare(a, b) === 0 consistently.
observeEquivocation racing with QUORUM achievement The contract says: the FSM checks equivocationObservedSinceLastQuorum ONLY during QUORUM→HARD attempts. Equivocation observed BEFORE first QUORUM doesn’t matter (we go through QUORUM regardless because the equivocation hasn’t yet been “between two QUORUM moments”). Reset flag every time we re-snapshot a new QUORUM.
lastQuorumEvidence updating mid-round Once at QUORUM, additional same-epoch votes are no-ops at the FSM level (the FSM is at QUORUM; staying at QUORUM doesn’t generate transitions).
Cross-epoch quorum boundary detection We compare currentEpoch > lastQuorumEvidence.epoch so the same-epoch additional matching votes do NOT trigger HARD.

4. Test strategy specifics

Cross-epoch flow (AC#6)

const fsm = new FinalitySM(round_id=1n, n=4n);  // window default 100n

// Epoch 10 — quorum at triple T1
fsm.receiveVote(voteA1, 10n);  // PENDING→SOFT
fsm.receiveVote(voteB1, 10n);  // still SOFT
fsm.receiveVote(voteC1, 10n);  // SOFT→QUORUM (3 ≥ threshold(4)=3)
expect(fsm.current()).toBe('QUORUM');

// Epoch 11 — quorum at same triple T1
fsm.receiveVote(voteA2, 11n);  // still QUORUM (already at QUORUM); but now sender_id A2 ≠ A1, so its triple matches T1 fresh quorum candidacy
// ...

Subtle: a “second round” in the consensus sense corresponds to a NEW set of arbiter signatures over the same triple in a later epoch. The FSM’s votesBySender map should NOT be cleared automatically — votes are append-only. The “two consecutive rounds” check is: at QUORUM, has a sufficient set of votes been observed in a strictly-later epoch?

Decision: re-read the contract. The HARD condition is:

“After QUORUM is reached, if a strictly-later currentEpoch arrives with a fresh quorum on the SAME triple AND no equivocation observed, transition to HARD.”

To make “fresh quorum” meaningful with append-only state, the FSM must track the highest epoch at which each sender’s matching-triple vote was observed. A vote (senderX, epoch=11, tripleT1) counts toward “round 2 quorum” if epoch > lastQuorumEvidence.epoch.

Actually simpler: the FSM tracks votesBySenderByEpoch: Map<senderId, Map<epoch, Vote>> for the current matching triple — when a vote arrives at epoch e > lastQuorumEvidence.epoch and triple === lastQuorumEvidence.triple, increment a “round-N+1 count” for that triple at that epoch. When count >= threshold at some e_2 > e_1, transition to HARD.

Even simpler implementation: when QUORUM is reached at epoch E1 with triple T1, snapshot (E1, T1, threshold_count). Then on every subsequent receiveVote(v, E2) where E2 > E1 AND voteGroupKey(v) === T1:

  • Add v.sender_id to secondRoundSenders if not present.
  • If secondRoundSenders.size >= threshold AND !equivocationObservedSinceLastQuorum, transition QUORUM→HARD with epoch = E2.

For the test, that means a clear 4-vote, 2-epoch trace produces HARD at the 4th vote (3rd second-epoch vote).

Single-arbiter n=1 (AC#15)

const fsm = new FinalitySM(round_id=1n, n=1n);  // threshold(1)=1
fsm.receiveVote(vR1, epoch=10n);
// PENDING → SOFT (first vote), then SOFT → QUORUM (hasQuorum([v], 1)=true)
expect(fsm.current()).toBe('QUORUM');
expect(fsm.transitions()).toHaveLength(2);

// Second epoch, same arbiter, same triple
fsm.receiveVote(vR2, epoch=11n);  // distinct vote object, same sender, same triple
// secondRoundSenders now has {sender_id} after epoch 11 > 10
// size === 1 >= threshold(1)=1
// → HARD
expect(fsm.current()).toBe('HARD');
expect(fsm.transitions()).toHaveLength(3);

// Window: default 100n. Need sealEpoch at epoch >= hardEpoch + 100.
fsm.sealEpoch(epoch=111n, sealRoot);
expect(fsm.current()).toBe('ABSOLUTE');
expect(fsm.transitions()).toHaveLength(4);

The vR1 vote and vR2 vote have the same sender_id because n=1 — there’s only one arbiter. The “different vote” is the implicit second-round signature. The FSM accepts BOTH as legitimate because:

  • First vote arrives in epoch 10 — counted into votesBySender.
  • Second vote arrives in epoch 11 — counted into secondRoundSenders if epoch > E1 AND triple matches.

The sender appearing in BOTH is fine — the spec calls for “two consecutive rounds reached QUORUM” not “two distinct sets of arbiters”.

Dispute window (AC#9, AC#20)

const fsm = new FinalitySM(1n, 1n);   // default 100n window
// ... reach HARD at hardEpoch ...
fsm.sealEpoch(hardEpoch + 99n, sealRoot);  // no-op
fsm.sealEpoch(hardEpoch + 100n, sealRoot); // ABSOLUTE
const fsm = new FinalitySM(1n, 1n, 50n);  // custom 50n window
// ... HARD at hardEpoch ...
fsm.sealEpoch(hardEpoch + 50n, sealRoot);  // ABSOLUTE

Forbidden-token self-scan (AC#21)

Pattern from messages.test.ts:

test('finality.ts body uses none of κ-forbidden tokens', () => {
  const src = readFileSync(
    new URL('../../../domains/consensus/finality.ts', import.meta.url),
    'utf8',
  );
  // Strip block comments (heuristic: /* ... */ across lines)
  const body = src.replace(/\/\*[\s\S]*?\*\//g, '');
  // Strip line comments
  const cleaned = body.replace(/\/\/.*$/gm, '');
  expect(cleaned).not.toMatch(/Math\.[A-Za-z]/);
  expect(cleaned).not.toMatch(/Date\./);
  expect(cleaned).not.toMatch(/Math\.random/);
  expect(cleaned).not.toMatch(/setTimeout/);
  expect(cleaned).not.toMatch(/setInterval/);
  expect(cleaned).not.toMatch(/setImmediate/);
  expect(cleaned).not.toMatch(/process\.hrtime/);
  expect(cleaned).not.toMatch(/performance\.now/);
});

5. Commit sequence

# Step Commit message
1 Audit audit(p3-2-1-finality-sm): inventory surface (already committed)
2 Contract contract(p3-2-1-finality-sm): behavioral contract (already committed)
3 Packet packet(p3-2-1-finality-sm): execution plan
4 Implement feat(p3-2-1-finality-sm): monotonic 5-level finality FSM with HARD-gate side effects
5 Verify verify(p3-2-1-finality-sm): test evidence

5 commits total.


6. Approval

Step 3 of 5 complete. Proceeding 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.