P2.5.1 — Reputation Query MCP Tools — Audit

Slice: p2-5-1-tools (R89 Wave 4 — closes λ Phase 2 at 7/7, first λ MCP surface) Branch: feature/p2-5-1-tools Worktree: .worktrees/claude/p2-5-1-tools Base: origin/main @ 618b1a13 (P2.3.1 close, 6/7 λ Phase 2 shipped) β task ID: 48536cf6-d65c-418e-a9af-1302a2a7057b

1. Scope

Ship 4 read-only MCP tools that compose the prior 6 λ Phase 2 outputs (P2.1.1 schema, P2.1.2 compute, P2.2.1 decay, P2.2.2 penalties, P2.3.1 tokens, P2.4.1 limits) into the MCP surface as the first λ-axis surface. After this PR merges, the MCP tool count moves from 14 → 18.

  • reputation_get — single-row or 5-row decay-applied snapshot
  • reputation_history — paginated history events (epoch DESC, id DESC)
  • reputation_leaderboard — top N nodes by decayed score per domain
  • reputation_check_gates — composed capability gates across 5 domains

2. Inventory of upstream λ surface (all shipped on origin/main)

File Role Symbols exercised by P2.5.1
src/domains/reputation/schema.ts (P2.1.1) Row shapes, DB readers Domain, DOMAINS, DomainSchema, ReputationRow, ReputationHistoryRow, selectReputation(db, node_id, domain?), selectHistory(db, node_id, domain, opts)
src/domains/reputation/compute.ts (P2.1.2) Pure score fold not directly invoked by P2.5.1 — score is read from the reputations row + decay; the fold runs at write time (not in this slice)
src/domains/reputation/decay.ts (P2.2.1) Pure lazy decay apply_decay(row, current_epoch), apply_decay_batch(rows, current_epoch)
src/domains/reputation/penalties.ts (P2.2.2) Penalty + scar engine not directly invoked — scar_bps/ban_until_epoch are simply forwarded from the row
src/domains/reputation/tokens.ts (P2.3.1) Token issuance not directly invoked in P2.5.1 (gates only need rep rows)
src/domains/reputation/limits.ts (P2.4.1) Capability derivations max_parallel_tasks(rep), rate_limit_bonus(rep, base_rate), stake_discount(stake, rep), can_arbitrate(rep_arb, rep_exec, ce), can_govern(rep_gov, ce)
src/domains/reputation/witnesses.ts (R89 Wave 3 helper) Witness registry not in this slice
src/db/migrations/007_reputation.sql DB schema tables reputations (PK (node_id, domain)) + reputation_history (PK id AUTOINCREMENT); index idx_reputations_leaderboard ON (domain, score DESC) already shipped — sufficient for the leaderboard tool

3. ε / MCP registration pattern (canonical)

The prompt asks to “match the κ admission tool registration (R87 P1.4.1)” — but κ P1.4.1 is a library module, not an MCP-tool registration. src/domains/rules/admission.ts exports evaluateAdmission(...) and src/domains/rules/tool-lock-adapter.ts exports createToolLockAdapter(...) — both shipped as Phase 0 libraries with no MCP tool registration (consistent with κ being colibri_code: partial / library-only at the MCP surface).

The actual canonical MCP-tool registration pattern in Phase 0 lives in:

  • src/tools/health.tsregisterHealthTool(ctx) — synchronous sync handler example
  • src/tools/merkle.tsregisterMerkleTools(ctx) — multi-tool registration in one function
  • src/domains/trail/repository.ts:404registerThoughtTools(ctx) — DB-bound handler with getDb() lazy resolution
  • src/domains/trail/verifier.tsregisterVerifyChainTool(ctx)
  • src/domains/skills/repository.tsregisterSkillTools(ctx)
  • src/domains/tasks/repository.tsregisterTaskTools(ctx)

All call registerColibriTool(ctx, name, { title, description, inputSchema }, handler) from src/server.ts:279. The handler may be sync or async; it returns a raw data payload (the α 5-stage middleware wraps the return in the { ok, data } envelope at server.ts:354). Tool registrations are wired in bootstrap() at src/server.ts:537-575.

Therefore the registration glue for P2.5.1:

// src/domains/reputation/tools.ts
export function registerReputationTools(ctx: ColibriServerContext): void {
  registerColibriTool(ctx, 'reputation_get',       { ... }, handler1);
  registerColibriTool(ctx, 'reputation_history',   { ... }, handler2);
  registerColibriTool(ctx, 'reputation_leaderboard',{ ... }, handler3);
  registerColibriTool(ctx, 'reputation_check_gates',{ ... }, handler4);
}

…wired into bootstrap() next to registerMerkleTools(ctx) in src/server.ts:575.

4. Resolution of current_epoch source (prompt gotcha)

