P2.3.1 — Experience Tokens (L0–L2b) — Step 3 Packet

§1. Dependency order (matters)

  1. src/db/migrations/008_experience_tokens.sql — schema first. Migration runner is idempotent; adding the file is sufficient.
  2. src/domains/reputation/witnesses.ts — witness registry primitives. Tokens depend on Witness for the promote_to_L1_5 signature.
  3. src/domains/reputation/tokens.ts — token surface. Depends on schema.ts (Domain) + witnesses.ts (Witness type only at the type level).
  4. src/__tests__/domains/reputation/witnesses.test.ts — witness coverage.
  5. 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 !== 10TypeError.
  • created_at_seconds < 0 → also TypeError (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.
  • afterEach calls closeDb() 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_get exposure (P2.5.1’s job).

Packet done. Proceed to implement.


Back to top

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

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