P2.3.1 — Experience Tokens (L0–L2b) — Step 2 Contract
§1. Module surface
Two pure-library modules + one SQL migration. No MCP tool registration.
src/domains/reputation/tokens.ts
src/domains/reputation/witnesses.ts
src/db/migrations/008_experience_tokens.sql
§1.1 tokens.ts exports
export type TokenLevel = 'L0' | 'L1' | 'L1.5' | 'L2a' | 'L2b';
export const TOKEN_LEVELS: readonly TokenLevel[]; // immutable tuple
export interface Token {
readonly id: string; // 'tok_<26-char-Crockford-base32>'
readonly node_id: string; // ξ identity; never mutated
readonly level: TokenLevel;
readonly domain: Domain; // re-uses reputation Domain
readonly scenario: string | null;
readonly counterparty: string | null;
readonly action: string;
readonly outcome_class: string;
readonly outcome_delta: number; // signed integer
readonly witnesses: readonly string[]; // witness_ids
readonly created_at: number; // caller-supplied epoch seconds
readonly promoted_from: string | null;
readonly feature_hash: string | null; // SHA-256 hex; null for L0/L1
}
export interface MintInput {
readonly node_id: string;
readonly domain: Domain;
readonly action: string;
readonly outcome_class: string;
readonly outcome_delta: number;
readonly scenario?: string | null;
readonly counterparty?: string | null;
readonly created_at: number;
readonly random_bytes: Uint8Array; // 10 bytes — caller-supplied entropy
}
export interface CycleProof {
readonly cycle_complete: true; // β FSM marker
readonly counterparty_confirmed: true;
}
export interface TokenInvariancePredicate {
replay(token: Token): string; // returns outcome_class after κ replay
}
export const MIN_EPISODES: 5n;
export function mint_L0(input: MintInput): Token;
export function promote_to_L1(
l0_token: Token,
cycle_proof: CycleProof,
created_at: number,
random_bytes: Uint8Array,
): Token;
export function promote_to_L1_5(
l1_token: Token,
witnesses: readonly Witness[],
created_at: number,
random_bytes: Uint8Array,
): Token;
export function promote_to_L2a(
tokens: readonly Token[],
created_at: number,
random_bytes: Uint8Array,
): Token;
export function promote_to_L2b(
l2a_token: Token,
predicate: TokenInvariancePredicate,
created_at: number,
random_bytes: Uint8Array,
): Token;
export function feature_hash(
canonical_context: object,
action_type: string,
outcome_class: string,
): string;
export function canonicalize_context(ctx: {
domain?: string | null;
scenario?: string | null;
counterparty?: string | null;
}): { domain: string; scenario: string; counterparty: string };
export function count_tokens_by_level(
db: Database.Database,
node_id: string,
domain: Domain,
level: TokenLevel,
): number;
export function insert_token(
db: Database.Database,
token: Token,
): void;
export function select_tokens_by_feature_hash(
db: Database.Database,
feature_hash: string,
): Token[];
// NO updateToken, NO deleteToken, NO set_node_id — append-only + non-transferable.
§1.2 witnesses.ts exports
export interface Witness {
readonly witness_id: string; // 'wit_<26-char-Crockford-base32>'
readonly agent_id: string;
readonly target_node_id: string;
readonly target_episode_id: string;
readonly reputation_at_witness: number; // frozen bps in [200, 10000]
readonly weight_cap: number; // bps × 100; ≤ 30
readonly counterparty_class: string;
readonly created_at: number; // epoch seconds
}
export interface WitnessRegistrationInput {
readonly agent_id: string;
readonly target_node_id: string;
readonly target_episode_id: string;
readonly reputation_at_witness: number;
readonly weight_cap: number;
readonly counterparty_class: string;
readonly created_at: number;
readonly random_bytes: Uint8Array;
}
export class WitnessReputationFloorError extends Error { ... }
export class WitnessWeightCapError extends Error { ... }
export class WitnessIndependenceError extends Error { ... }
export class WitnessSumCapError extends Error { ... }
export const WITNESS_REPUTATION_FLOOR: 200;
export const WITNESS_WEIGHT_CAP_MAX: 30; // bps × 100 = 0.3
export const WITNESS_INDEPENDENCE_WINDOW_SECONDS: 604800; // 7 days
export function register_witness(
db: Database.Database,
input: WitnessRegistrationInput,
): Witness;
export function lookup_witnesses(
db: Database.Database,
target_node_id: string,
target_episode_id: string,
): Witness[];
export function check_independence(
db: Database.Database,
target_node_id: string,
counterparty_class: string,
now: number,
): boolean;
export function total_witness_weight(
db: Database.Database,
target_episode_id: string,
): number; // sum of weight_cap fields for an episode
// NO updateWitness, NO deleteWitness, NO retract_witness — append-only.
§1.3 Migration 008
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 '[]', -- JSON array
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);
§2. Invariants
| ID | Statement | Enforcement |
|---|---|---|
| I1 | Token.level is one of L0 | L1 | L1.5 | L2a | L2b. |
TS union + SQL CHECK. |
| I2 | Token.node_id is immutable for a given Token.id. |
Interface readonly; no setter exists. |
| I3 | feature_hash for L0/L1 tokens is null; for L1.5/L2a/L2b it is a 64-char lowercase hex SHA-256. |
Constructor sets it conditionally; SQL stores TEXT to keep room for nulls. |
| I4 | Promotion produces a new row; promoted_from chains to the upstream token id. |
Each promote_to_* returns a brand-new Token with a new ULID. |
| I5 | No UPDATE / DELETE on experience_tokens or mcp_witnesses SQL. |
Source scan asserts the modules contain no UPDATE / DELETE FROM strings. |
| I6 | No set_node_id, update_token, delete_token, update_witness, delete_witness exports. |
Export-name scan in tests. |
| I7 | mint_L0 produces a token with level: 'L0', promoted_from: null, feature_hash: null, witnesses: []. |
Direct test. |
| I8 | promote_to_L1 requires cycle_proof.cycle_complete && cycle_proof.counterparty_confirmed. Missing field rejects. |
Predicate check + throw. |
| I9 | promote_to_L1_5 requires ≥1 witness; silent downgrade to L1 if witness rules violated (s05 §Witness registry). |
Returns L1 token instead of L1.5 when rules fail. |
| I10 | promote_to_L2a requires ≥5 same feature_hash, ≥3 distinct scenario, ≥3 distinct counterparty. |
Predicate; throws L2aDiversityError on shortfall. |
| I11 | promote_to_L2b calls predicate.replay(l2a_token); promotion is rejected with L2bInvarianceError when returned class ≠ l2a_token.outcome_class. |
Predicate call site. |
| I12 | feature_hash is deterministic — two invocations on byte-identical inputs return byte-identical hex. |
Test T-FH-determinism. |
| I13 | canonicalize_context always emits string fields (never null / undefined) — missing values bucket to '*'. |
Helper return type guarantees. |
| I14 | Witness reputation_at_witness ≥ 200 enforced both in TS (register_witness throws WitnessReputationFloorError) and in SQL (CHECK ≥ 200). |
Belt + braces. |
| I15 | Witness weight_cap ≤ 30 (encoded as bps × 100). Float-free. |
TS + SQL CHECK. |
| I16 | Independence rule: same counterparty class within 7 days of now rejected (WitnessIndependenceError). |
TS check via indexed SELECT. |
| I17 | Per-episode sum cap: Σ weight_cap ≤ 200 (= 0.4 × MIN_EPISODES × 100). Adding a witness that would exceed throws WitnessSumCapError. |
TS check. |
| I18 | ULID generators consume caller-supplied random_bytes: Uint8Array(10). No Math.random, no crypto.randomBytes call from inside the module. |
Determinism + testability. |
| I19 | created_at is caller-supplied (no Date.now() inside the module). |
Determinism. |
| I20 | All arithmetic is integer. No floats, no parseFloat, no Number.EPSILON, no Math.*. |
Source scan. |
| I21 | count_tokens_by_level returns an integer ≥ 0; on missing rows returns 0 (no throw). |
Test. |
| I22 | No L3 anywhere — no 'L3' literal in the source. |
Source scan asserts. |
§3. Behavioural specification
§3.1 mint_L0(input)
mint_L0({node_id, domain, action, outcome_class, outcome_delta, scenario?, counterparty?, created_at, random_bytes})
→ Token {
id: 'tok_' + ulid(random_bytes, created_at),
node_id,
level: 'L0',
domain,
scenario: scenario ?? null,
counterparty: counterparty ?? null,
action,
outcome_class,
outcome_delta,
witnesses: [],
created_at,
promoted_from: null,
feature_hash: null,
}
§3.2 promote_to_L1(l0, proof, created_at, random_bytes)
- Throws
InvalidPromotionErrorifl0.level !== 'L0'. - Throws
CycleProofMissingErrorifproof.cycle_complete !== true || proof.counterparty_confirmed !== true. - Returns new
Tokenat level'L1',promoted_from: l0.id, fields copied froml0(witnesses still[]).
§3.3 promote_to_L1_5(l1, witnesses, created_at, random_bytes)
Validate the witness array:
witnesses.length >= 1else throwsWitnessSetEmptyError.- Every
w.reputation_at_witness >= 200,w.weight_cap > 0 && w.weight_cap <= 30. Any violation → silent downgrade: return a token at level'L1'carrying the witness ids (the s05 “violation downgrades to L1” rule). The token is materially a fresh L1 row (new id,promoted_from: l1.id, witnesses captured) butlevelstays'L1'. - Sum cap:
Σ witnesses.weight_cap > 200triggers the same downgrade. - Independence: caller asserts independence via
register_witnessat write time; the promoter does not re-check the 7-day rule (consistent witness set is the caller’s responsibility).
Otherwise return new token at level 'L1.5', promoted_from: l1.id, feature_hash: feature_hash(canonicalize_context(l1), l1.action, l1.outcome_class), witnesses captured.
§3.4 promote_to_L2a(tokens, created_at, random_bytes)
Predicates (all required else throw L2aDiversityError):
tokens.length >= 5.- Every token has the same non-null
feature_hash. - Distinct
scenariocount>= 3(treatsnullas the distinct value'*'). - Distinct
counterpartycount>= 3(same null-to-'*'rule). - All tokens share the same
node_id(a token is non-transferable; promoting tokens from different actors makes no sense). - All tokens share the same
domain.
Returns new token at level 'L2a', promoted_from: tokens[0].id (anchor — the rest live in the SQL chain via feature_hash), feature_hash preserved, scenario set to canonical '*', counterparty set to canonical '*', action copied from tokens[0].action, outcome_class copied from tokens[0].outcome_class, outcome_delta set to tokens[0].outcome_delta, witnesses: [] (witnesses are L1.5 evidence; not carried up).
§3.5 promote_to_L2b(l2a, predicate, created_at, random_bytes)
- Throws
InvalidPromotionErrorifl2a.level !== 'L2a'. - Calls
predicate.replay(l2a). - If returned
outcome_class !== l2a.outcome_class: throwsL2bInvarianceErrorwith both classes attached. - Otherwise returns new token at level
'L2b',promoted_from: l2a.id, all other fields copied.
§3.6 feature_hash(canonical_context, action_type, outcome_class)
canonical = JSON.stringify(canonical_context, sortedKeys) + '|' + action_type + '|' + outcome_class
return sha256_hex(canonical)
sortedKeys: Object.keys(obj).sort() recursively for nested objects.
§3.7 canonicalize_context(ctx)
return {
domain: ctx.domain ?? '*',
scenario: ctx.scenario ?? '*',
counterparty: ctx.counterparty ?? '*',
}
§3.8 register_witness(db, input)
- Validate
input.reputation_at_witness >= 200else throwWitnessReputationFloorError. - Validate
0 < input.weight_cap <= 30else throwWitnessWeightCapError. - Independence: SELECT count from
mcp_witnessesWHEREtarget_node_id = ? AND counterparty_class = ? AND created_at > input.created_at - 604800. If count ≥ 1 throwWitnessIndependenceError. - Sum cap: SELECT SUM(weight_cap) WHERE
target_episode_id = ?. Ifsum + input.weight_cap > 200throwWitnessSumCapError. - INSERT witness row; return
Witness.
§3.9 check_independence(db, target_node_id, counterparty_class, now)
Returns true if no witness exists for (target_node_id, counterparty_class) with created_at > now - 604800. Read-only.
§3.10 total_witness_weight(db, target_episode_id)
Returns sum of weight_cap for an episode (integer in bps × 100). Read-only.
§3.11 count_tokens_by_level(db, node_id, domain, level)
SELECT COUNT(*) FROM experience_tokens WHERE node_id = ? AND domain = ? AND level = ?.
§3.12 insert_token(db, token) + select_tokens_by_feature_hash(db, feature_hash)
Plain prepared statements. insert_token binds JSON.stringify(token.witnesses) for the witnesses TEXT column.
§4. Error taxonomy
export class InvalidPromotionError extends Error { ... }
export class CycleProofMissingError extends Error { ... }
export class WitnessSetEmptyError extends Error { ... }
export class L2aDiversityError extends Error {
readonly reason: 'too_few_tokens' | 'mixed_feature_hash' | 'too_few_scenarios'
| 'too_few_counterparties' | 'mixed_node_id' | 'mixed_domain';
}
export class L2bInvarianceError extends Error {
readonly expected_outcome_class: string;
readonly actual_outcome_class: string;
}
Witness errors live in witnesses.ts per §1.2.
§5. Cross-domain contracts
| Touched | Contract |
|---|---|
src/domains/reputation/schema.ts |
Re-imports Domain and DOMAINS. No schema changes. |
src/domains/rules/engine.ts |
Not imported. L2b invariance via TokenInvariancePredicate injected by caller. Engine stays pure. |
src/db/index.ts |
Migration auto-discovered via filename 008_experience_tokens.sql. No runner change. |
src/server.ts |
Untouched. No MCP tool registration (Phase 2 λ posture: library-only). |
§6. ULID generator
// 26-char Crockford base32 (0123456789ABCDEFGHJKMNPQRSTVWXYZ)
// First 10 chars: 48-bit big-endian timestamp (created_at in milliseconds since epoch as bigint).
// Last 16 chars: 80 bits from random_bytes[0..10).
function ulid(random_bytes: Uint8Array, created_at_seconds: number): string;
created_at_seconds is converted to milliseconds (* 1000n) for the timestamp portion to match the standard ULID layout. Two tokens created in the same second can still differ on the 80-bit random tail.
random_bytes.length === 10 validated; otherwise throw TypeError.
§7. Test plan
src/__tests__/domains/reputation/tokens.test.ts
- T1 mint_L0 happy path: level === ‘L0’, promoted_from null, witnesses [], feature_hash null.
- T2 promote_to_L1: cycle_proof required; missing/false throws.
- T3 promote_to_L1: rejects when input level !== L0.
- T4 promote_to_L1_5: empty witnesses → throws.
- T5 promote_to_L1_5: witness rep < 200 → silent downgrade to L1.
- T6 promote_to_L1_5: witness weight_cap > 30 → silent downgrade.
- T7 promote_to_L1_5: sum cap > 200 → silent downgrade.
- T8 promote_to_L1_5: all witnesses valid → L1.5 token.
- T9 promote_to_L2a: <5 tokens → throws (too_few_tokens).
- T10 promote_to_L2a: mixed feature_hash → throws.
- T11 promote_to_L2a: 5 tokens / 2 scenarios → throws (too_few_scenarios).
- T12 promote_to_L2a: 5 tokens / 2 counterparties → throws (too_few_counterparties).
- T13 promote_to_L2a: 5 tokens / 3 scenarios / 3 counterparties → L2a token.
- T14 promote_to_L2a: mixed node_id → throws.
- T15 promote_to_L2a: mixed domain → throws.
- T16 promote_to_L2b: predicate returns matching class → L2b token.
- T17 promote_to_L2b: predicate returns differing class → throws L2bInvarianceError.
- T18 feature_hash determinism: two invocations on identical input → identical hex.
- T19 feature_hash distinct inputs → distinct hex.
- T20 canonicalize_context buckets nulls to ‘*’.
- T21 Append-only: source scan — no UPDATE / DELETE FROM tokens SQL.
- T22 Non-transferable: no
set_node_id/ no node_id mutator export. - T23 No L3: source contains no
'L3'literal. - T24 No update / delete exports.
- T25 mint_L0 with scenario / counterparty null defaults.
- T26 count_tokens_by_level returns 0 for missing rows.
- T27 count_tokens_by_level reflects insert_token side effects.
- T28 select_tokens_by_feature_hash returns matching tokens only.
- T29 ULID rejects random_bytes of wrong length.
- T30 ULID determinism: same created_at + random_bytes → same id.
src/__tests__/domains/reputation/witnesses.test.ts
- W1 register_witness rep < 200 → throws WitnessReputationFloorError.
- W2 register_witness weight_cap > 30 → throws WitnessWeightCapError.
- W3 register_witness weight_cap = 0 → throws WitnessWeightCapError (must be > 0).
- W4 register_witness happy path → row inserted, returned shape matches Witness.
- W5 check_independence: same class within 7 days → false.
- W6 check_independence: same class >7 days apart → true.
- W7 check_independence: different class same window → true.
- W8 register_witness throws WitnessIndependenceError when violated.
- W9 total_witness_weight sums weight_cap values for an episode.
- W10 register_witness sum cap > 200 → throws WitnessSumCapError.
- W11 lookup_witnesses returns matching rows ordered by created_at ASC.
- W12 lookup_witnesses returns [] for missing episode.
- W13 Append-only: source contains no UPDATE / DELETE FROM mcp_witnesses.
- W14 No update / delete witness exports.
Migration test (folded into tokens.test.ts or schema.test.ts)
- M1 Migration 008 applies cleanly to a fresh db (both tables exist).
- M2 Migration is idempotent across two
initDbcalls. - M3 CHECK constraint rejects
level = 'L3'row insert. - M4 CHECK constraint rejects
reputation_at_witness = 199row insert.
§8. Acceptance gates
npm run build— clean.npm run lint— clean.npm test— all 2478 baseline tests + new test additions pass.
Contract done. Proceed to packet.