Audit — Decay Epoch Ceiling Hardening

1. Origin

Medium Finding #13 from a code review of R81.A’s decay(value, rate_bps, epochs) in src/domains/rules/integer-math.ts:

The function compounds floor-decay per epoch via a for loop iterating epochs times. An adversarial caller passing epochs = 1_000_000_000n (or worse, MAX_INT64) hangs the process. The application layer does not currently bound epochs before reaching this function.

2. Surface inventory

2.1 Live code

Path Line Surface Notes
src/domains/rules/integer-math.ts 37–58 OverflowError, DivisionByZeroError, UnderflowError typed errors Pattern to replicate for EpochCeilingError
src/domains/rules/integer-math.ts 65–71 BPS_DENOMINATOR, INT64_MAX, INT64_MIN internal constants Section header “Internal constants” — placement target for MAX_DECAY_EPOCHS
src/domains/rules/integer-math.ts 126–139 decay(value, rate_bps, epochs) Function body to harden. Current guard is epochs < 0n → UnderflowError only.
src/domains/rules/bps-constants.ts 31 import { OverflowError, UnderflowError } from './integer-math.js' Import pattern for EpochCeilingError re-export
src/domains/rules/bps-constants.ts 154–160 Re-export block safe_mul, safe_div, OverflowError, DivisionByZeroError, UnderflowError Placement target for EpochCeilingError re-export
src/domains/rules/determinism.ts (whole file) Determinism harness; readonly FORBIDDEN_PATTERNS. Not edited. Re-runs assertNoForbiddenOps(decay) after the change.

2.2 Tests

