Audit — P2.2.2 Offense Penalties

Task ID: f693961c-cc51-4384-8e75-93c386c6a4a1 Branch: feature/p2-2-2-penalties Worktree: .worktrees/claude/p2-2-2-penalties Step: 1 of 5 (audit → contract → packet → implement → verify) Round: R89 Wave 2 (λ Phase 2 — offense application) Spec sources:

  • docs/spec/s04-reputation.md §Damage table + §Permanent scars
  • docs/spec/s05-experience-tokens.md §Decay and scar supersession (referenced; not load-bearing for this slice)
  • docs/spec/s09-arbitration.md §Arbiter constraints (overturned-decision row in damage table)
  • docs/3-world/social/reputation.md §Penalty schedule
  • docs/guides/implementation/task-prompts/p2.1-lambda-reputation.md §P2.2.2 (lines 582–747)

The audit step inventories the existing surface that P2.2.2 must extend (without breaking) and the new surface that P2.2.2 will introduce. Strictly read-only — no implementation lives here.

§1. Why this task exists

P2.1.1 (Wave 1, shipped at 994db1e4) landed the foundational λ slice:

  • reputations and reputation_history SQLite tables (migration 007).
  • ReputationRow / ReputationHistoryRow interfaces + Zod validators.
  • selectReputation / selectHistory readers.
  • insertHistoryEvent (the only allowed write path).

What it deliberately did not ship: any score computation, any decay engine, and any penalty application. Those are split across P2.1.2 (compute), P2.2.1 (decay), and P2.2.2 (penalties) — the slice this audit scopes.

P2.2.2’s job is to mechanise the damage table of s04 §Damage table — turning a severity classification (minor / moderate / severe / critical / fraud) into a deterministic effect on (score, scar_bps, ban_until_epoch) plus an append-only reputation_history row that a caller will persist via insertHistoryEvent.

§2. Existing surface (read-only inputs)

§2.1 BPS constants (src/domains/rules/bps-constants.ts, P1.1.3, shipped)

The five DAMAGE_* constants are already exported as bigint literals:

Symbol Value Spec meaning
DAMAGE_MINOR 1_500n 15% reputation loss
DAMAGE_MODERATE 3_000n 30% reputation loss
DAMAGE_SEVERE 5_000n 50% reputation loss
DAMAGE_CRITICAL 8_000n 80% reputation loss
DAMAGE_FRAUD 10_000n 100% wipe + permanent scar

Also re-exported from this module: BPS_MAX = 10_000n (the scar ceiling), the Bps branded type, and the typed errors. Nothing here needs to change. No DAMAGE_* constants are missing — the precondition stated in the dispatch packet is met.

§2.2 Integer math (src/domains/rules/integer-math.ts, P1.1.1, shipped)

