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
src/domains/consensus/tools.ts— 5 MCP tool registrations + handlers.src/__tests__/domains/consensus/tools.test.ts— in-process tests.
Files to edit
src/server.ts— addregisterConsensusTools(ctx)call insidebootstrap(), alongsideregisterReputationTools(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 themcp_consensus_votestable 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 withn=1, which by construction (quorumThreshold(1n)===1n) yields trivial results. This keeps the code path one path.
Design implication:
n_arbiters = 1nis the only operating regime in Phase 0.- Round state lives in a
Map<bigint, RoundEntry>inside the consensus tools module (per-process, not per-context — process-singleton, akin to__logicalClockinmessages.ts). consensus_proposeallocates a freshround_id(monotonic counter, also process-singleton). Returnsstatus: "QUORUM"because the FSM jumps SOFT → QUORUM on the first vote, butconsensus_proposedoes NOT itself record a vote — it allocates the round and waits. Re-read of the source prompt §Acceptance criteria: “consensus_proposeaccepts proposal; returns{round_id, status: "QUORUM"}immediately whenn=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".consensus_voteaccepts a vote shape (the 3-tuple + signature), records it under the round, runsFinalitySM.receiveVote, returns the resulting state.consensus_finalitylooks up the round’s FSM and returns.current()plus a hex-encoded evidence string from the last transition.consensus_gossipreturns{events_sent: [], events_received: []}— no peers in n=1.vrf_evalis 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}.tsdocs/3-world/physics/laws/consensus.md§Phase 0 posture
Step 2 (contract) gates on this audit.