P3.7.1 — θ MCP Tool Surface — Audit

Round: R89 θ Wave 4 Branch: feature/p3-7-1-mcp-tools Worktree: .worktrees/claude/p3-7-1-mcp-tools Base: origin/main @ 9c7165d5 Owner: T3 executor Date: 2026-05-13


§1. Inventory — files in scope

Files to create

  1. src/domains/consensus/tools.ts — 5 MCP tool registrations + handlers.
  2. src/__tests__/domains/consensus/tools.test.ts — in-process tests.

Files to edit

  1. src/server.ts — add registerConsensusTools(ctx) call inside bootstrap(), alongside registerReputationTools(ctx).

Files to read (upstream surfaces — no edits)

Surface File Reuse target
α 5-stage middleware src/server.ts:280-411 (registerColibriTool) Wrapper API
λ tool registration pattern src/domains/reputation/tools.ts:392-442 Direct template
Vote / Commit / Reveal types src/domains/consensus/messages.ts Type import; not invoked
Quorum + thresholds src/domains/consensus/quorum.ts quorumThreshold(1n)===1n used implicitly via FSM
FinalitySM + 5 levels src/domains/consensus/finality.ts FinalityLevel union + FSM for consensus_finality
Round state machine src/domains/consensus/round-state.ts RoundStateLabel reference; not used directly (we use FinalitySM only)
VRF stub src/domains/consensus/vrf-stub.ts:244-250 (vrfEval) + :76-78 (VrfError) Direct wrap for vrf_eval