apply_bps(value: bigint, bps: bigint): bigint computes value - floor(value * bps / 10_000). For value >= 0n and bps ∈ [0n, 10_000n] the result stays non-negative (contract AC#7). This is the operation P2.2.2 uses to derive the post-penalty score from the pre-penalty score: the delta magnitude is bps_mul(value, bps) = value - apply_bps(value, bps).

The module also exports OverflowError, DivisionByZeroError, UnderflowError, and EpochCeilingError. apply_bps itself does not throw; overflow is the caller’s responsibility.

§2.3 Reputation schema (src/domains/reputation/schema.ts, P2.1.1, shipped)

The row types this module reads and produces:

export interface ReputationRow {
  readonly node_id: string;
  readonly domain: Domain;
  readonly score: number;        // integer bps in [0, 10000]
  readonly scar_bps: number;     // integer bps in [0, 10000]
  readonly ban_until_epoch: number | null;
  readonly last_activity_epoch: number;
}

export interface ReputationHistoryRow {
  readonly id: number;
  readonly node_id: string;
  readonly domain: Domain;
  readonly epoch: number;
  readonly delta: number;        // signed bps, negative for penalty
  readonly reason: string;
  readonly event_id: string;
}

export type HistoryInsertInput = Omit<ReputationHistoryRow, 'id'>;

Two storage-boundary contracts that drive this slice’s typing decisions:

  1. score / scar_bps / ban_until_epoch are TS number, not bigint. The schema doc comment §”Storage layer” says: “better-sqlite3 INTEGER columns return as JS number. The bps range [0, 10_000] fits inside Number.MAX_SAFE_INTEGER with room to spare … This module never sees bigint.”
  2. epoch is also TS number (reputations.last_activity_epoch, history epoch, and the optional ban_until_epoch). Phase 0 epochs are bounded by real wall-clock; nowhere near 2^53 - 1.

P2.2.2 must respect both: the bigint math happens inside apply_penalty’s body, but its inputs and outputs are number (for row fields) and the helper accepts current_epoch as bigint per the dispatch packet — so a single boundary cast sits inside the function.

No exported helper from P2.1.1 mutates state. P2.2.2 will follow the same rule: pure function, caller does the DB insert.

§3. New surface to ship

Symbol Kind Notes
SeverityBand type alias "minor" \| "moderate" \| "severe" \| "critical" \| "fraud" (lower-case to match s04 §Damage table style).
BAN_DURATION_EPOCHS const 100n — governance parameter. Phase 6 π may tune via rule upgrade.
damage_for(band) function SeverityBand → bigint, returns the matching DAMAGE_*.
is_double_penalty(eventId, band, history) function Boolean — true iff (event_id, band) already appears in history. Caller responsibility to pass the relevant slice.
apply_penalty(row, band, currentEpoch, eventId, reason) function Pure; returns { row: ReputationRow; history_event: Omit<ReputationHistoryRow, 'id'> }.

All five exports live in src/domains/reputation/penalties.ts — new file, no existing module is edited.

§3.1 Tests to ship

src/__tests__/domains/reputation/penalties.test.ts (new file, no existing penalties test) covering:

  • Per-band damage delta (5 bands, each derived from apply_bps baseline).
  • Score floor at zero — minor penalty on score = 0 stays 0.
  • Scar mechanics — fraud adds DAMAGE_FRAUD to scar_bps; clamped at 10_000n; non-fraud bands leave scar untouched.
  • Ban mechanics — critical/fraud set ban_until_epoch = current_epoch + 100n; minor/moderate/severe leave ban_until_epoch unchanged (null or prior value).
  • Double-jeopardy guard — is_double_penalty returns true iff a (event_id, band) tuple already lives in history. apply_penalty rejects (throw) when the guard fires (decision recorded in contract §3).
  • Append-only — history_event has negative delta and correct shape; helper performs no DB write (no db arg).
  • Recovery — after ban_until_epoch < current_epoch, the gate is read-only (no state to mutate from this module — covered by spec, asserted via comment).
  • Sign discipline — delta is -bps_mul(BigInt(score), damage) cast to Number, never the negation of the post-penalty score.

§4. Forbidden moves

Inherited from P2.1.1 (AX-09 append-only) and CLAUDE.md:

  • UPDATE reputation_history … or DELETE FROM reputation_history … anywhere in this slice’s body (or its tests).
  • Math.*, Date.*, Math.random, crypto.randomBytes in penalties.ts — pure-function discipline mirrors integer-math.ts and bps-constants.ts.
  • float arithmetic — damage_for returns bigint; the storage-boundary cast happens once per call inside apply_penalty.
  • ✗ DB writes inside apply_penalty (or any helper here). All persistence is via insertHistoryEvent (P2.1.1) at the caller’s site.
  • ✗ Edits to bps-constants.ts, integer-math.ts, schema.ts, or migration
    1. Those are fixed surfaces; P2.2.2 is purely additive.
  • ✗ Negative damage parameters — damage_for returns positive bps; only the delta field on the history row is negative.
  • --no-verify, --amend, --force-push, edits to the main checkout.

§5. Touched files

Path Action
docs/audits/p2-2-2-penalties-audit.md New (this file).
docs/contracts/p2-2-2-penalties-contract.md New (Step 2).
docs/packets/p2-2-2-penalties-packet.md New (Step 3).
src/domains/reputation/penalties.ts New (Step 4).
src/__tests__/domains/reputation/penalties.test.ts New (Step 4).
docs/verification/p2-2-2-penalties-verification.md New (Step 5).

No existing file is edited. No existing test is touched. The baseline test count (2444 at dispatch) climbs by exactly the count introduced by Step 4.

§6. Open questions (resolved in contract §3)

  1. Double-jeopardy: throw or return unchanged row?

    Two valid interpretations of s04 §Damage table. The dispatch packet says “pick one, document”. Decision (held until §3 of the contract): throw a typed DoublePenaltyError. Rationale:

    • apply_penalty is pure; returning an unchanged row would force callers to diff inputs vs. outputs to detect rejection. A typed error makes the rejection observable at the boundary.
    • Callers that want the silent-no-op behaviour can wrap the call in a try/catch on DoublePenaltyError; callers that want the strict semantics get them by default.
    • Mirrors OverflowError / DivisionByZeroError from integer-math.tsapply_penalty joins the same error taxonomy.
  2. Scar arithmetic across the boundary.

    scar_bps is stored as number. The bigint addition + clamp happens inside apply_penalty; the cast back to number is the final step. The clamp must apply before the cast (otherwise a hypothetical >Number.MAX_SAFE_INTEGER intermediate would corrupt) — easy here since scar_bps + DAMAGE_FRAUD is bounded by 20_000n, well under Number.MAX_SAFE_INTEGER. Documented in the implementation comment.

  3. Reason strings. Per the dispatch packet “common gotchas” note, prefer κ denial-reason taxonomy entries (R87 P1.4.2) such as REP_FRAUD_PROVEN. P2.2.2 does not introduce new reason strings; the caller passes whichever string it owns. This module accepts any non-empty string and writes it through to the reason field. (Validation against the κ taxonomy is the caller’s job, not this module’s.)

  4. Last-activity-epoch update. s04 §Decay says decay timer resets on any activity. A penalty is activity. Decision (contract §3): apply_penalty sets row.last_activity_epoch = Number(current_epoch). Otherwise a fraud penalty followed by inactivity would reduce score twice — once via damage and again via stale-decay.

§7. Verification posture (preview for Step 5)

  • npm run build — TypeScript compile must pass; penalties.ts has only bigint
    • number arithmetic.
  • npm run lint — eslint with the existing .eslintrc shape; no unused imports, no any types.
  • npm test — 7+ describe blocks; expect the suite delta to land in the [+15, +25] range. Test count delta is recorded in Step 5.
  • Append-only invariant proof — Step 5 greps penalties.ts for UPDATE and DELETE FROM SQL strings; both counts must be zero.

§8. Audit sign-off

The surface is non-overlapping with P2.1.1 (no symbol collision; no module edited in src/domains/reputation/). The dispatch packet’s “STOP if DAMAGE_* missing” precondition is resolved: all five constants exist at the expected names + values in src/domains/rules/bps-constants.ts. Proceeding to Step 2 — contract.


Back to top

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

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