The source prompt forbids Date.now() and the existing apply_decay(row, current_epoch) signature requires current_epoch: bigint. The prompt explicitly flags this gotcha and asks to “make current_epoch an optional input to reputation_get and reputation_leaderboard too (Zod-validated)”.

Decision (documented for future readers):

  • reputation_check_gates takes current_epoch: number (REQUIRED) — already in the source spec.
  • reputation_get takes current_epoch: number (REQUIRED) — needed for decay; matches the integration test on lines 1192-1195 of the source prompt which writes events at epochs 100-104 and reads at epoch 104 and epoch 200.
  • reputation_leaderboard takes current_epoch: number (REQUIRED) — same reason.
  • reputation_history does NOT take current_epoch — it returns raw history rows, no decay applies.

Required (not optional) was chosen because the alternative — falling back to max(last_activity_epoch) — introduces non-deterministic global state read into an otherwise pure path and could mask caller error. The κ tools’ pattern of always taking the epoch as input matches the determinism posture documented in src/domains/rules/admission.ts and src/domains/reputation/limits.ts:I5 (no clock).

Wire is current_epoch: z.number().int().nonnegative() for serialization compatibility (bigint at JSON boundary would need a custom codec); the handler bridges to BigInt(current_epoch) before calling apply_decay / can_arbitrate / can_govern.

5. Zod input schema design

All four schemas use z.object({...}).strict() for input rejection of unknown keys (matches src/domains/trail/repository.ts:267 pattern for thought_record’s schema). Output schemas defined but not passed to registerColibriTool (matches src/tools/health.ts:91-106 comment about α-envelope double-wrapping).

const ReputationGetInput = z.object({
  node_id: z.string().min(1),
  domain: DomainSchema.optional(),
  current_epoch: z.number().int().nonnegative(),
}).strict();

const ReputationHistoryInput = z.object({
  node_id: z.string().min(1),
  domain: DomainSchema,
  limit: z.number().int().min(1).max(500).optional(),
  offset: z.number().int().nonnegative().optional(),
}).strict();

const ReputationLeaderboardInput = z.object({
  domain: DomainSchema,
  limit: z.number().int().min(1).max(1000).optional(),
  current_epoch: z.number().int().nonnegative(),
}).strict();

const ReputationCheckGatesInput = z.object({
  node_id: z.string().min(1),
  current_epoch: z.number().int().nonnegative(),
}).strict();

6. Output payload shapes

reputation_get: when domain is provided, returns { row: ReputationRow | null } (after decay). When omitted, returns { rows: ReputationRow[] } (after decay batch). score field is number (bps in [0, 10000], well inside Number.MAX_SAFE_INTEGER).

reputation_history: returns { events: ReputationHistoryRow[] }.

reputation_leaderboard: returns { rows: ReputationRow[] } (top N by decayed score DESC).

reputation_check_gates: returns { can_arbitrate: boolean, can_govern: boolean, max_parallel_tasks: number, rate_limit_bonus_factor: number, effective_stake_bps: number }.

For reputation_check_gates, max_parallel_tasks is Number(bigint) from limits.max_parallel_tasks. The two remaining fields are derived helpers — rate_limit_bonus_factor is Number(limits.rate_limit_bonus(rep_exec, BPS_100_PERCENT)) (the per-1.00× base; consumer multiplies by their actual base rate); effective_stake_bps is Number(limits.stake_discount(BPS_100_PERCENT, rep_exec)) (the effective stake when the input is 1.00× / 10000n bps). Both are clamped to Number.MAX_SAFE_INTEGER safety via bigint → number, which is safe for any value ≤ 2^53.

7. Decay-on-read mechanics

  • reputation_get(node_id, domain?, current_epoch) reads one or all 5 rows via selectReputation(db, node_id, domain?), applies apply_decay(row, BigInt(current_epoch)) to each, and returns the decayed snapshots. Per decay.ts:124, apply_decay short-circuits to the input ref when inactive_epochs === 0n (no allocation when caller’s epoch matches last_activity_epoch).
  • reputation_leaderboard(domain, limit, current_epoch) SELECTs from reputations WHERE domain = ? ORDER BY score DESC LIMIT ? (uses idx_reputations_leaderboard), then applies apply_decay_batch to the result. The DESC ordering may shift after decay because nodes with older last_activity_epoch decay more — therefore we re-sort the batch by decayed score DESC after apply_decay_batch. This is the documented O(N·limit) cost noted in the source-prompt gotcha. We additionally fetch a small overshoot (limit * 2, capped at 200) and slice to limit after re-sort, to limit the chance of a “decayed-out” row stealing the top-N slot from a row not in the initial SELECT. Caveat: this is a best-effort approximation — the rigorous fix is a batch decay job (deferred to Phase 6+ per source prompt). We document this in the PR body.

8. reputation_check_gates composition