Files NOT touched (out of scope for P3.7.1)

  • src/db/migrations/* — no new migration. Single-arbiter Phase 0 posture in this slice keeps round state in-memory inside the server context. Consensus.md §Phase 0 posture says the mcp_consensus_votes table is “schema-ready” but the spec does not require a Phase 0 migration; the dispatch packet explicitly bans “mutation tools (Phase 0 single-arbiter is read-trivial)” — so no SQL writes are produced. A future Phase 1.5 BFT activation slice will add the migration and write-through paths.
  • Any other consensus domain file (messages, quorum, finality, vrf-stub, round-state, time-anchors, equivocation, gossip-wire) — read-only consumption only.

§2. Upstream surfaces — function signatures used

From messages.ts (P3.1.1)

export interface Vote extends VoteTuple {
  readonly sender_id: string;
  readonly vote_type: VoteType;
  readonly timestamp_logical: bigint;
  readonly signature: Buffer;
}
export type VoteType = 'ACCEPT' | 'REJECT' | 'ABSTAIN';
export function signMessage(msg: Vote | Commit | Reveal | ViewChange, privateKey: KeyObject): Buffer;

From quorum.ts (P3.1.2)

export function quorumThreshold(n: bigint): bigint;  // (n*2n)/3n + 1n; for n=1n → 1n

From finality.ts (P3.2.1)

export type FinalityLevel = 'PENDING' | 'SOFT' | 'QUORUM' | 'HARD' | 'ABSOLUTE';
export class FinalitySM {
  constructor(round_id: bigint, n_arbiters: bigint, dispute_window_epochs?: bigint);
  current(): FinalityLevel;
  receiveVote(vote: Vote, currentEpoch: bigint): void;
  transitions(): readonly Transition[];
}

From vrf-stub.ts (P3.6.1)

export interface VrfEvalOutput {
  readonly output: Buffer;
  readonly proof: Buffer;
}
export function vrfEval(seed: Buffer, input: Buffer, privKey: Buffer): VrfEvalOutput;
export class VrfError extends Error;

From server.ts

export function registerColibriTool<I extends z.ZodRawShape>(
  ctx: ColibriServerContext,
  name: string,
  toolConfig: ColibriToolConfig<I>,
  handler: (args: z.infer<z.ZodObject<I>>) => Promise<unknown> | unknown,
): void;

§3. MCP surface delta

Phase Count Tools
Pre-R89 14 β (5) + ε (1) + ζ (3) + η (3) + system (2)
Post-λ P2.5.1 18 + λ (4): reputation_get/history/leaderboard/check_gates
Post-θ P3.7.1 (this slice) 23 + θ (5): consensus_propose/vote/finality/gossip + vrf_eval

§4. Single-arbiter Phase 0 posture — interpretation

Per the source prompt §Common gotchas:

Single-arbiter shortcut — DO NOT branch on if (n === 1) return trivial; instead, run the real state machines with n=1, which by construction (quorumThreshold(1n)===1n) yields trivial results. This keeps the code path one path.

Design implication:

  1. n_arbiters = 1n is the only operating regime in Phase 0.
  2. Round state lives in a Map<bigint, RoundEntry> inside the consensus tools module (per-process, not per-context — process-singleton, akin to __logicalClock in messages.ts).
  3. consensus_propose allocates a fresh round_id (monotonic counter, also process-singleton). Returns status: "QUORUM" because the FSM jumps SOFT → QUORUM on the first vote, but consensus_propose does NOT itself record a vote — it allocates the round and waits. Re-read of the source prompt §Acceptance criteria: “consensus_propose accepts proposal; returns {round_id, status: "QUORUM"} immediately when n=1” — interpreted as: in single-arbiter mode, as soon as the first vote arrives, the round will be QUORUM. But the propose call returns synchronously before any vote. Resolution: per the spec wording “trivially finalized”, treat n=1 propose as auto-recording a synthetic ACCEPT vote from the sole arbiter, which moves FSM PENDING → SOFT → QUORUM in one tick. Status returned: "QUORUM".
  4. consensus_vote accepts a vote shape (the 3-tuple + signature), records it under the round, runs FinalitySM.receiveVote, returns the resulting state.
  5. consensus_finality looks up the round’s FSM and returns .current() plus a hex-encoded evidence string from the last transition.
  6. consensus_gossip returns {events_sent: [], events_received: []} — no peers in n=1.
  7. vrf_eval is a stateless wrapper.

Process-singleton state — rationale

messages.ts’s __logicalClock is process-singleton too (one let at module scope). Following the same pattern for the consensus tools’ round registry avoids per-context plumbing and matches the “in-memory by design in Phase 0” framing. Tests can call an exported resetConsensusToolsForTesting() (parallel to resetLogicalForTesting()) to clear state between tests.


§5. Error code taxonomy

Code Tool Trigger
INVALID_INPUT All 5 Zod schema rejection (handled by α middleware Stage 2 — emits INVALID_PARAMS envelope). Handler-level explicit re-throw for finer messages where appropriate.
ALREADY_VOTED consensus_vote Same arbiter signs same (round_id, merkle_root, rule_version_hash) tuple twice. NOT equivocation — equivocation is a distinct tuple from the same arbiter.
ROUND_NOT_FOUND consensus_finality, consensus_vote round_id not in the in-memory registry.
INVALID_KEY vrf_eval privKey malformed (e.g. empty buffer, non-hex, wrong length). Wraps VrfError.

Handler-thrown errors propagate through α middleware Stage 4 as HANDLER_ERROR envelopes. To convey the structured error code, handlers throw Error instances whose message begins with the code (e.g. "ALREADY_VOTED: ...") — the test harness extracts the code with a prefix match.

This matches the in-spec error taxonomy where the dispatch packet says “Error ALREADY_VOTED if same arbiter signs same round twice” without prescribing a wire-level error code field.


§6. Input/output schemas — preliminary shapes

consensus_propose

Input:

{ event: { merkle_root_hex: string, rule_version_hash_hex: string } }

Output:

{ round_id: string, status: 'PENDING' | 'QUORUM' }

merkle_root_hex and rule_version_hash_hex: lowercase hex, 64 chars (32 bytes). Validated by Zod regex.

round_id returned as decimal string (matches κ canonical encoding for bigint).

consensus_vote

Input:

{
  round_id: string,
  vote: {
    merkle_root_hex: string,
    rule_version_hash_hex: string,
    vote_type: 'ACCEPT' | 'REJECT' | 'ABSTAIN',
  },
  privKey?: string  // hex-encoded raw Ed25519 private key; if absent, a deterministic per-process key is used
}

Output:

{ vote_signed: true, sig_b64: string }

privKey optional per the dispatch packet: “input {round_id, vote: {merkle_root, rule_version_hash, vote_type}, privKey?}”. In Phase 0 single-arbiter mode, when omitted we use a process-singleton Ed25519 key pair (generated once per process — not committed to disk, just for the trivial-finalize loop to be cryptographically well-typed).

sig_b64 is base64-encoded — matches “do not log signatures” forbidden (still emits the signature back to the caller, but base64 is the standard MCP shape).

consensus_finality

Input:

{ round_id: string }

Output:

{ round_id: string, level: FinalityLevel, evidence?: string }

evidence is lowercase hex of the last transition’s evidence Buffer (matches “lowercase hex without 0x prefix” gotcha).

consensus_gossip

Input:

{ peer_id: string, events?: string[] }

Output:

{ events_sent: string[], events_received: string[] }

Single-arbiter Phase 0 returns { events_sent: [], events_received: [] } always.

vrf_eval

Input:

{ seed_hex: string, input_hex: string, priv_key_hex: string }

Output:

{ output_hex: string, proof_hex: string }

All hex inputs: even-length lowercase. Zod regex validation.


§7. Test plan summary (full plan in packet)

Group Count Coverage
consensus_propose happy + retry 3 Returns QUORUM in n=1; allocates fresh round_id; re-propose distinct event allocates new id
consensus_vote happy + retry + bad-round 5 Returns signed vote; ALREADY_VOTED on retry same tuple; ROUND_NOT_FOUND on unknown id; Zod rejection on bad hex; non-default privKey accepted
consensus_finality happy + missing 3 Returns QUORUM after propose; ROUND_NOT_FOUND on unknown; evidence hex non-empty
consensus_gossip happy 2 Returns empty arrays in n=1; accepts empty events arg
vrf_eval happy + bad-key 3 Returns deterministic output; INVALID_KEY on empty hex; Zod rejection on non-hex
Registration 1 All 5 tools registered after registerConsensusTools(ctx)
Mode gate 1 All 5 tools available in any phase (matches “all tools available in all modes” Phase 0 advisory)

Target: 18–25 tests; expect baseline 2970 + ~25 = ~2995.


§8. Forbidden-token policy

The consensus domain has a corpus-wide self-scan (per messages.ts, quorum.ts, finality.ts, vrf-stub.ts JSDoc) banning Math.*, Date.*, Math.random, process.hrtime, performance.now, setTimeout, setInterval, and dotted-form crypto access. The new tools.ts body MUST also be free of these tokens. Buffer encoding uses Node’s Buffer.from(_, 'hex') and .toString('hex'), which are not on the forbidden list.

This means the current_epoch parameter that FinalitySM.receiveVote requires must be supplied by the caller (not read from a clock). The propose path will pass 1n as the synthetic epoch; consensus_vote will pass 1n + (in-memory vote counter) to keep the FSM’s epoch monotonic across QUORUM → HARD attempts.


§9. Risks + open questions

# Risk Mitigation
R1 privKey optional in dispatch packet’s input shape, but signing needs one. Generate a process-singleton key pair at module load (deterministic seed under test mode).
R2 consensus_propose returning QUORUM contradicts FSM which is PENDING until a vote arrives. Propose synthesizes an internal ACCEPT vote from the sole arbiter (n=1), so FSM steps PENDING → SOFT → QUORUM before propose returns.
R3 Process-singleton state leaks across tests. Export resetConsensusToolsForTesting(); call in beforeEach.
R4 messages.ts Ed25519 paths require KeyObject, not raw hex. Use createPrivateKey({ key: privKeyBuf, format: 'der', type: 'pkcs8' }) or a similar canonical decoder; if input is raw 32-byte hex, wrap via crypto.createPrivateKey({ key, format: 'der', type: 'pkcs8' }). The simplest path: stash a generated KeyObject in the registry alongside the round, signed votes use it.
R5 Round registry size unbounded. Acceptable in Phase 0; tests reset between cases; production has only single-arbiter rounds = single in-flight FSM (the just-proposed one).

R1, R4 resolution: handler internally generates a KeyObject pair via generateKeyPairSync('ed25519') and uses the private side for signing. The privKey field in the input is accepted but ignored in Phase 0 (it’s a forward-compat passthrough). This matches the ADR-005 stub precedent (input shape is the Phase 1.5 shape, behavior is Phase 0 single-arbiter trivial).


§10. Sign-off

Audit reviewed against:

  • Dispatch packet (this session’s user message)
  • Source prompt §P3.7.1 (lines 2036–2210)
  • src/server.ts (Phase 0 boot + middleware)
  • src/domains/reputation/tools.ts (most-recent MCP tool registration pattern, λ P2.5.1)
  • src/domains/consensus/{messages,quorum,finality,vrf-stub,round-state,time-anchors,gossip-wire,equivocation}.ts
  • docs/3-world/physics/laws/consensus.md §Phase 0 posture

Step 2 (contract) gates on this audit.


Back to top

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

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