Contract — P2.2.2 Offense Penalties
Task ID: f693961c-cc51-4384-8e75-93c386c6a4a1
Branch: feature/p2-2-2-penalties
Step: 2 of 5 (audit ✓ → contract → packet → implement → verify)
Audit: docs/audits/p2-2-2-penalties-audit.md
The behavioural contract for the λ offense-penalty slice. Locked at this step; any deviation in implementation must update this contract first.
§1. Goal
Ship the five-band penalty engine (Minor / Moderate / Severe / Critical / Fraud) layered on top of:
- The P1.1.3
DAMAGE_*bps constants (src/domains/rules/bps-constants.ts). - The P1.1.1
apply_bpsinteger math primitive (src/domains/rules/integer-math.ts). - The P2.1.1 reputation rows +
insertHistoryEventwrite path (src/domains/reputation/schema.ts).
The slice is a pure function library — no DB writes inside this module. The
caller composes apply_penalty’s output with insertHistoryEvent and a
state-update operation it owns.
§2. Public TypeScript surface
Exported from src/domains/reputation/penalties.ts:
// 1. Severity band enum
export type SeverityBand = 'minor' | 'moderate' | 'severe' | 'critical' | 'fraud';
// Closed-set tuple (for iteration + Zod adoption by future tasks)
export const SEVERITY_BANDS: readonly SeverityBand[];
// 2. Governance parameter
export const BAN_DURATION_EPOCHS: bigint; // = 100n
// 3. Damage lookup
export function damage_for(band: SeverityBand): bigint;
// minor -> 1500n
// moderate -> 3000n
// severe -> 5000n
// critical -> 8000n
// fraud -> 10000n
// 4. Double-jeopardy guard
export function is_double_penalty(
event_id: string,
band: SeverityBand,
history: readonly ReputationHistoryRow[],
): boolean;
// 5. Pure penalty applier
export function apply_penalty(
row: ReputationRow,
band: SeverityBand,
current_epoch: bigint,
event_id: string,
reason: string,
): { row: ReputationRow; history_event: Omit<ReputationHistoryRow, 'id'> };
// 6. Typed error for double-jeopardy
export class DoublePenaltyError extends Error {
readonly event_id: string;
readonly band: SeverityBand;
}
Forbidden exports (would break the pure-function discipline or expose heritage donor names):
updatePenalty,deletePenalty,revertPenalty,applyPenaltyAndCommit,recordPenalty(no DB-side helpers).- Any function whose body contains
db.prepare,db.exec,db.run,UPDATE reputation_history, orDELETE FROM reputation_history.
§3. Decisions (open questions from audit §6)
D1 — Double-jeopardy semantics
apply_penalty throws a typed DoublePenaltyError when the (event_id, band)
tuple already lives in the supplied history slice. The caller decides whether
to try/catch (silent no-op) or let it propagate (strict mode).
DoublePenaltyError extends Error with two readonly fields: event_id and
band. Name and shape mirror OverflowError / DivisionByZeroError from
integer-math.ts. The error message is human-readable: "apply_penalty:
double-jeopardy for event ${event_id} band ${band}".
This means callers that want per-event idempotency should pass the relevant
history slice; callers that want per-tuple idempotency should not. The
guard is a function of the history passed in — apply_penalty has no DB
access, so it cannot self-verify against the canonical store.
D2 — Scar arithmetic
scar_bps accumulates only when band === 'fraud':
new_scar = clamp(0n, BPS_MAX, BigInt(row.scar_bps) + DAMAGE_FRAUD)
Non-fraud bands leave scar_bps unchanged. The clamp guarantees scar_bps ∈
[0, 10_000] at all times, matching ReputationRowSchema’s storage bounds.
Sequential fraud penalties keep adding until clamp engages — once scar_bps =
10_000n, the node’s future maximum score ceiling is 0 (interpretation of
s04 §Permanent scars; not enforced inside apply_penalty — the cap is the
job of the future P2.5.1 read tool).
D3 — Ban arithmetic
Critical and fraud bands set:
new_ban_until = Number(current_epoch + BAN_DURATION_EPOCHS)
Other bands preserve the existing row.ban_until_epoch (whether null or
an integer). Bans are not removed by apply_penalty; they expire by the
caller comparing ban_until_epoch < current_epoch.
D4 — Last-activity epoch
apply_penalty sets row.last_activity_epoch = Number(current_epoch). Reason
documented in audit §6.4: a penalty is activity; failing to update would let
decay double-count.
D5 — Sign discipline
history_event.delta is negative (a penalty reduces score). Computed as
-Number(bps_mul_eq) where bps_mul_eq = BigInt(row.score) -
apply_bps(BigInt(row.score), damage). When row.score = 0, bps_mul_eq = 0n
so delta = 0 (a no-op event still gets logged — keeps the audit trail intact).
D6 — Score floor
row.score >= 0 is invariant from P2.1.1 (Zod min). apply_bps(value, bps)
with value >= 0 and bps ∈ [0, 10_000] cannot underflow (per integer-math.ts
contract AC#7). The result therefore lands in [0, 10_000] and no manual
Math.max(0, ...) clamp is required — but the implementation will still
defensive-clamp to 0n to make the invariant local-and-readable.
D7 — Closed-set membership check
damage_for(band) and apply_penalty(... band ...) rely on TypeScript’s
exhaustiveness checking via the SeverityBand union. At runtime, a hostile
caller could pass a string that the compiler missed (e.g. JSON.parse). The
defensive layer is a switch over the five bands with a throw new
TypeError(damage_for: unknown band ${band}) default. This keeps behaviour
deterministic at the function boundary.
D8 — Lower-case band names
Spec docs use Title-case (“Minor”, “Moderate”) but the task-prompt and the
dispatch packet specify lower-case (“minor”, “moderate”). Lower-case wins
— this matches the Domain enum style from P2.1.1 ('execution',
'commissioning', …) and produces no JSON-boundary capitalisation surprises.
§4. Internal invariants
| ID | Invariant | Enforcement |
|---|---|---|
| AX-01 | apply_penalty is pure (no I/O, no clock, no RNG, no globals). |
Source grep test. |
| AX-02 | All arithmetic is bigint inside the function body. | TS compile + source grep. |
| AX-03 | Number boundary casts happen only at function entry / exit. | Source grep test. |
| AX-04 | damage_for is total over SeverityBand (no undefined). |
Unit tests cover all 5 bands. |
| AX-05 | scar_bps output ∈ [0, 10_000] at all times. |
Unit test on repeated fraud. |
| AX-06 | score output ∈ [0, 10_000] at all times. |
Unit test on score=0 floor + score=10_000 max-damage. |
| AX-07 | ban_until_epoch cleared only when the band does not ban. |
Critical/fraud overwrite; others preserve. |
| AX-08 | last_activity_epoch is Number(current_epoch) on every call. |
Unit test. |
| AX-09 | history_event.delta <= 0. |
Unit test (penalty never raises score). |
| AX-10 | is_double_penalty is read-only — does not mutate history. |
Source grep + unit test. |
| AX-11 | apply_penalty throws DoublePenaltyError when the guard fires. |
Unit test. |
| AX-12 | The module imports nothing from node:* (pure library). |
TS compile + source grep. |
§5. Failure modes
| Scenario | Behaviour |
|---|---|
Caller passes band = "foobar" (TS bypassed). |
damage_for throws TypeError. apply_penalty propagates. |
Caller passes the same (event_id, band) that already exists in history. |
apply_penalty throws DoublePenaltyError. |
Caller passes current_epoch < row.last_activity_epoch (clock skew). |
Allowed — apply_penalty overwrites last_activity_epoch unconditionally. No clock validation in this module. |
Caller passes row.scar_bps = 10_000 and band = "fraud". |
Clamp engages; new_scar = 10_000n. No throw. |
Caller passes row.score = 0 and any band. |
delta = 0; score stays 0; scar/ban handled per D2/D3. |
Caller passes empty history array for the double-jeopardy check. |
is_double_penalty returns false. apply_penalty proceeds. |
§6. Files
| Path | Action | Owner of test |
|---|---|---|
src/domains/reputation/penalties.ts |
Create | Step 4. |
src/__tests__/domains/reputation/penalties.test.ts |
Create | Step 4. |
docs/contracts/p2-2-2-penalties-contract.md |
This file. | n/a. |
docs/audits/p2-2-2-penalties-audit.md |
Already exists from Step 1. | n/a. |
§7. Test coverage map (preview for Step 4)
| ID | Coverage | Pass criterion |
|---|---|---|
| T1 | damage_for('minor') |
=== 1_500n |
| T2 | damage_for('moderate') |
=== 3_000n |
| T3 | damage_for('severe') |
=== 5_000n |
| T4 | damage_for('critical') |
=== 8_000n |
| T5 | damage_for('fraud') |
=== 10_000n |
| T6 | damage_for over unknown band |
throws TypeError |
| T7 | SEVERITY_BANDS cardinality + order |
[minor, moderate, severe, critical, fraud] |
| T8 | Minor penalty on score=10000 | new score = 8500, delta = -1500 |
| T9 | Moderate on score=10000 | new score = 7000, delta = -3000 |
| T10 | Severe on score=10000 | new score = 5000, delta = -5000 |
| T11 | Critical on score=10000 | new score = 2000, delta = -8000 |
| T12 | Fraud on score=10000 | new score = 0, delta = -10000 |
| T13 | Minor on score=0 | new score = 0, delta = 0 |
| T14 | Fraud on score=0 | scar still updates; ban still applies |
| T15 | Fraud adds 10000n to scar_bps | new scar_bps = 10000 |
| T16 | Repeated fraud clamps scar at 10000 | scar_bps stays 10000 |
| T17 | Non-fraud bands preserve scar | scar unchanged for minor/moderate/severe/critical |
| T18 | Critical sets ban_until_epoch = current + 100 | ban = current + 100 |
| T19 | Fraud sets ban_until_epoch = current + 100 | ban = current + 100 |
| T20 | Minor/moderate/severe leave ban_until_epoch unchanged | ban === row.ban_until_epoch |
| T21 | apply_penalty updates last_activity_epoch |
last_activity_epoch = Number(current_epoch) |
| T22 | is_double_penalty returns false on empty history |
=== false |
| T23 | is_double_penalty returns true on matching (event_id, band) |
=== true |
| T24 | is_double_penalty returns false on matching event_id but different band |
=== false |
| T25 | is_double_penalty does not mutate history |
history.length unchanged after call |
| T26 | apply_penalty throws DoublePenaltyError on guard hit |
throw with correct event_id + band |
| T27 | DoublePenaltyError carries event_id + band fields |
err.event_id + err.band set |
| T28 | apply_penalty produces no DB write |
(no db param exists; covered by signature compile) |
| T29 | history_event shape matches HistoryInsertSchema |
parse(history_event) succeeds |
| T30 | history_event.delta is negative for non-zero penalty | delta < 0 when score > 0 |
| T31 | Source contains no UPDATE/DELETE FROM SQL |
regex on file content === [] |
| T32 | Source imports only from ../rules/bps-constants.js, ../rules/integer-math.js, ./schema.js |
import-graph assertion |
| T33 | apply_penalty returns same row reference type (readonly Domain) |
row.domain === input.domain |
| T34 | Pure: calling twice with same args yields structurally equal output | deep-equal across two calls |
§8. Sign-off
Decisions D1–D8 are locked. Step 3 — packet — will detail the execution sequence and file diff. Step 4 must not deviate from §2 / §3 / §4 without first amending this file.