P3.1.2 — Quorum Computation — Execution Packet
Step 3 of the 5-step chain. This is the gate before Step 4 (implement).
§1. Order of work
- Implement
src/domains/consensus/quorum.ts— pure module, 5 exported functions plusQuorumErrorand thevoteGroupKeyhelper. - Implement
src/__tests__/domains/consensus/quorum.test.ts— 28 acceptance criteria across ~30it()blocks plus a corpus self-scan. - Run
npm run build(zero TS errors) →npm run lint(zero warnings) →npm test(zero failures, +20-30 tests above 2687 baseline). - Commit
feat(p3-1-2-quorum): BFT quorum thresholds + honest-majority intersection. - Write
docs/verification/p3-1-2-quorum-verification.mdwith the AC trace and test output excerpt. - Commit
verify(p3-1-2-quorum): test evidence. - Push branch; open PR with body containing the writeback block.
§2. File 1: src/domains/consensus/quorum.ts
2.1 Layout
src/domains/consensus/quorum.ts
├── §1. File-level JSDoc (mirrors messages.ts pattern)
├── §2. Imports — type-only Vote
├── §3. QuorumError class
├── §4. quorumThreshold(n)
├── §5. maxFaulty(n)
├── §6. voteGroupKey(v) — exported helper
├── §7. hasQuorum(votes, n)
├── §8. intersect(a, b)
└── §9. detectDoubleVote(arbiterId, roundId, votes)
2.2 Imports
import type { Vote } from './messages.js';
Type-only. Vote is the structural shape; no value imports. No
node:crypto, no node:fs, no node:path.
2.3 Function bodies (pseudocode)
export class QuorumError extends Error {
override readonly name = 'QuorumError';
}
function assertPositiveN(n: bigint): void {
if (n < 1n) {
throw new QuorumError(`n must be >= 1n; got ${n.toString()}`);
}
}
export function quorumThreshold(n: bigint): bigint {
assertPositiveN(n);
return (n * 2n) / 3n + 1n;
}
export function maxFaulty(n: bigint): bigint {
assertPositiveN(n);
return (n - 1n) / 3n;
}
export function voteGroupKey(v: Vote): string {
return `${v.round_id.toString()}|${v.merkle_root.toString('hex')}|${v.rule_version_hash.toString('hex')}`;
}
export function hasQuorum(votes: readonly Vote[], n: bigint): boolean {
const threshold = quorumThreshold(n);
if (votes.length === 0) {
return false;
}
const counts = new Map<string, bigint>();
for (const v of votes) {
const k = voteGroupKey(v);
counts.set(k, (counts.get(k) ?? 0n) + 1n);
}
let largest = 0n;
for (const c of counts.values()) {
if (c > largest) largest = c;
}
return largest >= threshold;
}
export function intersect(a: ReadonlySet<string>, b: ReadonlySet<string>): Set<string> {
const out = new Set<string>();
for (const x of a) {
if (b.has(x)) out.add(x);
}
return out;
}
export function detectDoubleVote(
arbiterId: string,
roundId: bigint,
votes: readonly Vote[],
): Array<readonly [Vote, Vote]> {
// §1. Filter to this arbiter+round.
const mine: Vote[] = [];
for (const v of votes) {
if (v.sender_id === arbiterId && v.round_id === roundId) {
mine.push(v);
}
}
// §2. Group by canonical sub-tuple key.
const byKey = new Map<string, Vote>();
for (const v of mine) {
const k = voteGroupKey(v);
if (!byKey.has(k)) {
byKey.set(k, v);
}
}
// §3. If <2 distinct keys, no equivocation.
const keys = [...byKey.keys()];
if (keys.length < 2) return [];
const reps = keys.map((k) => byKey.get(k)!);
// §4. Emit every pair (i, j) with i < j.
const out: Array<readonly [Vote, Vote]> = [];
for (let i = 0; i < reps.length; i++) {
for (let j = i + 1; j < reps.length; j++) {
out.push([reps[i]!, reps[j]!] as const);
}
}
return out;
}
Notes:
readonly Vote[]accepted on signature to allow callers passing frozen arrays.- Inner counter loops use
let i = 0(number) because they index a JS array (which isnumber-keyed), NOT a logical bigint count. This is consistent withmessages.ts:288-300where the same pattern appears forfor (const k of Object.keys(...))etc. (byKey.get(k))!— non-null assertion safe because key came frombyKey.keys().- The
for (const c of counts.values())loop returns the largest count in a single pass — O(V) where V =votes.length.
2.4 Forbidden-token compliance
Source body of quorum.ts MUST NOT contain:
Math.(literal substring)Date.(literal substring)await(no async)require((no CJS)process.(no env)console.(no output)- File-system / network / crypto imports
The corpus-scan test (AC#27) will assert these.
§3. File 2: src/__tests__/domains/consensus/quorum.test.ts
3.1 Layout
src/__tests__/domains/consensus/quorum.test.ts
├── Imports — Vote helpers (createVote builder) + the module under test
├── Group 1 — QuorumError class (AC#28)
├── Group 2 — quorumThreshold worked table (AC#1, AC#3, AC#6)
├── Group 3 — maxFaulty worked table (AC#2, AC#4, AC#7)
├── Group 4 — Honest-majority invariant property test (AC#8, AC#9)
├── Group 5 — voteGroupKey (AC#26)
├── Group 6 — hasQuorum (AC#5, AC#10, AC#11, AC#12, AC#13, AC#14, AC#15)
├── Group 7 — intersect (AC#16-AC#20)
├── Group 8 — detectDoubleVote (AC#21-AC#25)
└── Group 9 — Forbidden-token corpus self-scan (AC#27)
3.2 Vote builder helper
The test file needs to fabricate Vote values. We hand-build a
createVote() helper that produces deterministic Buffers without
calling signMessage (Ed25519 signing is unnecessary — these tests
operate on the SUB-tuple, not the signature). The signature Buffer is
filled with deterministic 32-zero bytes since no test verifies it.
function createVote(args: {
sender_id: string;
round_id: bigint;
root: string; // hex tag for distinct merkle_root values
rv?: string; // hex tag for rule_version_hash (default 'rv1')
vote_type?: VoteType;
}): Vote {
return {
sender_id: args.sender_id,
round_id: args.round_id,
merkle_root: Buffer.from(args.root.padEnd(64, '0'), 'hex'),
rule_version_hash: Buffer.from((args.rv ?? 'rv1').padEnd(64, '0').replace(/[^0-9a-f]/g, '0'), 'hex'),
vote_type: args.vote_type ?? 'ACCEPT',
timestamp_logical: 0n,
signature: Buffer.alloc(64),
};
}
(Note: the test builder will use deterministic hex-fragment padding so
two distinct root tags hash to distinct Buffer.toString(‘hex’) keys.)
3.3 Property-test PRNG
Seeded LCG matching the contract §11:
let __seed = 0xC4F3F0DEn;
const M = 0xFFFFFFFFFFFFFFFFn;
function lcg(): bigint {
__seed = (__seed * 6364136223846793005n + 1442695040888963407n) & M;
return __seed;
}
function lcgRange(lo: bigint, hi: bigint): bigint {
const span = hi - lo + 1n;
return lo + (lcg() % span);
}
function resetSeed(s: bigint = 0xC4F3F0DEn): void { __seed = s; }
Test file resets the seed before each property-test block to keep
runs deterministic across jest --runInBand and CI parallel runners.
3.4 Honest-majority property generator
For each iteration:
- Pick
n∈[4n, 100n]vialcgRange. - Build the universe
[1, n]asstring[]of arbiter IDs. - Draw two quorum-sized subsets by Fisher-Yates-style shuffles under the LCG (also bigint-safe).
- Assert
intersect(A, B).size >= 1nAND that the structural invariantq + f === n + 1n - (n % 3n === 0n ? 1n : 0n)holds.
200 iterations. Fixed seed ⇒ reproducible.
3.5 Forbidden-token scan (mirrors messages.test.ts pattern)
Load the compiled quorum.ts source via readFile (already a
pattern used by messages.test.ts Group 12). Strip JSDoc comments,
then assert NO occurrence of Math., Date., await, require(,
process., console.. Whitelist : number ONLY where it appears as
a loop index NOT touching counts (the contract permits this for
for (let i = 0; i < arr.length; i++) JS-array indexing).
Decision: do NOT include : number in the forbidden list because
inner JS-array loops legitimately use number indices per the
prevailing convention in messages.ts (visible at line 296: for
(const k of Object.keys(value as Record<string, unknown>))).
§4. Implementation order (Step 4)
1. Create src/domains/consensus/quorum.ts
2. npm run build → expect 0 errors
3. Create src/__tests__/domains/consensus/quorum.test.ts
4. npm run build → expect 0 errors
5. npm run lint → expect 0 warnings
6. npm test → expect +20-30 tests over 2687
7. Commit: `feat(p3-1-2-quorum): BFT quorum thresholds + honest-majority intersection`
§5. Verification (Step 5 plan)
After Step 4 commits cleanly, Step 5 writes
docs/verification/p3-1-2-quorum-verification.md capturing:
git log --oneline | head -5showing 4 chain commits + the verify commit (5 total).npm run buildoutput excerpt.npm run lintoutput excerpt.npm testoutput excerpt with the final summary line (suites, tests, snapshots).- AC traceability table (28 ACs → test names).
- Test-delta line:
Tests: 2686 → <N>proving +20-30.
§6. Risk / mitigations
| Risk | Mitigation |
|---|---|
| Vote signature Buffer.alloc(64) misinterpreted as valid Ed25519 | Tests don’t call verifySignature; only structural fields matter for quorum |
voteGroupKey collisions on edge-case hex |
Buffers are fixed 32-byte sizes from messages.ts (sha256 outputs), so prefix collision impossible |
| LCG state leaking across tests | beforeEach resets seed; PRNG is module-local |
npm run lint rejecting non-null assertions byKey.get(k)! |
Project ESLint config allows non-null assertions when invariant is comment-justified; if it errors, replace with explicit ?? undefined ladder |
for-loop index let i = 0 flagged |
Already in messages.ts; convention permits JS-array indices |
| Test file too large breaking Jest worker | 200 LCG iterations is fast; total file ~300 LOC; comfortably under limits |
§7. Step 3 conclusion
Plan is concrete. All function bodies written out as pseudocode. Acceptance corpus enumerated. PRNG choice documented. Ready for Step 4 (implement).