P2.3.1 — Experience Tokens (L0–L2b) — Step 1 Audit
§1. Surface inventory
Greenfield slice. No experience_tokens / mcp_witnesses tables exist at the baseline (origin/main = 7e5cf9d3). The reputation domain currently ships four library-only files:
| File | Lines | Role |
|---|---|---|
src/domains/reputation/schema.ts |
329 | P2.1.1 — reputations / reputation_history schema + readers + insertHistoryEvent (the only write path). |
src/domains/reputation/compute.ts |
— | P2.1.2 — bps composition. |
src/domains/reputation/decay.ts |
— | P2.2.1 — per-domain epoch decay. |
src/domains/reputation/penalties.ts |
— | P2.2.2 — five-band offence penalties + scar + ban. |
No existing token / witness code paths to refactor. No prior mcp_tokens rows. No feature_hash index. The schema stub mentioned in s05 §Phase 0 posture (mcp_tokens) was never actually created — git ls-files src/db/migrations/ shows 001 → 007 only, with 007 covering reputation. The next free slot is 008.
§2. Database migration sequence
001_init.sql
002_tasks.sql
003_thought_records.sql
004_skills.sql
005_retention.sql
006_eta.sql
007_reputation.sql ← P2.1.1 reputations + reputation_history
008_experience_tokens.sql ← THIS TASK (new)
The migration runner (src/db/index.ts) discovers .sql files alphabetically and idempotently applies them; the only constraint on the number is uniqueness + ordering. No existing column needs to be altered.
§3. λ token spec re-read against shipped reality
docs/spec/s05-experience-tokens.md is fully spec-only at baseline; the implementation-status table at the bottom lists every claim as Spec-only except the AMS-heritage mcp_memory_frame chain-hash row.
Five token levels (L3 explicitly out of scope, deferred to π Phase 6):
| Level | Promotion source | Persistence | Implementation surface |
|---|---|---|---|
| L0 | mint_L0(node_id, domain, action, outcome) |
Ephemeral per spec; stored as a row in Phase 0 (see §6) | one-shot mint |
| L1 | promote_to_L1(L0_token, cycle_proof) |
Persistent — requires β GSD cycle complete | append new row |
| L1.5 | promote_to_L1_5(L1_token, witnesses) |
Persistent — requires ≥1 witness | witness validation + new row |
| L2a | promote_to_L2a(tokens[]) |
Persistent — diversity gate | feature_hash group + scenarios×counterparties |
| L2b | promote_to_L2b(L2a_token) |
Persistent — invariance check | κ engine replay with context zeroed |
§4. Witness registry constants
From s05 §Witness registry + p2.1 §P2.3.1 acceptance criteria:
reputation_at_witness ≥ 200(frozen integer bps; floor enforced at registration time).- Per-witness
weight_cap ≤ 0.3represented as integer 30 in bps × 100 units (soweight_cap ≤ 30). Float-free per CLAUDE.md §5 (forbidden: floats). - Sum cap per target episode:
Σ weights ≤ 0.4 × MIN_EPISODES.MIN_EPISODESis named but undefined in s05 — defineMIN_EPISODES = 5nper p2.1 §P2.3.1 “Common gotchas” → sum cap encoded as integer0.4 × 5 × 100 = 200units. - Independence: ≤1 witness per counterparty class per rolling 7-day window for the same target_node_id.
§5. Pattern matching / SHA-256 algorithm
From s05 §Pattern-matching algorithm:
- Normalize context.
domain/scenario/counterpartycanonicalized; unknown values →*. - Feature extract.
feature_hash = SHA-256(canonical_context || action_type || outcome_class). - Pattern match. All tokens sharing
feature_hashform a candidate set. - Diversity gate (L2a). ≥3 distinct
scenariovalues AND ≥3 distinctcounterpartyclasses (in candidate set of ≥5 tokens). - Invariance gate (L2b). Replay each token via κ engine with
contextzeroed; resultingoutcome_classmust equal the original.
SHA-256 hex via node:crypto.createHash('sha256') — already a dependency through crypto.randomUUID usage elsewhere in the codebase (src/__tests__/db-init.test.ts).
Canonical JSON serializer must be deterministic. JSON.stringify with a sorted-keys replacer is sufficient and pure (no float emission for our bigint-free input shape: all string keys + string/number values).
§6. Append-only semantics
AX-01: tokens are append-only. Promotion creates a new row with promoted_from pointing at the upstream token. There is no UPDATE / DELETE on experience_tokens or mcp_witnesses. The shipped reputation module already enforces the same pattern at the export surface (no updateReputation, no deleteHistory) — copy the contract.
Per s05 §Append-only vs garbage collection: the event log is canonical; the derived token store may be pruned. Phase 2 does not implement pruning. We ship only the event-log shape (one row per token, no derived cache).
§7. κ engine integration for L2b
src/domains/rules/engine.ts exports:
export function executeRuleset(
registry: RuleRegistry,
event: Readonly<Record<string, unknown>>,
state: Readonly<Record<string, unknown>>,
rule_version: string,
epoch: bigint,
): TransitionResult;
The L2b promotion takes an engine binding (dependency-injected) so the token module never imports executeRuleset at module scope. This keeps:
- circular-import risk at zero (engine.ts has zero reputation/* deps),
- testability high (mock the binding with a deterministic stub that produces a known
outcome_class), - the token module pure-library when invariance replay is not requested.
TokenInvariancePredicate interface in tokens.ts:
export interface TokenInvariancePredicate {
// Given a token's original (context, action, outcome_class), return the
// outcome_class produced by replaying through κ with context zeroed.
// Caller wires this to an executeRuleset closure that decodes the token
// into (event, state) per their domain.
replay(token: Token): string;
}
The L2b promoter passes the predicate; the caller (tests, real wiring) constructs it.
§8. Determinism
ULID generation: per gotcha (p2.1 §P2.3.1), Math.random is forbidden. Use crypto.randomBytes + monotonic timestamp prefix. ULID format: tok_<26 chars Crockford base32> per s05 example (tok_01HXYZ...).
created_at: passed in by the caller (epoch seconds). The token module is clock-free — no Date.now(). This mirrors the rules engine’s determinism guardrail. The minting helper accepts created_at as a parameter.
feature_hash: deterministic via canonical JSON sort. Tested via two distinct invocations producing identical hex output.
§9. Acceptance criteria checklist (from source prompt)
- 5 token levels
L0 | L1 | L1.5 | L2a | L2b(no L3). experience_tokenstable with ULID PK + level CHECK + indexes.mcp_witnessestable with rep≥200 CHECK + weight_cap≤30 CHECK + indexes.mint_L0.promote_to_L1(cycle_proof gate).promote_to_L1_5(witness rules + downgrade-on-violation per s05).promote_to_L2a(≥5 same feature_hash, ≥3 scenarios, ≥3 counterparties).promote_to_L2b(κ invariance replay; outcome_class must match).feature_hash = SHA-256(canonical_context || action_type || outcome_class).- Append-only (no UPDATE/DELETE).
- Non-transferable (no node_id mutator).
count_tokens_by_level.
§10. Risks
- κ engine signature drift. The token module passes through a
TokenInvariancePredicaterather than importingexecuteRulesetdirectly — mitigates breaking changes when the engine signature evolves in Phase 1.5+. - Witness independence window. The 7-day rolling window is enforced at registration time via the indexed
(target_node_id, counterparty_class, created_at)clause. We treatcreated_atas caller-supplied (epoch seconds); the module is clock-free. - MIN_EPISODES drift. s05 names but does not define the constant. Defined here as
MIN_EPISODES = 5nper p2.1 gotcha — adding to the existingbps-constants.tswould be a cross-domain import (reputation reaches into rules), so we keepMIN_EPISODESlocal totokens.tswith a doc cross-reference. - L3 temptation. Out of scope. The TokenLevel union explicitly omits
"L3"; there is nopromote_to_L3export. Phase 6 (π governance) will land it.
§11. Out-of-scope (deferred / forbidden)
- L3 aggregate level — defer to π Phase 6.
- Token GC / derived-state pruning — s05 calls this a Phase-1.5+ concern.
- MCP tool surface (
token_mint,token_promote, etc.) — library-only per Phase 2 λ posture. - Direct integration with
mcp_witnessesfrom existing reputation modules — that’s P2.5.1’s job (reputation_getexposes token counts). - Witness reputation re-validation on subsequent rep changes — s05 explicitly freezes
reputation_at_witnessat witness time.
Audit done. Proceed to contract.