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 InvalidPromotionError if l0.level !== 'L0'.
  • Throws CycleProofMissingError if proof.cycle_complete !== true || proof.counterparty_confirmed !== true.
  • Returns new Token at level 'L1', promoted_from: l0.id, fields copied from l0 (witnesses still []).

§3.3 promote_to_L1_5(l1, witnesses, created_at, random_bytes)

Validate the witness array:

  1. witnesses.length >= 1 else throws WitnessSetEmptyError.
  2. 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) but level stays 'L1'.
  3. Sum cap: Σ witnesses.weight_cap > 200 triggers the same downgrade.
  4. Independence: caller asserts independence via register_witness at 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 scenario count >= 3 (treats null as the distinct value '*').
  • Distinct counterparty count >= 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 InvalidPromotionError if l2a.level !== 'L2a'.
  • Calls predicate.replay(l2a).
  • If returned outcome_class !== l2a.outcome_class: throws L2bInvarianceError with 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)

  1. Validate input.reputation_at_witness >= 200 else throw WitnessReputationFloorError.
  2. Validate 0 < input.weight_cap <= 30 else throw WitnessWeightCapError.
  3. Independence: SELECT count from mcp_witnesses WHERE target_node_id = ? AND counterparty_class = ? AND created_at > input.created_at - 604800. If count ≥ 1 throw WitnessIndependenceError.
  4. Sum cap: SELECT SUM(weight_cap) WHERE target_episode_id = ?. If sum + input.weight_cap > 200 throw WitnessSumCapError.
  5. 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 initDb calls.
  • M3 CHECK constraint rejects level = 'L3' row insert.
  • M4 CHECK constraint rejects reputation_at_witness = 199 row 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.


Back to top

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

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