Contract — P2.1.1 Reputation Record Schema

Task ID: c4bd3540-34a6-478a-b02b-567aa4aa2df6 Branch: feature/p2-1-1-rep-schema Step: 2 of 5 (audit ✓ → contract → packet → implement → verify) Audit: docs/audits/p2-1-1-rep-schema-audit.md

The behavioural contract for the foundational λ Reputation slice. Locked at this step; any deviation in implementation must update this contract first.

§1. Goal

Ship the SQLite schema, TypeScript types, and append-only insert helper for the two λ tables that downstream Phase 2 work consumes:

  • reputations — current per-(node, domain) state.
  • reputation_history — append-only audit log of bps deltas.

No score computation, no decay, no penalty application. Those land in P2.1.2 / P2.2.1 / P2.2.2.

§2. Public TypeScript surface

Exported from src/domains/reputation/schema.ts:

// 1. Enum
export type Domain = 'execution' | 'commissioning' | 'arbitration' | 'governance' | 'social';
export const DOMAINS: readonly Domain[]; // exact members of `Domain`, frozen at module load

// 2. Row types
export interface ReputationRow {
  node_id: string;            // ξ Soul Vector id; no FK in Phase 0
  domain: Domain;             // closed enum
  score: number;              // integer bps in [0, 10000]
  scar_bps: number;           // integer bps; cumulative permanent penalty
  ban_until_epoch: number | null;  // null = not banned
  last_activity_epoch: number;     // last epoch with any history event
}

export interface ReputationHistoryRow {
  id: number;                 // AUTOINCREMENT primary key
  node_id: string;
  domain: Domain;
  epoch: number;              // event epoch
  delta: number;              // signed bps; negative = penalty
  reason: string;             // free-form audit label
  event_id: string;           // upstream event id (free-form in Phase 0)
}

// 3. Zod validators
export const DomainSchema: z.ZodEnum<[...Domain[]]>;
export const ReputationRowSchema: z.ZodObject<{...}>;     // shape of ReputationRow
export const ReputationHistoryRowSchema: z.ZodObject<{...}>;  // shape with id

// 4. Append-only write helper (THE ONLY allowed mutation path)
export function insertHistoryEvent(
  db: Database.Database,
  row: Omit<ReputationHistoryRow, 'id'>,
): { id: number };

// 5. Readers
export function selectReputation(
  db: Database.Database,
  node_id: string,
  domain?: Domain,
): ReputationRow | ReputationRow[] | null;

export function selectHistory(
  db: Database.Database,
  node_id: string,
  domain: Domain,
  opts?: { limit?: number; offset?: number; before_epoch?: number },
): ReputationHistoryRow[];

Forbidden exports (would violate AX-01 append-only invariant):

  • updateReputation
  • deleteReputation
  • deleteHistory
  • truncateReputation
  • Any function whose body contains UPDATE reputation* or DELETE FROM reputation* SQL.

§3. Database schema (migration 007)

CREATE TABLE reputations (
  node_id              TEXT NOT NULL,
  domain               TEXT NOT NULL,
  score                INTEGER NOT NULL DEFAULT 0,
  scar_bps             INTEGER NOT NULL DEFAULT 0,
  ban_until_epoch      INTEGER,
  last_activity_epoch  INTEGER NOT NULL,
  PRIMARY KEY (node_id, domain),
  CHECK (score >= 0 AND score <= 10000),
  CHECK (scar_bps >= 0 AND scar_bps <= 10000)
);

CREATE TABLE reputation_history (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  node_id    TEXT NOT NULL,
  domain     TEXT NOT NULL,
  epoch      INTEGER NOT NULL,
  delta      INTEGER NOT NULL,
  reason     TEXT NOT NULL,
  event_id   TEXT NOT NULL
);

CREATE INDEX idx_reputations_lookup       ON reputations(node_id, domain);
CREATE INDEX idx_reputations_leaderboard  ON reputations(domain, score DESC);
CREATE INDEX idx_history_node             ON reputation_history(node_id, domain, epoch DESC);

Notes:

  • domain is TEXT, not an enum type (SQLite has no enum). Domain validation lives in the Zod layer.
  • scar_bps has the same [0, 10000] bound as score; without this, a runaway penalty could blow past 100% reduction.
  • No foreign keys. nodes table is Phase 2+ (ξ Soul Vector).
  • AUTOINCREMENT is preferred over plain ROWID because the contract is append-only and we want the row id to be a monotonic audit cursor.

§4. Invariants

