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):
updateReputationdeleteReputationdeleteHistorytruncateReputation- Any function whose body contains
UPDATE reputation*orDELETE 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:
domainis TEXT, not an enum type (SQLite has no enum). Domain validation lives in the Zod layer.scar_bpshas the same[0, 10000]bound asscore; without this, a runaway penalty could blow past 100% reduction.- No foreign keys.
nodestable 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 fromlastInsertRowid. - Validates
rowagainstReputationHistoryRowSchema.omit({ id: true })before binding parameters. ThrowsZodErroron validation failure. - Does NOT touch the
reputationstable. Score recomputation is P2.1.2’s job; this function only appends an event.
§6.2 selectReputation(db, node_id, domain?)
- If
domainis given: returns the single matchingReputationRowornullif no row exists for that(node_id, domain). - If
domainis omitted: returnsReputationRow[]— all rows fornode_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 byepoch DESC, thenid DESC(most recent first). opts.limit— defaults to100; capped at1000.opts.offset— defaults to0.opts.before_epoch— if set, returns only rows withepoch < 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.