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 → returns value unchanged.
  • epochs < 0n → throws UnderflowError.
  • epochs > MAX_DECAY_EPOCHS (10_000n) → throws EpochCeilingError.
  • Compounds per epoch via apply_bps, floored each step (I5).
  • Pure, deterministic, integer-only (I1–I8).

P2.2.1 must:

  1. Pass bigint arguments (no float coercion).
  2. Convert score: numberbigint at the function boundary.
  3. Guard against epochs > MAX_DECAY_EPOCHS indirectly — inactive_epochs above the ceiling will propagate EpochCeilingError to 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 is number per the P2.1.1 module comment (line 28-33): “better-sqlite3 INTEGER columns return as JS number. The bps range [0, 10_000] fits inside Number.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 / it blocks (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 (TS never check is type-only).
  • apply_decay: T3 (zero-inactive identity), T4 (clock-skew identity), T5 (10-epoch execution decay matches decay() 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 .js import suffix per project convention (NodeNext).
  • TS exactOptionalPropertyTypes is on (per tsconfig.json); avoid undefined fields.
  • Jest config: tests must end with .test.ts and live under src/__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 at src/domains/rules/integer-math.ts:158-176.
  • DECAY_* constants present at src/domains/rules/bps-constants.ts:50-63.
  • ReputationRow shape confirmed at src/domains/reputation/schema.ts:99-106.
  • No decay.ts already exists in src/domains/reputation/.
  • Test path src/__tests__/domains/reputation/decay.test.ts is free.

Audit complete. Proceed to Step 2 (contract).


Back to top

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

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