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_bps integer math primitive (src/domains/rules/integer-math.ts).
  • The P2.1.1 reputation rows + insertHistoryEvent write 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, or DELETE 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 inapply_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.


Back to top

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

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