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 scarsdocs/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 scheduledocs/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:
reputationsandreputation_historySQLite tables (migration 007).ReputationRow/ReputationHistoryRowinterfaces + Zod validators.selectReputation/selectHistoryreaders.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:
score/scar_bps/ban_until_epochare TSnumber, notbigint. The schema doc comment §”Storage layer” says: “better-sqlite3 INTEGER columns return as JSnumber. The bps range[0, 10_000]fits insideNumber.MAX_SAFE_INTEGERwith room to spare … This module never sees bigint.”epochis also TSnumber(reputations.last_activity_epoch, historyepoch, and the optionalban_until_epoch). Phase 0 epochs are bounded by real wall-clock; nowhere near2^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_bpsbaseline). - Score floor at zero — minor penalty on score = 0 stays 0.
- Scar mechanics — fraud adds
DAMAGE_FRAUDtoscar_bps; clamped at10_000n; non-fraud bands leave scar untouched. - Ban mechanics — critical/fraud set
ban_until_epoch = current_epoch + 100n; minor/moderate/severe leaveban_until_epochunchanged (null or prior value). - Double-jeopardy guard —
is_double_penaltyreturns true iff a(event_id, band)tuple already lives in history.apply_penaltyrejects (throw) when the guard fires (decision recorded in contract §3). - Append-only — history_event has negative
deltaand correct shape; helper performs no DB write (nodbarg). - 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 —
deltais-bps_mul(BigInt(score), damage)cast toNumber, 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 …orDELETE FROM reputation_history …anywhere in this slice’s body (or its tests). - ✗
Math.*,Date.*,Math.random,crypto.randomBytesinpenalties.ts— pure-function discipline mirrorsinteger-math.tsandbps-constants.ts. - ✗
floatarithmetic —damage_forreturnsbigint; the storage-boundary cast happens once per call insideapply_penalty. - ✗ DB writes inside
apply_penalty(or any helper here). All persistence is viainsertHistoryEvent(P2.1.1) at the caller’s site. - ✗ Edits to
bps-constants.ts,integer-math.ts,schema.ts, or migration- Those are fixed surfaces; P2.2.2 is purely additive.
- ✗ Negative
damageparameters —damage_forreturns positive bps; only thedeltafield 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)
-
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 typedDoublePenaltyError. Rationale:apply_penaltyis 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/catchonDoublePenaltyError; callers that want the strict semantics get them by default. - Mirrors
OverflowError/DivisionByZeroErrorfrominteger-math.ts—apply_penaltyjoins the same error taxonomy.
-
Scar arithmetic across the boundary.
scar_bpsis stored asnumber. The bigint addition + clamp happens insideapply_penalty; the cast back tonumberis the final step. The clamp must apply before the cast (otherwise a hypothetical >Number.MAX_SAFE_INTEGERintermediate would corrupt) — easy here sincescar_bps + DAMAGE_FRAUDis bounded by20_000n, well underNumber.MAX_SAFE_INTEGER. Documented in the implementation comment. -
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-emptystringand writes it through to thereasonfield. (Validation against the κ taxonomy is the caller’s job, not this module’s.) -
Last-activity-epoch update.
s04 §Decaysays decay timer resets on any activity. A penalty is activity. Decision (contract §3):apply_penaltysetsrow.last_activity_epoch = Number(current_epoch). Otherwise a fraud penalty followed by inactivity would reducescoretwice — once via damage and again via stale-decay.
§7. Verification posture (preview for Step 5)
npm run build— TypeScript compile must pass;penalties.tshas onlybigintnumberarithmetic.
npm run lint— eslint with the existing.eslintrcshape; no unused imports, noanytypes.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.tsforUPDATEandDELETE FROMSQL 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.