Path Coverage of decay Notes
src/__tests__/domains/rules/integer-math.test.ts Lines 154–194 — 6 cases: epochs=1, epochs=2, epochs=0, negative epochs (UnderflowError), 100% decay/100 epochs, monotonicity over 10 epochs Add: ceiling-pass at exactly MAX_DECAY_EPOCHS; ceiling-throw at MAX_DECAY_EPOCHS + 1n. Re-confirm UnderflowError.
src/__tests__/domains/rules/bps-constants.test.ts Group 11 (lines 341–371) checks reference equality of re-exported error classes Add: reference-equal assertion for EpochCeilingError if re-exported.
src/__tests__/domains/rules/determinism.test.ts Line 73 (assertDeterministic(decay, [1000n, 150n, 2n])); Line 105–107 (assertDeterministic(decay, [100n, 10n, -1n])); Line 343 (inspectFunctionForbidden(decay) returns []); Lines 833–889 corpus self-scan over src/domains/rules/*.ts. The new decay body adds a MAX_DECAY_EPOCHS const + an extra branch. inspectFunctionForbidden(decay) must remain [] (no Math.*, Date.*, RNG, async, float literals, fs imports). The corpus self-scan must also remain green (the new constant is 10_000n, integer literal, no decimal point).

2.3 Contract docs to inspect (acceptance criterion #6)

Path Section to consider Likely action
docs/contracts/r83-a-determinism-contract.md Mentions decay as one of the harness clients Light addendum: note the new ceiling for completeness; no contract-defining edits.
docs/contracts/r83-b-bps-constants-contract.md §2 modules table currently says integer-math.ts “must not change”. §4 enumerates re-exports. Addendum: if EpochCeilingError is re-exported, mention it in the re-export block; flag that R81.A is no longer “sealed” w.r.t. decay because of the present hardening pass.
docs/contracts/r81-a-p1-1-1-integer-math-contract.md Owns decay semantics. AC#5 (decay) and AC#7 (underflow) are the relevant clauses. Addendum: AC#5 grows a ceiling clause (epochs ≤ 10_000n); AC#7 covers negative epochs (already there).

3. Existing semantics (do not change)

  • decay(v, r, 0n)v (no-op for zero epochs). Retained by acceptance criterion #2c.
  • decay(v, r, k>0) compounds floor-decay k times. Loop body unchanged per task constraint.
  • decay(v, r, -1n) throws UnderflowError('decay: negative epochs'). Message will be enriched to include the offending value, per acceptance criterion #2a, but the class remains the same.
  • Integer-only, deterministic, no I/O. Retained.

4. New semantics (this change introduces)

  • Constant MAX_DECAY_EPOCHS: bigint = 10_000n exported from integer-math.ts.
  • New typed error EpochCeilingError extends RangeError exported from integer-math.ts. Pattern matches OverflowError/UnderflowError (named, message-passing constructor, prototype chain restored). Distinct from OverflowError because the bound is not int64 — it is a domain-level safety cap.
  • Guard in decay: epochs > MAX_DECAY_EPOCHS → throw EpochCeilingError(...). Fires before the loop (no work done if input rejected).
  • Re-export from bps-constants.ts of EpochCeilingError to keep the pattern: every typed error from integer-math.ts is re-exported alongside.

5. Determinism harness implications

The new decay function body still contains:

  • if (epochs < 0n) … (existing branch)
  • if (epochs > MAX_DECAY_EPOCHS) … (new branch)
  • let v = value; for (let i = 0n; i < epochs; i++) { v = apply_bps(v, rate_bps); } return v; (unchanged)

The function references only typed-error class names, integer literals, and apply_bps. The forbidden-op manifest in determinism.ts checks for Math.*, Date.*, new Date, setTimeout|setInterval|setImmediate, fetch|XMLHttpRequest, require fs, import fs, crypto.*, process.hrtime|nextTick, await, async function, float literals, and [native code]. None of these appear in the new body.

Conclusion: assertNoForbiddenOps(decay) will continue to return [], and the corpus self-scan over integer-math.ts will continue to be clean. The determinism contract is not violated.

6. Cap rationale (10_000n)

Following the task prompt:

  • κ uses 1–10 % per-epoch decay rates. After 1000 epochs at 1 %, the value is reduced by a factor of (1 − 0.01)^1000 ≈ 4.3 × 10⁻⁵ — already negligible.
  • 10 000 epochs is well past any meaningful semantic range and lets each epoch run within microseconds.
  • Abort time of a rejected call is O(1) (one comparison). Abort time of a worst-case accepted call (MAX_DECAY_EPOCHS = 10_000n, apply_bps per epoch ≈ 100 ns on bigint hardware) is well under 50 ms — meeting the task brief’s “< 50 ms for a single decay call” bar.
  • The bps-constants.ts decay-rate constants (DECAY_EXECUTION = 500n, DECAY_ARBITRATION = 1_000n, …) imply that any rule using these named rates is in the 1 %–10 % range; the 10 000 cap leaves four orders of magnitude of headroom.
  • No extraction or roadmap doc cites a need for >10 000 epochs in any planned Phase 1+ rule. Default cap stands.

7. Out of scope

  • safe_mul, safe_div, bps_mul, bps_div, apply_bps semantics — not touched.
  • Lexer, BPS constants, determinism harness — not touched beyond consuming the new error class transparently via re-export pattern.
  • Any non-decay change to integer-math.ts.
  • Any change to call-sites of decay outside the rules subtree (none currently exist; decay is library-only).
  • Update of the runtime-roadmap or task-breakdown.md — this is a hardening hygiene task, not a Phase-1 sub-task addition.

8. Risk register

Risk Mitigation
Determinism corpus self-scan flags the new constant 10_000n The pattern manifest does not match plain integer literals (the float-literal regex requires \d+\.\d+). Verified by inspection of FORBIDDEN_PATTERNS.
EpochCeilingError accidentally caught by instanceof OverflowError in callers EpochCeilingError extends RangeError (per task brief), not OverflowError. The only existing instanceof OverflowError site is the bps() validator; unaffected.
Test for decay(1000n, 100n, MAX_DECAY_EPOCHS) succeeds-but-takes-too-long At 100 ns/iter and 10 000 iters → ~1 ms; well under any reasonable Jest timeout. The acceptance criterion explicitly asks for this success case.
Re-export pattern divergence — re-exporting class but not constant MAX_DECAY_EPOCHS re-export is not required by the task brief (only the error class is). Keep the re-export surface minimal: error class only. The constant MAX_DECAY_EPOCHS is exported from integer-math.ts and will be importable directly by anyone who needs it. This matches how INT64_MAX is not re-exported through bps-constants.ts either (it is mirrored as MAX_INT64).

9. Acceptance trace

Criterion (from task prompt §Acceptance) Audit verdict
1. Add MAX_DECAY_EPOCHS: bigint = 10_000n to integer-math.ts near the range constants. Insertion target: after line 71 (INT64_MIN), in the “Internal constants” block, lifted to export const.
2a. epochs < 0nUnderflowError w/ message including offending value. Existing throw at line 132 does not include the value; message will be enriched (decay: negative epochs (got -1n) style).
2b. epochs > MAX_DECAY_EPOCHSEpochCeilingError(message including value AND ceiling). Class extends RangeError, exported. New class + new throw.
2c. epochs === 0n → returns value unchanged. Already true; preserved by the loop semantics.
3. Re-export pattern from bps-constants.ts. Add EpochCeilingError to the existing re-export block (line 154–160).
4. assertNoForbiddenOps(decay) returns []; corpus self-scan stays green. Verified analytically in §5; will be re-run in test suite.
5. New tests in integer-math.test.ts and bps-constants.test.ts. Spec’d in §2.2.
6. Review/update r83-a and r83-b contracts. Spec’d in §2.3 — light addenda, not rewrites.
7. npm run build && npm run lint && npm test all green. Final verification step.

Back to top

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

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