const rows = selectReputation(db, node_id);  // ReputationRow[]
const byDomain = new Map<Domain, ReputationRow>(rows.map(r => [r.domain, r]));
const fallback = (d: Domain): ReputationRow => ({ node_id, domain: d, score: 0, scar_bps: 0, ban_until_epoch: null, last_activity_epoch: current_epoch });
const rep_arb  = byDomain.get('arbitration') ?? fallback('arbitration');
const rep_gov  = byDomain.get('governance')  ?? fallback('governance');
const rep_exec = byDomain.get('execution')   ?? fallback('execution');
const e = BigInt(current_epoch);
return {
  can_arbitrate: can_arbitrate(rep_arb, rep_exec, e),
  can_govern:    can_govern(rep_gov, e),
  max_parallel_tasks:      Number(max_parallel_tasks(rep_exec)),
  rate_limit_bonus_factor: Number(rate_limit_bonus(rep_exec, BPS_100_PERCENT)),
  effective_stake_bps:      Number(stake_discount(BPS_100_PERCENT, rep_exec)),
};

When a node has no row at all (all 5 absent), every gate returns its zero-rep default: can_arbitrate = false, can_govern = false, max_parallel_tasks = 0, rate_limit_bonus_factor = 0, effective_stake_bps = 10000 (10× stake at the 1000n floor). This matches the donor-bug-#1-style “missing-row → zero-rep” defensive default already used in selectReputation’s null return.

9. Read-only invariant enforcement

tools.ts must contain zero INSERT, UPDATE, DELETE SQL statements and zero writes to the reputations / reputation_history tables. A test in the verification step asserts this via grep over the source file. apply_decay is purely functional (decay.ts:AX-04) so no row state changes leak across handler calls.

10. Test strategy

src/__tests__/domains/reputation/tools.test.ts mirrors the test posture of src/__tests__/domains/reputation/schema.test.ts (real SQLite in os.tmpdir(), migration 007 applied). Test cases:

  1. reputation_get — single domain: insert reputation row at epoch 100 with score 8000; read at epoch 100 → 8000; read at epoch 196 → score reflects ~96 epochs of execution-domain 500bps decay.
  2. reputation_get — no domain (all 5): returns 5 rows (or empty array for an unknown node).
  3. reputation_get — unknown node + domain: returns { row: null }.
  4. reputation_history — pagination: insert 100 events; limit=50, offset=0 returns 50 epoch-DESC; limit=50, offset=50 returns next 50.
  5. reputation_history — limit clamp: requesting limit > 500 is rejected by Zod (input validation).
  6. reputation_history — default limit: omitted limit yields 50 events.
  7. reputation_leaderboard — top-N: 10 nodes with scores 100..1000 → top 3 by score DESC. Apply decay correctly to maintain ordering.
  8. reputation_check_gates — gate composition: hand-craft rows; assert each derived gate matches P2.4.1’s formula.
  9. reputation_check_gates — missing node: all gates return zero-rep defaults.
  10. Read-only invariant: capture row count + history count before and after every read tool; assert no change.
  11. reputation_get does not mutate last_activity_epoch: read at far-future epoch; reread original epoch → original score returned.
  12. Zod rejections: invalid domain (“execition”), negative offset, limit > 500, current_epoch < 0.
  13. Tool registration: registerReputationTools(ctx) registers exactly 4 names and they are visible in ctx._registeredToolNames.

11. Surface delta

Symbol Action
src/domains/reputation/tools.ts NEW — exports registerReputationTools(ctx) and the 4 internal handler functions for test access
src/server.ts EDIT — import + call registerReputationTools(ctx) after registerMerkleTools(ctx)
src/__tests__/domains/reputation/tools.test.ts NEW — ~15 integration tests
MCP tool count 14 → 18
λ Phase 2 sub-tasks 6/7 → 7/7 (CLOSED)
Greek concepts shipping code 9/15 (κ closed in R87) → 10/15 (λ now colibri_code: partial)

12. Out of scope

  • Mutation tools (any write to reputations / reputation_history from MCP); Phase 2 is read-only at the MCP surface.
  • Materialised decay snapshots (deferred to Phase 6+ per source prompt §11 + ν integrations roadmap).
  • ξ identity bridge (node_id validation against a Soul Vector registry); the schema already permits plain TEXT node_id with no FK per P2.1.1 schema.
  • Multi-actor consensus over the current_epoch value (θ phase 3).

13. References

  • Source prompt: docs/guides/implementation/task-prompts/p2.1-lambda-reputation.md §P2.5.1 (lines 1102–1271)
  • Concept: docs/3-world/social/reputation.md §Phase 0 posture
  • Spec: docs/spec/s04-reputation.md §Computation
  • κ contracts (closest registration pattern): docs/contracts/p1-4-4-tool-lock-adapter-contract.md (note: library-only, no MCP tool)
  • α registration: src/server.ts:279 (registerColibriTool), src/server.ts:537-575 (bootstrap wiring)

Back to top

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

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