Audit — P2.2.1 Exponential Decay
Task ID: 7ab3f352-7ba5-4132-9db4-aeda2affb2a0
Branch: feature/p2-2-1-decay
Worktree: .worktrees/claude/p2-2-1-decay
Step: 1 of 5 (audit → contract → packet → implement → verify)
Round: R89 Wave 2 (λ Phase 2 foundation)
Base: origin/main @ 994db1e4 — P2.1.1 schema shipped.
§1. Goal
Inventory every existing surface that the P2.2.1 implementation depends on or must integrate with, so the contract (Step 2) can be written against fixed upstream signatures rather than guessed ones.
P2.2.1 ships pure decay functions over the ReputationRow produced by P2.1.1,
using the decay() primitive from P1.1.1 and the DECAY_* constants from
P1.1.3. No DB writes, no MCP tool registration, no schema migrations.
§2. Upstream κ primitives
§2.1 decay() — src/domains/rules/integer-math.ts:158-176
export function decay(
value: bigint,
rate_bps: bigint,
epochs: bigint,
): bigint
Behaviour observed at 994db1e4:
epochs === 0n→ returnsvalueunchanged.epochs < 0n→ throwsUnderflowError.epochs > MAX_DECAY_EPOCHS(10_000n) → throwsEpochCeilingError.- Compounds per epoch via
apply_bps, floored each step (I5). - Pure, deterministic, integer-only (I1–I8).
P2.2.1 must:
- Pass
bigintarguments (no float coercion). - Convert
score: number↔bigintat the function boundary. - Guard against
epochs > MAX_DECAY_EPOCHSindirectly —inactive_epochsabove the ceiling will propagateEpochCeilingErrorto the caller. Per the prompt, P2.2.1 should not silently clamp; the caller (P2.5.1) chooses.
§2.2 DECAY_* constants — src/domains/rules/bps-constants.ts:50-63
export const DECAY_EXECUTION = 500n; // 5.00% / epoch
export const DECAY_COMMISSIONING = 300n; // 3.00% / epoch
export const DECAY_ARBITRATION = 1_000n; // 10.00% / epoch
export const DECAY_GOVERNANCE = 200n; // 2.00% / epoch
export const DECAY_SOCIAL = 100n; // 1.00% / epoch
All five are present, all bigint, all in [0n, 10_000n] (valid Bps).
Order in the file matches the canonical DOMAINS tuple order from P2.1.1.
§3. Upstream P2.1.1 schema surface
§3.1 Domain union — src/domains/reputation/schema.ts:60-84
export const DOMAINS = [
'execution',
'commissioning',
'arbitration',
'governance',
'social',
] as const;
export type Domain = (typeof DOMAINS)[number];
Closed-set, frozen at module load. Exhaustive switch over Domain in
rate_for() will let TS catch a sixth-domain addition via the never check.
§3.2 ReputationRow interface — src/domains/reputation/schema.ts:99-106
export interface ReputationRow {
readonly node_id: string;
readonly domain: Domain;
readonly score: number; // integer bps in [0, 10000]
readonly scar_bps: number; // integer bps
readonly ban_until_epoch: number | null;
readonly last_activity_epoch: number; // integer epoch
}
Notes:
- All fields are
readonly— TS structural readonly. P2.2.1 must return a fresh object via spread; never mutate. score: number,last_activity_epoch: number. Storage type isnumberper the P2.1.1 module comment (line 28-33): “better-sqlite3 INTEGER columns return as JSnumber. The bps range [0, 10_000] fits insideNumber.MAX_SAFE_INTEGER.”- Bigint arithmetic happens in this module; we convert at the IO boundary.
§3.3 Existing reputation surface — src/domains/reputation/
src/domains/reputation/
schema.ts — P2.1.1 (DOMAINS, Domain, ReputationRow, schema-level helpers)
src/__tests__/domains/reputation/
schema.test.ts — P2.1.1 tests (T1–T14)
No decay.ts, no compute.ts, no penalties.ts. P2.2.1 is a clean add.
§4. Files to create
| Path | Purpose |
|---|---|
src/domains/reputation/decay.ts |
Pure decay functions: rate_for, apply_decay, apply_decay_batch |
src/__tests__/domains/reputation/decay.test.ts |
Unit + property + perf-smoke tests |
docs/audits/p2-2-1-decay-audit.md |
This file (Step 1) |
docs/contracts/p2-2-1-decay-contract.md |
Step 2 |
docs/packets/p2-2-1-decay-packet.md |
Step 3 |
docs/verification/p2-2-1-decay-verification.md |
Step 5 |
§5. Files NOT to touch
src/domains/reputation/schema.ts— locked at P2.1.1; P2.2.1 imports only.src/domains/rules/integer-math.ts— locked at P1.1.1.src/domains/rules/bps-constants.ts— locked at P1.1.3.src/db/migrations/*.sql— no schema changes.src/server.ts— no MCP tool registration; P2.5.1 wires the surface.
§6. Invariants and forbidden patterns
| ID | Statement |
|---|---|
| AX-01 | apply_decay is pure — returns a new ReputationRow; never mutates the input. |
| AX-02 | apply_decay does NOT modify last_activity_epoch. The event-write path owns that field. |
| AX-03 | rate_for is exhaustive over Domain; a sixth domain is a TS compile error via never narrowing. |
| AX-04 | All arithmetic flows through decay() from integer-math.ts. No local re-implementation. |
| AX-05 | No Math.*, no Date.*, no Math.random in decay.ts — κ I1/I2/I4. |
| AX-06 | No better-sqlite3 import — pure functions only. P2.5.1 owns the persistence layer. |
| AX-07 | apply_decay_batch is a pure map over apply_decay — no shared mutable state. |
| AX-08 | Score floor at 0 is provided by decay() (P1.1.1 AC#7); re-verified at this layer. |
| AX-09 | bigint ↔ number conversion happens at the function boundary only — no leaks. |
§7. Edge cases identified
| ID | Case |
|---|---|
| E1 | current_epoch === last_activity_epoch → unchanged row (zero inactive epochs). |
| E2 | current_epoch < last_activity_epoch → unchanged row (clock-skew guard; inactive = max(0, …)). |
| E3 | current_epoch > last_activity_epoch by 1 → one-epoch decay. |
| E4 | score === 0 → stays at 0 across any number of epochs (decay floor). |
| E5 | inactive_epochs > MAX_DECAY_EPOCHS → propagates EpochCeilingError from decay(). |
| E6 | All five domains → distinct rate_for results; exhaustive over DOMAINS. |
| E7 | Object.freeze(row) input → apply_decay must still return a fresh object, never throw. |
| E8 | Batch of 10,000 rows → completes under 50ms (smoke perf check). |
| E9 | Compound asymmetry: decay(decay(x, r, e1), r, e2) !== decay(x, r, e1+e2) due to per-step floor. |
§8. Test plan summary
Mirrors src/__tests__/domains/reputation/schema.test.ts posture:
- Plain
describe/itblocks (Jest ESM). - No DB fixtures — pure in-memory rows.
- Determinism check by running the same input twice and asserting deep equality.
- Perf-smoke uses
performance.now()(no Date.*).
Coverage map → contract §7:
rate_for: T1 (each domain returns canonical constant), T2 (TSnevercheck is type-only).apply_decay: T3 (zero-inactive identity), T4 (clock-skew identity), T5 (10-epoch execution decay matchesdecay()reference), T6 (score=0 floor), T7 (last_activity_epoch unchanged), T8 (input not mutated —Object.freeze), T9 (each domain decays at its own rate).apply_decay_batch: T10 (10k rows, 50ms perf smoke), T11 (empty array → empty array), T12 (mixed domains within one batch).- Property: T13 (compound asymmetry —
decay(decay(x, r, e1), r, e2) !== decay(x, r, e1+e2)). - Determinism: T14 (same input twice → deep equal output).
§9. Risks and dependencies
- No new npm dependency.
- No DB migration.
- ESM
.jsimport suffix per project convention (NodeNext). - TS
exactOptionalPropertyTypesis on (pertsconfig.json); avoidundefinedfields. - Jest config: tests must end with
.test.tsand live undersrc/__tests__/.
§10. Out of scope for this task
compute_score(node_id, domain)— P2.1.2 (parallel).- Penalty / scar application — P2.2.2 (parallel).
- MCP tool surface — P2.5.1 (downstream).
- DB writes to
reputations— owned by the event-write path, not by decay. - Multi-process decay job orchestration — Phase 1.5+.
§11. Gates before contract step
decay()signature confirmed atsrc/domains/rules/integer-math.ts:158-176.DECAY_*constants present atsrc/domains/rules/bps-constants.ts:50-63.ReputationRowshape confirmed atsrc/domains/reputation/schema.ts:99-106.- No
decay.tsalready exists insrc/domains/reputation/. - Test path
src/__tests__/domains/reputation/decay.test.tsis free.
Audit complete. Proceed to Step 2 (contract).