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:

  1. src/domains/reputation/penalties.ts — the pure penalty library (≈ 230 LOC).
  2. 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:

  1. damage_for (T1–T7) — 7 cases including unknown band TypeError + cardinality.
  2. apply_penalty per band (T8–T14) — 7 cases covering all 5 bands at score=10000 plus floor and fraud-at-zero.
  3. apply_penalty scar mechanics (T15–T17) — 3 cases.
  4. apply_penalty ban mechanics (T18–T20) — 3 cases.
  5. apply_penalty last-activity (T21) — 1 case.
  6. is_double_penalty (T22–T25) — 4 cases.
  7. apply_penalty double-jeopardy (T26–T27) — 2 cases.
  8. apply_penalty shape + 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.


Back to top

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

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