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

  1. Implement src/domains/consensus/quorum.ts — pure module, 5 exported functions plus QuorumError and the voteGroupKey helper.
  2. Implement src/__tests__/domains/consensus/quorum.test.ts — 28 acceptance criteria across ~30 it() blocks plus a corpus self-scan.
  3. Run npm run build (zero TS errors) → npm run lint (zero warnings) → npm test (zero failures, +20-30 tests above 2687 baseline).
  4. Commit feat(p3-1-2-quorum): BFT quorum thresholds + honest-majority intersection.
  5. Write docs/verification/p3-1-2-quorum-verification.md with the AC trace and test output excerpt.
  6. Commit verify(p3-1-2-quorum): test evidence.
  7. 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 is number-keyed), NOT a logical bigint count. This is consistent with messages.ts:288-300 where the same pattern appears for for (const k of Object.keys(...)) etc.
  • (byKey.get(k))! — non-null assertion safe because key came from byKey.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:

  1. Pick n[4n, 100n] via lcgRange.
  2. Build the universe [1, n] as string[] of arbiter IDs.
  3. Draw two quorum-sized subsets by Fisher-Yates-style shuffles under the LCG (also bigint-safe).
  4. Assert intersect(A, B).size >= 1n AND that the structural invariant q + 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 -5 showing 4 chain commits + the verify commit (5 total).
  • npm run build output excerpt.
  • npm run lint output excerpt.
  • npm test output 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).


Back to top

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

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