ID Statement Enforced by
AX-01 reputation_history is append-only. No UPDATE or DELETE SQL anywhere in src/domains/reputation/**. Grep-style test in schema.test.ts
AX-02 Five-domain closed set. Sixth domain (e.g. 'foo') is rejected by DomainSchema.parse. Zod z.enum(DOMAINS) + test
AX-03 score[0, 10000] at the storage layer. SQL CHECK constraint
AX-04 score[0, 10000] at the TS validation layer. ReputationRowSchema
AX-05 score is an integer. z.number().int()
AX-06 scar_bps[0, 10000] and integer. SQL CHECK + Zod
AX-07 (node_id, domain) is unique. SQL PRIMARY KEY
AX-08 Migration is idempotent. Running initDb twice on the same DB file does not error. user_version skip in the runner
AX-09 The Phase 0 reputation surface has zero mutator exports. Named-export grep test
AX-10 ban_until_epoch defaults to NULL (no ban) and is the only nullable column. SQL column definition + Zod .nullable()

§5. Bps integration

src/domains/rules/bps-constants.ts already exports BPS_MIN = 0n and BPS_MAX = 10_000n as bigints. The TS schema imports them and converts at the single boundary:

import { BPS_MIN, BPS_MAX } from '../rules/bps-constants.js';

const SCORE_MIN = Number(BPS_MIN);  // 0
const SCORE_MAX = Number(BPS_MAX);  // 10000

const scoreField = z.number().int().min(SCORE_MIN).max(SCORE_MAX);

The TS storage type is number because better-sqlite3 returns INTEGER values in [-2^53, 2^53] as JS number. The bps domain [0, 10000] lives well inside that range. Bigint arithmetic happens only in the compute layer (P2.1.2+) which converts at its own IO boundary.

§6. Function semantics

§6.1 insertHistoryEvent(db, row)

  • Prepares an INSERT statement for reputation_history (no UPDATE; no DELETE).
  • Returns { id: number } — the AUTOINCREMENT row id from lastInsertRowid.
  • Validates row against ReputationHistoryRowSchema.omit({ id: true }) before binding parameters. Throws ZodError on validation failure.
  • Does NOT touch the reputations table. Score recomputation is P2.1.2’s job; this function only appends an event.

§6.2 selectReputation(db, node_id, domain?)

  • If domain is given: returns the single matching ReputationRow or null if no row exists for that (node_id, domain).
  • If domain is omitted: returns ReputationRow[] — all rows for node_id, ordered by domain enumeration (stable). Empty array if none.
  • Read-only. No write side effects.

§6.3 selectHistory(db, node_id, domain, opts?)

  • Returns ReputationHistoryRow[] for the given (node, domain) tuple, ordered by epoch DESC, then id DESC (most recent first).
  • opts.limit — defaults to 100; capped at 1000.
  • opts.offset — defaults to 0.
  • opts.before_epoch — if set, returns only rows with epoch < before_epoch.
  • Empty array if no matches. Does not throw on absent (node, domain).

§7. Tests

src/__tests__/domains/reputation/schema.test.ts must cover:

ID Test
T1 DomainSchema.parse('foo') throws (sixth-domain rejection)
T2 All 5 canonical domains pass DomainSchema.parse
T3 ReputationRowSchema rejects score = -1
T4 ReputationRowSchema rejects score = 10001
T5 ReputationRowSchema rejects score = 100.5 (non-integer)
T6 ReputationRowSchema accepts a canonical row with score = 5000
T7 ReputationHistoryRowSchema accepts delta = -3000 (signed bps)
T8 Migration applies cleanly to a fresh in-memory DB; reputations + reputation_history exist
T9 Migration is idempotent — initDb called twice on the same file does not error
T10 insertHistoryEvent appends a row; selectHistory returns it
T11 selectReputation with no row returns null (single-domain query)
T12 selectReputation with no rows returns [] (all-domain query)
T13 No UPDATE reputation* or DELETE FROM reputation* SQL appears in the source of src/domains/reputation/**
T14 Module exports contain no updateReputation / deleteReputation / deleteHistory names

T9 + T13 are the high-leverage tests for the append-only invariant. T1–T7 are the Zod validation tier. T8 + T10 + T11 + T12 confirm the read/write helpers work against a real DB.

§8. Out of scope (P2.1.2+)

  • Score computation from history (P2.1.2)
  • Decay application (P2.2.1)
  • Offense penalties (P2.2.2)
  • Experience token minting (P2.3.1)
  • Capability gates (P2.4.1)
  • Voting credits derivation
  • κ-driven rule integration

§9. Acceptance gate

Before this slice can be marked complete, all three Phase 0 gates must pass in the worktree:

npm run build && npm run lint && npm test

Plus the verification doc (Step 5) must record:

  • Total test count delta vs baseline.
  • Confirmation that no UPDATE / DELETE SQL appears in the reputation domain.
  • Migration number used (locked at 007 per audit §3).

Contract locked.


Back to top

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

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