P2.3.1 — Experience Tokens (L0–L2b) — Step 3 Packet
§1. Dependency order (matters)
src/db/migrations/008_experience_tokens.sql— schema first. Migration runner is idempotent; adding the file is sufficient.src/domains/reputation/witnesses.ts— witness registry primitives. Tokens depend on Witness for thepromote_to_L1_5signature.src/domains/reputation/tokens.ts— token surface. Depends onschema.ts(Domain) +witnesses.ts(Witness type only at the type level).src/__tests__/domains/reputation/witnesses.test.ts— witness coverage.src/__tests__/domains/reputation/tokens.test.ts— token coverage + migration smoke.
Five source-of-truth artefacts; five commits at Step 4 (feat). Migration first, witnesses second, tokens third, tests last (two test commits).
§2. SQL migration (file content)
src/db/migrations/008_experience_tokens.sql mirrors 007_reputation.sql style:
-- 008_experience_tokens — λ Experience Tokens + Witness Registry (P2.3.1).
--
-- Introduces two append-only tables for the L0–L2b token chain plus the
-- supporting witness registry that grants L1 → L1.5 promotions.
--
-- experience_tokens
-- id TEXT PRIMARY KEY ULID prefixed 'tok_'
-- node_id TEXT NOT NULL ξ Soul Vector identity (no FK, Phase 0)
-- level TEXT NOT NULL CHECK one of L0/L1/L1.5/L2a/L2b — L3 deferred to π
-- domain TEXT NOT NULL re-uses reputation 5-domain vocabulary
-- scenario TEXT nullable; bucketed to '*' in feature_hash
-- counterparty TEXT nullable; bucketed to '*' in feature_hash
-- action TEXT NOT NULL free-form action label
-- outcome_class TEXT NOT NULL free-form outcome label
-- outcome_delta INTEGER NOT NULL signed integer
-- witnesses TEXT NOT NULL JSON array of witness_ids (empty='[]')
-- created_at INTEGER NOT NULL epoch seconds (caller-supplied)
-- promoted_from TEXT null for L0; ULID of upstream token else
-- feature_hash TEXT null for L0/L1; SHA-256 hex else
--
-- mcp_witnesses
-- witness_id TEXT PRIMARY KEY ULID prefixed 'wit_'
-- agent_id TEXT NOT NULL who attested
-- target_node_id TEXT NOT NULL whose work was witnessed
-- target_episode_id TEXT NOT NULL episode (≈ L1 token id) being witnessed
-- reputation_at_witness INTEGER NOT NULL frozen ≥ 200 (bps)
-- weight_cap INTEGER NOT NULL bps × 100; ≤ 30 (= 0.3)
-- counterparty_class TEXT NOT NULL class for 7-day independence
-- created_at INTEGER NOT NULL epoch seconds
--
-- Append-only at both layers (TS exports + this SQL). Promotion creates a
-- NEW row with promoted_from pointing at the upstream id. There is no
-- UPDATE or DELETE FROM on either table anywhere in the codebase.
--
-- Phase 2 λ posture (s05 §Phase 0 posture, restated for Phase 2):
-- No MCP tool surface is registered against these tables. Library-only.
--
-- Canonical references:
-- - docs/guides/implementation/task-prompts/p2.1-lambda-reputation.md §P2.3.1
-- - docs/spec/s05-experience-tokens.md (full)
-- - docs/audits/p2-3-1-tokens-audit.md
-- - docs/contracts/p2-3-1-tokens-contract.md
-- - docs/packets/p2-3-1-tokens-packet.md
CREATE TABLE experience_tokens (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
level TEXT NOT NULL CHECK (level IN ('L0','L1','L1.5','L2a','L2b')),
domain TEXT NOT NULL,
scenario TEXT,
counterparty TEXT,
action TEXT NOT NULL,
outcome_class TEXT NOT NULL,
outcome_delta INTEGER NOT NULL,
witnesses TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
promoted_from TEXT,
feature_hash TEXT,
FOREIGN KEY (promoted_from) REFERENCES experience_tokens(id)
);
CREATE INDEX idx_tokens_node_domain_level ON experience_tokens(node_id, domain, level);
CREATE INDEX idx_tokens_feature_hash ON experience_tokens(feature_hash);
CREATE INDEX idx_tokens_promoted_from ON experience_tokens(promoted_from);
CREATE TABLE mcp_witnesses (
witness_id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
target_node_id TEXT NOT NULL,
target_episode_id TEXT NOT NULL,
reputation_at_witness INTEGER NOT NULL CHECK (reputation_at_witness >= 200 AND reputation_at_witness <= 10000),
weight_cap INTEGER NOT NULL CHECK (weight_cap > 0 AND weight_cap <= 30),
counterparty_class TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_witnesses_episode ON mcp_witnesses(target_episode_id);
CREATE INDEX idx_witnesses_independence ON mcp_witnesses(target_node_id, counterparty_class, created_at);
§3. ULID generator algorithm
Crockford base32 alphabet (per spec): 0123456789ABCDEFGHJKMNPQRSTVWXYZ (32 chars, excludes I, L, O, U).
function encodeBase32(value: bigint, length: number): string {
let s = '';
for (let i = 0; i < length; i++) {
const idx = Number(value & 31n);
s = ALPHABET[idx] + s;
value >>= 5n;
}
return s;
}
ULID format: <10 chars timestamp ms big-endian><16 chars random 80-bit>. Caller provides 10 bytes (Uint8Array(10)). Module prepends tok_ or wit_.
Edge cases:
random_bytes.length !== 10→TypeError.created_at_seconds < 0→ alsoTypeError(timestamp must be ≥ 0).- Two calls with identical
(random_bytes, created_at)return identical IDs → property tested.
§4. SHA-256 + canonical JSON
node:crypto.createHash('sha256') — already idiomatic in the codebase (existing test files use crypto.randomUUID).
Canonical JSON: stringify-with-sorted-keys recursion. For our input the canonical context has only three flat keys (domain, scenario, counterparty); we still write the recursive helper so the algorithm is correct for future expansion.
function canonicalJson(value: unknown): string {
if (value === null || value === undefined) return 'null';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return '[' + value.map(canonicalJson).join(',') + ']';
}
const keys = Object.keys(value as object).sort();
const parts = keys.map(k =>
JSON.stringify(k) + ':' + canonicalJson((value as Record<string, unknown>)[k]),
);
return '{' + parts.join(',') + '}';
}
feature_hash:
input = canonicalJson(canonical_context) + '|' + action_type + '|' + outcome_class
output = createHash('sha256').update(input).digest('hex')
§5. Token construction
mint_L0 and each promote_to_* share an internal makeToken(overrides) helper that returns a fully populated Token with defaults. Promotion functions are thin wrappers that compute the right field overrides.
function makeToken(spec: {
id: string;
node_id: string;
level: TokenLevel;
domain: Domain;
scenario: string | null;
counterparty: string | null;
action: string;
outcome_class: string;
outcome_delta: number;
witnesses: readonly string[];
created_at: number;
promoted_from: string | null;
feature_hash: string | null;
}): Token;
All Token fields are readonly. Object frozen via Object.freeze to prevent caller-side mutation.
§6. Witness validation flow
register_witness(db, input):
1. validate reputation_at_witness >= 200
2. validate 0 < weight_cap <= 30
3. SELECT count(*) FROM mcp_witnesses
WHERE target_node_id = ?
AND counterparty_class = ?
AND created_at > ? (= input.created_at - 604800)
LIMIT 1
→ throw WitnessIndependenceError if count > 0
4. SELECT COALESCE(SUM(weight_cap), 0) FROM mcp_witnesses
WHERE target_episode_id = ?
→ throw WitnessSumCapError if sum + input.weight_cap > 200
5. build Witness via ULID + INSERT
6. return Witness
check_independence and total_witness_weight are read-only wrappers for the same SQL the validator uses internally — exposed so callers can run pre-flight checks.
§7. Test scaffolding
Mirrors src/__tests__/domains/reputation/schema.test.ts:
- Each migration-touching test gets a unique
os.tmpdir()db path. afterEachcallscloseDb()and removes temp directories (Windows WAL residues swallowed).- Helper
makeRandomBytes(seed: number): Uint8Array(10)produces deterministic 10-byte buffers for ULID inputs.
For invariance-replay tests (T16, T17):
const matchingPredicate: TokenInvariancePredicate = { replay: t => t.outcome_class };
const breakingPredicate: TokenInvariancePredicate = { replay: () => 'other_class' };
For determinism guard (T18, T30):
expect(feature_hash(ctx, action, outcome)).toBe(feature_hash(ctx, action, outcome));
For source-scan invariants (T21, T22, T23, T24):
const src = await fs.readFile(TOKENS_FILE, 'utf8');
expect(src).not.toMatch(/UPDATE\s+experience_tokens/i);
expect(src).not.toMatch(/DELETE\s+FROM\s+experience_tokens/i);
expect(src).not.toMatch(/['"`]L3['"`]/); // no L3 literal
For export-shape invariant (T22, T24):
const exports = Object.keys(await import('.../tokens.js'));
for (const forbidden of ['updateToken','deleteToken','setNodeId','set_node_id','update_token','delete_token']) {
expect(exports).not.toContain(forbidden);
}
§8. Commit plan
| # | Step | Files |
|---|---|---|
| 1 | already committed | docs/audits/p2-3-1-tokens-audit.md |
| 2 | already committed | docs/contracts/p2-3-1-tokens-contract.md |
| 3 | already committed | docs/packets/p2-3-1-tokens-packet.md (this) |
| 4 | feat(p2-3-1-tokens): migration + tables | src/db/migrations/008_experience_tokens.sql |
| 5 | feat(p2-3-1-tokens): witness registry | src/domains/reputation/witnesses.ts |
| 6 | feat(p2-3-1-tokens): token engine | src/domains/reputation/tokens.ts |
| 7 | feat(p2-3-1-tokens): token + witness tests | both test files |
| 8 | verify(p2-3-1-tokens): test evidence | docs/verification/p2-3-1-tokens-verification.md |
Eight commits total. Step-4 split into four feat commits keeps each PR review chunk to ~300–700 LOC and lets reviewers verify the migration + each layer separately.
§9. Gate plan
After commit 7:
cd .worktrees/claude/p2-3-1-tokens
npm run build && npm run lint && npm test
Expected:
- build: clean (TS 5.3 ESM).
- lint: clean.
- test: baseline 2478 + new tests (~44+); all green.
If gate fails on startup subprocess smoke flake (pre-existing per memory), re-run once.
§10. Risk register
| Risk | Mitigation |
|---|---|
FOREIGN KEY on promoted_from would prevent inserting orphaned tokens during test setup. |
sqlite default has FK disabled; we don’t PRAGMA foreign_keys=ON in migrations. Existing migrations 001–007 rely on the same convention. The FK declaration is documentary. |
ULID timestamp portion produces 0...0 for created_at = 0. |
Acceptable. The test fixture uses non-zero created_at values. |
Object.freeze on the Token interface breaks no consumer because every field is readonly. |
Verified at type level; runtime freeze enforced in makeToken. |
| s05 hint that “L2a witnesses: []” might conflict with carrying upstream witness ids — we resolve by carrying them at L1.5 only (per s05 token-schema example), and L2a/L2b clear them. | Confirmed in contract §3.4 and §3.5. |
Independence rule edge case: now - 604800 exactly equal to an existing witness’s created_at should pass. We use >, not >=. |
SQL created_at > ? - 604800 matches. |
§11. Out-of-scope reminders
- No MCP tool registration (Phase 2 λ is library-only).
- No L3 promotion.
- No witness retraction / decay / soft delete.
- No derived-state pruning.
- No
reputation_getexposure (P2.5.1’s job).
Packet done. Proceed to implement.