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:
- JSDoc header — module-purpose, pure-module guarantees, citations, forbidden-token self-scan declaration.
- Imports:
type Vote from './messages.js',hasQuorum,voteGroupKeyfrom./quorum.js,canonicalizefrom../rules/canonical.js. - Error classes:
FinalityRollbackError,PrematureExternalEffectError. - Type exports:
FinalityLevel,FINALITY_ORDER,Transition. - Helper:
levelIndex(level)—FINALITY_ORDER.indexOf(level)wrapped for clarity. - Helper:
rewriteBuffersToHex(v)— local replica ofmessages.tspre-pass; recursive walk. - Helper:
encodeEvidence(value)— Buffer-rewrite + κ canonicalize +Buffer.from(_, 'utf8'). class FinalitySMwith the public surface from the contract.- Private helpers:
__advance(target, epoch, evidence),__tryAdvanceFromPending,__tryAdvanceToQuorum,__tryAdvanceToHard. - End-of-file marker.
Estimated LOC: 220-260.
src/__tests__/domains/consensus/finality.test.ts — NEW
Sections:
- JSDoc with traceability to contract AC#1-AC#25.
- Imports: types from
messages.js,FinalitySMand exports fromfinality.js,readFileSyncfor self-scan. - Helper:
makeVote(...)— identical pattern toquorum.test.ts. - Helper:
signedTriple(rootTag, rvTag)— fingerprints. - AC#1-AC#25 in
describeblocks, 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
- Write
finality.tsskeleton (types, errors, FINALITY_ORDER export). npm run build— surface zero.- Implement
class FinalitySMbody. npm run buildagain — must pass strict TS.- Write
finality.test.ts. npm test -- --testPathPattern=consensus/finality— iterate until green.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
currentEpocharrives 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
secondRoundSendersif not present. - If
secondRoundSenders.size >= thresholdAND!equivocationObservedSinceLastQuorum, transition QUORUM→HARD withepoch = 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
secondRoundSendersif 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).