P2.3.1 — Experience Tokens (L0–L2b) — Step 5 Verification
§1. Gate execution log
Run on worktree .worktrees/claude/p2-3-1-tokens at HEAD 83777b28.
$ npm run build
> colibri@0.0.1 build
> tsc
> colibri@0.0.1 postbuild
> node scripts/copy-migrations.mjs
copy-migrations: copied 8 migration(s) src/db/migrations -> dist/db/migrations
$ npm run lint
> colibri@0.0.1 lint
> eslint src
(no output — clean)
$ npm test
Test Suites: 54 passed, 54 total
Tests: 2587 passed, 2587 total
Snapshots: 0 total
Time: 27.439 s
Build green. Lint green. Tests 2587/2587 (+109 vs 2478 baseline). All three gates pass.
§2. Test delta breakdown
| Suite | Existing | New | After |
|---|---|---|---|
Full suite baseline at origin/main 7e5cf9d3 |
2478 | — | — |
src/__tests__/domains/reputation/witnesses.test.ts (new) |
0 | 27 | 27 |
src/__tests__/domains/reputation/tokens.test.ts (new) |
0 | 82 | 82 |
| Total after P2.3.1 | 2478 | +109 | 2587 |
§3. Acceptance criteria verification (from source prompt)
| AC | Status | Evidence |
|---|---|---|
5 token levels L0 | L1 | L1.5 | L2a | L2b (no L3) |
✓ | TOKEN_LEVELS tuple at tokens.ts §B; test TOKEN_LEVELS is exactly [L0,L1,L1.5,L2a,L2b]; SQL CHECK (level IN ('L0','L1','L1.5','L2a','L2b')); test M3 verifies CHECK rejects ‘L3’. |
experience_tokens table with required columns + indexes |
✓ | Migration 008 §CREATE TABLE experience_tokens; test M1 verifies table existence; test “experience_tokens indexes exist” confirms all three indexes. |
mcp_witnesses table with required columns + indexes |
✓ | Migration 008 §CREATE TABLE mcp_witnesses; test “table exists after initDb”; SQL CHECK on rep≥200 + weight_cap≤30. |
mint_L0(node_id, domain, action, outcome) |
✓ | tokens.ts §G mint_L0; test T1 + T25. |
promote_to_L1(L0_token, cycle_proof) requires cycle complete |
✓ | tokens.ts §G promote_to_L1; tests T2/T3 + happy path. CycleProof requires both flags literally true. |
promote_to_L1_5(L1_token, witnesses) with witness rules |
✓ | tokens.ts §G promote_to_L1_5; tests T4–T8. s05 silent-downgrade to L1 on rule violation rather than reject. |
| L1.5 witness floor (rep ≥ 200) | ✓ | Test T5 (silent downgrade); register_witness throws WitnessReputationFloorError at registration time (test W1). |
| L1.5 per-witness weight cap (≤ 0.3, encoded as 30 bps×100) | ✓ | Test T6 (silent downgrade); test W2 throws WitnessWeightCapError. SQL CHECK weight_cap > 0 AND weight_cap <= 30. |
| L1.5 sum cap (Σ ≤ 0.4 × MIN_EPISODES = 200 bps×100) | ✓ | Test T7 (silent downgrade); test W10 throws WitnessSumCapError. WITNESS_SUM_CAP_MAX = 200. |
| L1.5 independence (≤1 per counterparty class per 7-day window) | ✓ | Tests W5/W6/W7/W8; SQL index idx_witnesses_independence(target_node_id, counterparty_class, created_at). Window = 604800s. |
promote_to_L2a requires ≥5 same feature_hash + ≥3 scenarios + ≥3 counterparties |
✓ | Tests T9/T11/T11-refined/T12/T13. |
promote_to_L2b invariance via κ engine replay |
✓ | tokens.ts §G promote_to_L2b calls predicate.replay; tests T16 (match) + T17 (mismatch throws L2bInvarianceError). κ injected via TokenInvariancePredicate. |
feature_hash = SHA-256(canonical_context || action || outcome_class) |
✓ | tokens.ts §E feature_hash; tests T18 (determinism) + T19 (distinct inputs) + “key insertion order insensitive”. |
| Append-only | ✓ | No UPDATE experience_tokens or DELETE FROM experience_tokens in source (test T21); no UPDATE/DELETE mcp_witnesses in source (test W13). |
| Non-transferable | ✓ | No set_node_id / transfer / mutateNodeId exports (test T22). Token.node_id is readonly and the object is Object.freezed. |
count_tokens_by_level(node_id, domain, level) |
✓ | tokens.ts §H count_tokens_by_level; tests T26/T27. |
§4. Invariant verification (contract §2)
All 22 invariants from docs/contracts/p2-3-1-tokens-contract.md verified:
| Invariant | Test |
|---|---|
| I1 — level ∈ {L0/L1/L1.5/L2a/L2b} | “TOKEN_LEVELS is exactly…” + M3 |
| I2 — node_id immutable | T22 export-scan + readonly type + Object.freeze |
| I3 — feature_hash null for L0/L1, set for L1.5/L2a/L2b | T1 + T8 + happy paths |
| I4 — promotion produces new row | All promote_to_* tests assert new id + promoted_from |
| I5 — no UPDATE/DELETE SQL | T21 + W13 |
| I6 — no update/delete/setNodeId exports | T22 + T24 + W14 |
| I7 — mint_L0 has level=L0, witnesses=[], feature_hash=null | T1 |
| I8 — promote_to_L1 requires both cycle_proof flags | T2 + “counterparty_confirmed missing” |
| I9 — L1.5 silent downgrade on violation | T5/T6/T7 |
| I10 — L2a diversity gate | T9/T11/T12 + T13 happy |
| I11 — L2b invariance via predicate.replay | T16/T17 |
| I12 — feature_hash determinism | T18 + key-order test |
| I13 — canonicalize_context emits strings (no null/undefined) | T20 |
| I14 — Witness rep ≥ 200 enforced TS + SQL | W1 + “CHECK rejects rep=199” |
| I15 — Witness weight_cap ≤ 30, float-free | W2/W3 + “rejects non-integer” + “CHECK rejects weight_cap=31” |
| I16 — 7-day independence | W5/W6/W7/W8 |
| I17 — Sum cap (Σ ≤ 200) | W10 |
| I18 — ULID consumes caller-supplied random_bytes | T29 + W4 (matches ^wit_[0-9A-Z]{26}$) |
| I19 — created_at caller-supplied | All happy paths assert returned created_at matches input |
| I20 — All arithmetic integer | Float-free guards in register_witness and encodeUlid; no parseFloat / Math.* in source |
| I21 — count_tokens_by_level returns 0 on missing | T26 |
| I22 — No L3 anywhere in code | T23 (post-comment-strip) + TOKEN_LEVELS assertion |
§5. Determinism guardrails
- No
Math.*/Math.random: confirmed viagrep -E 'Math\.' src/domains/reputation/tokens.ts src/domains/reputation/witnesses.tsreturns zero matches in code (any prose mention is in stripped-comment blocks). - No
Date.now(): same scan — zero matches. - No floats: numeric literals in module source are integers;
weight_capis stored bps × 100;MIN_EPISODES = 5n(bigint). - No
crypto.randomBytes()from inside module:encodeUlidtakes caller-suppliedUint8Array(10). Tests use deterministicbytes(seed)helper.
§6. Anomalies
None encountered.
Minor design decision worth flagging:
-
feature_hashexcludes scenario + counterparty. The source prompt says “canonical_contextis JSON-stringified with sorted keys; unknown values bucketed to*”. The s05 §Pattern-matching algorithm step 1 says “Canonicalizedomain,scenario, andcounterpartyto a shared vocabulary.” Reading these together, the cleanest implementation that makes the L2a gate (“≥5 tokens with the same feature_hash spanning ≥3 distinct scenarios AND ≥3 distinct counterparty classes”) reachable is to bucket scenario + counterparty to*when computing the hash. Otherwise five tokens with three distinct scenarios would always produce three distinct hashes and L2a could never gate.The diversity check still enforces ≥3 distinct values — those distinct values live in the token’s
scenario/counterpartyrow fields, not in the hash input. The hash captures the pattern abstraction; the row fields capture the instance variation.This is consistent with s05’s narrative example (L2a token has concrete
scenario: "bug_triage"even though the gate requires diversity). -
promoted_fromenforced by SQLite FK.PRAGMA foreign_keys = ONis set byinitDb(src/db/index.ts:252). The audit assumed FKs were off based on the documentary FK in earlier migrations; in practice the FK is enforced. Test T28 was updated to insert an anchor L0 token first so the FK constraint is satisfied for the L1.5 row inserts.
§7. Source artefacts
docs/audits/p2-3-1-tokens-audit.md (Step 1)
docs/contracts/p2-3-1-tokens-contract.md (Step 2)
docs/packets/p2-3-1-tokens-packet.md (Step 3)
src/db/migrations/008_experience_tokens.sql (Step 4)
src/domains/reputation/tokens.ts (Step 4)
src/domains/reputation/witnesses.ts (Step 4)
src/__tests__/domains/reputation/tokens.test.ts (Step 4)
src/__tests__/domains/reputation/witnesses.test.ts (Step 4)
docs/verification/p2-3-1-tokens-verification.md (Step 5 — this)
§8. Commit ledger
| # | SHA | Description |
|---|---|---|
| 1 | b8de13bc | audit(p2-3-1-tokens): inventory surface |
| 2 | a76e3e01 | contract(p2-3-1-tokens): behavioral contract |
| 3 | 0fef40b0 | packet(p2-3-1-tokens): execution plan |
| 4 | d878777e | feat(p2-3-1-tokens): L0-L2b token issuance + witness registry + κ invariance replay |
| 5 | 83777b28 | feat(p2-3-1-tokens): tests + feature_hash scenario/counterparty abstraction |
| 6 | (this commit) | verify(p2-3-1-tokens): test evidence |
§9. Phase-2-λ posture confirmation
- Library-only: no MCP tool registration in
src/server.ts. Confirmed via diff thatsrc/server.tsis untouched. - L3 deferred: no
'L3'literal in code (comments document the deferral citing Phase 6 π governance). - κ engine integration via injection: no direct
importofsrc/domains/rules/engine.tsfromtokens.ts. Confirmed viagrep "from '../rules'" src/domains/reputation/tokens.tsreturning zero matches. - Witness reads via Witness shape:
tokens.tsimports onlytype Witness(type-only). - Determinism: caller-supplied
random_bytes+created_atfor every mint/promote. No internal clock or RNG.
Verification done. Ready for PR open.