Packet — P2.2.2 Offense Penalties
Task ID: f693961c-cc51-4384-8e75-93c386c6a4a1
Branch: feature/p2-2-2-penalties
Step: 3 of 5 (audit ✓ → contract ✓ → packet → implement → verify)
Audit: docs/audits/p2-2-2-penalties-audit.md
Contract: docs/contracts/p2-2-2-penalties-contract.md
The execution plan for Step 4. This document gates Step 4: no code may land until the packet is approved (CLAUDE.md §6).
§1. Goal
Ship two new files in the worktree:
src/domains/reputation/penalties.ts— the pure penalty library (≈ 230 LOC).src/__tests__/domains/reputation/penalties.test.ts— 34 test cases per contract §7 (≈ 480 LOC).
Plus the verification doc in Step 5. No other path is touched.
§2. Execution sequence
§2.1 Create src/domains/reputation/penalties.ts
Skeleton (decisions D1–D8 from contract §3 baked in):
/**
* Colibri — Phase 2 λ Reputation: offense penalty engine.
*
* Pure-function library that turns a (row, severity_band, current_epoch,
* event_id, reason) tuple into:
* - the post-penalty ReputationRow (score, scar_bps, ban_until_epoch,
* last_activity_epoch updated)
* - a history_event ready to feed insertHistoryEvent (P2.1.1)
*
* Pure: no I/O, no clock, no RNG, no DB. The caller composes apply_penalty's
* output with `insertHistoryEvent` + a row-update operation it owns.
*
* Canonical references:
* - docs/audits/p2-2-2-penalties-audit.md
* - docs/contracts/p2-2-2-penalties-contract.md
* - docs/packets/p2-2-2-penalties-packet.md
* - docs/spec/s04-reputation.md §Damage table + §Permanent scars
* - docs/3-world/social/reputation.md §Penalty schedule
*
* Storage boundary: ReputationRow uses `number` for score/scar/ban_until.
* The bigint arithmetic happens inside the function body; cast at the
* return statement.
*/
import {
BPS_MAX,
DAMAGE_CRITICAL,
DAMAGE_FRAUD,
DAMAGE_MINOR,
DAMAGE_MODERATE,
DAMAGE_SEVERE,
} from '../rules/bps-constants.js';
import { apply_bps } from '../rules/integer-math.js';
import type { ReputationHistoryRow, ReputationRow } from './schema.js';
// §A. SeverityBand
export type SeverityBand = 'minor' | 'moderate' | 'severe' | 'critical' | 'fraud';
export const SEVERITY_BANDS: readonly SeverityBand[] = [
'minor', 'moderate', 'severe', 'critical', 'fraud',
] as const;
// §B. Governance parameter
/**
* Default ban duration (epochs) for critical / fraud bands. Starting value
* pending Phase 6 π governance tune via rule upgrade.
*/
export const BAN_DURATION_EPOCHS = 100n;
// §C. Typed error
/** Thrown by apply_penalty when (event_id, band) already exists in history. */
export class DoublePenaltyError extends Error {
override readonly name = 'DoublePenaltyError';
readonly event_id: string;
readonly band: SeverityBand;
constructor(event_id: string, band: SeverityBand) {
super(`apply_penalty: double-jeopardy for event ${event_id} band ${band}`);
this.event_id = event_id;
this.band = band;
}
}
// §D. damage_for
export function damage_for(band: SeverityBand): bigint {
switch (band) {
case 'minor': return DAMAGE_MINOR;
case 'moderate': return DAMAGE_MODERATE;
case 'severe': return DAMAGE_SEVERE;
case 'critical': return DAMAGE_CRITICAL;
case 'fraud': return DAMAGE_FRAUD;
default: {
const exhaustive: never = band;
throw new TypeError(`damage_for: unknown band ${String(exhaustive)}`);
}
}
}
// §E. is_double_penalty
export function is_double_penalty(
event_id: string,
band: SeverityBand,
history: readonly ReputationHistoryRow[],
): boolean {
for (const row of history) {
if (row.event_id === event_id && row.reason.startsWith(`band:${band}`)) {
return true;
}
}
return false;
}
Note on the double-jeopardy match key. The contract §3 D1 says the guard is
keyed by (event_id, band). The persisted ReputationHistoryRow only carries
event_id, delta, reason — not an explicit band column. The packet
inscribes the band into the reason field with the prefix band:<band>|<reason>
(documented in the function header). Implementation must keep this
contract-internal: callers that don’t use apply_penalty to construct their
reason strings will not engage the guard, but they will also not benefit from
the double-jeopardy protection — that’s by design (the guard is a property of
this module’s outputs).
The reason field stays human-readable: e.g. band:fraud|REP_FRAUD_PROVEN. The
prefix is a stable parsing token.
§2.2 apply_penalty
export function apply_penalty(
row: ReputationRow,
band: SeverityBand,
current_epoch: bigint,
event_id: string,
reason: string,
history: readonly ReputationHistoryRow[] = [],
): { row: ReputationRow; history_event: Omit<ReputationHistoryRow, 'id'> } {
// Double-jeopardy guard (contract D1).
if (is_double_penalty(event_id, band, history)) {
throw new DoublePenaltyError(event_id, band);
}
const damage = damage_for(band);
// bigint arithmetic — single boundary cast on the return path.
const score_in = BigInt(row.score);
const score_out_raw = apply_bps(score_in, damage); // value - floor(value*bps/10000)
const delta_magnitude = score_in - score_out_raw; // >= 0n by integer-math AC#7
// Defensive clamp at 0n; never raises score (AX-09).
const score_out = score_out_raw < 0n ? 0n : score_out_raw;
// Scar (contract D2).
const scar_in = BigInt(row.scar_bps);
const scar_out =
band === 'fraud'
? (scar_in + DAMAGE_FRAUD > BPS_MAX ? BPS_MAX : scar_in + DAMAGE_FRAUD)
: scar_in;
// Ban (contract D3).
const ban_out: number | null =
band === 'critical' || band === 'fraud'
? Number(current_epoch + BAN_DURATION_EPOCHS)
: row.ban_until_epoch;
// Reason key (contract internal — see §2.1 note).
const tagged_reason = `band:${band}|${reason}`;
const next_row: ReputationRow = {
node_id: row.node_id,
domain: row.domain,
score: Number(score_out),
scar_bps: Number(scar_out),
ban_until_epoch: ban_out,
last_activity_epoch: Number(current_epoch),
};
const history_event: Omit<ReputationHistoryRow, 'id'> = {
node_id: row.node_id,
domain: row.domain,
epoch: Number(current_epoch),
delta: -Number(delta_magnitude),
reason: tagged_reason,
event_id,
};
return { row: next_row, history_event };
}
§2.3 Create src/__tests__/domains/reputation/penalties.test.ts
34 test(...) calls organised into describe blocks per contract §7:
damage_for(T1–T7) — 7 cases including unknown band TypeError + cardinality.apply_penaltyper band (T8–T14) — 7 cases covering all 5 bands at score=10000 plus floor and fraud-at-zero.apply_penaltyscar mechanics (T15–T17) — 3 cases.apply_penaltyban mechanics (T18–T20) — 3 cases.apply_penaltylast-activity (T21) — 1 case.is_double_penalty(T22–T25) — 4 cases.apply_penaltydouble-jeopardy (T26–T27) — 2 cases.apply_penaltyshape + invariants (T28–T34) — 7 cases.
Total expected test count = 34. Per-test pass criteria locked in contract §7 table.
§2.4 Update todos and commit
feat(p2-2-2-penalties): 5-band penalty system + scar + ban + double-jeopardy guard— single commit covers both files.- The commit message body documents the per-band damage table and the contract D1 throw-decision.
§3. Risks and mitigations
| Risk | Mitigation |
|---|---|
| Double-jeopardy guard misses bands because history rows lack a band column. | Inscribe band:<band>| prefix in reason. Documented in module header and contract D1. |
BigInt(NaN) or similar boundary failure if row.score is a float (Zod should already reject). |
Defensive: rely on P2.1.1 Zod validation upstream. P2.2.2 will not re-validate row (avoids double-validation cost on hot path). |
Number(current_epoch + BAN_DURATION_EPOCHS) overflow for adversarial current_epoch near Number.MAX_SAFE_INTEGER. |
Out of scope for Phase 0 — epoch values are bounded by the clock cell. Documented as a Phase 6 π governance concern. |
Linter trips on the default: never exhaustiveness pattern. |
Use const exhaustive: never = band; throw … — the canonical TS-strict pattern; passes typecheck and lint in this codebase. |
Test file’s import of ReputationRow collides with helper construction. |
Use a makeRow factory at the top of the test file (mirrors schema.test.ts pattern). |
§4. Acceptance map
| Spec line (§2.1 of source prompt, §P2.2.2 acceptance criteria) | Contract |
|---|---|
| 5 severity bands wired to DAMAGE_* | §3 D7 + §7 T1–T6 |
| Scar appends only on fraud, capped at 10000n | §3 D2 + §7 T15–T17 |
| Ban only on critical/fraud (BAN_DURATION_EPOCHS = 100n) | §3 D3 + §7 T18–T20 |
Double-jeopardy guard via is_double_penalty |
§3 D1 + §7 T22–T27 |
| Pure function — caller does the DB insert | §4 AX-01 + §7 T28 |
| BPS math via apply_bps | §4 AX-02 + impl §2.2 |
| No deletion of history; append-only | §4 AX-09 + §7 T30, T31 |
| Floor at 0 | §3 D6 + §7 T13 |
| Recovery path (ban_until_epoch passes) | §3 D3 — read-only on consumer side; covered by spec |
§5. Test count delta projection
Baseline (per dispatch packet): 2444 tests.
Expected delta: +34 (one test(...) per contract §7 row).
Predicted post-merge count: 2478 tests.
Tolerance: ±3 (one test may split into a parametrised pair; one may collapse if TypeScript’s exhaustiveness check absorbs T6’s runtime path).
§6. Gates (CLAUDE.md §5)
After Step 4 lands:
cd .worktrees/claude/p2-2-2-penalties
npm run build && npm run lint && npm test
All three must pass. Lint failures block the verification step — sub-agents
have historically skipped npm run lint per CLAUDE.md §5; not here.
§7. Sign-off
The plan is locked. Step 4 — implement — begins after this packet commits.