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 snapshotreputation_history— paginated history events (epoch DESC, id DESC)reputation_leaderboard— top N nodes by decayed score per domainreputation_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.ts→registerHealthTool(ctx)— synchronous sync handler examplesrc/tools/merkle.ts→registerMerkleTools(ctx)— multi-tool registration in one functionsrc/domains/trail/repository.ts:404→registerThoughtTools(ctx)— DB-bound handler withgetDb()lazy resolutionsrc/domains/trail/verifier.ts→registerVerifyChainTool(ctx)src/domains/skills/repository.ts→registerSkillTools(ctx)src/domains/tasks/repository.ts→registerTaskTools(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_gatestakescurrent_epoch: number(REQUIRED) — already in the source spec.reputation_gettakescurrent_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_leaderboardtakescurrent_epoch: number(REQUIRED) — same reason.reputation_historydoes NOT takecurrent_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 viaselectReputation(db, node_id, domain?), appliesapply_decay(row, BigInt(current_epoch))to each, and returns the decayed snapshots. Perdecay.ts:124,apply_decayshort-circuits to the input ref wheninactive_epochs === 0n(no allocation when caller’s epoch matcheslast_activity_epoch).reputation_leaderboard(domain, limit, current_epoch)SELECTs fromreputations WHERE domain = ? ORDER BY score DESC LIMIT ?(usesidx_reputations_leaderboard), then appliesapply_decay_batchto the result. The DESC ordering may shift after decay because nodes with olderlast_activity_epochdecay more — therefore we re-sort the batch by decayed score DESC afterapply_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 tolimitafter 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:
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.reputation_get— no domain (all 5): returns 5 rows (or empty array for an unknown node).reputation_get— unknown node + domain: returns{ row: null }.reputation_history— pagination: insert 100 events;limit=50, offset=0returns 50 epoch-DESC;limit=50, offset=50returns next 50.reputation_history— limit clamp: requestinglimit > 500is rejected by Zod (input validation).reputation_history— default limit: omitted limit yields 50 events.reputation_leaderboard— top-N: 10 nodes with scores 100..1000 → top 3 by score DESC. Apply decay correctly to maintain ordering.reputation_check_gates— gate composition: hand-craft rows; assert each derived gate matches P2.4.1’s formula.reputation_check_gates— missing node: all gates return zero-rep defaults.- Read-only invariant: capture row count + history count before and after every read tool; assert no change.
reputation_getdoes not mutatelast_activity_epoch: read at far-future epoch; reread original epoch → original score returned.- Zod rejections: invalid domain (“execition”), negative offset,
limit > 500,current_epoch < 0. - Tool registration:
registerReputationTools(ctx)registers exactly 4 names and they are visible inctx._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_historyfrom 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_idvalidation against a Soul Vector registry); the schema already permits plain TEXTnode_idwith no FK per P2.1.1 schema. - Multi-actor consensus over the
current_epochvalue (θ 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)