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
forloop iteratingepochstimes. An adversarial caller passingepochs = 1_000_000_000n(or worse,MAX_INT64) hangs the process. The application layer does not currently boundepochsbefore 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)throwsUnderflowError('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_000nexported frominteger-math.ts. - New typed error
EpochCeilingError extends RangeErrorexported frominteger-math.ts. Pattern matchesOverflowError/UnderflowError(named, message-passing constructor, prototype chain restored). Distinct fromOverflowErrorbecause the bound is not int64 — it is a domain-level safety cap. - Guard in
decay:epochs > MAX_DECAY_EPOCHS→ throwEpochCeilingError(...). Fires before the loop (no work done if input rejected). - Re-export from
bps-constants.tsofEpochCeilingErrorto keep the pattern: every typed error frominteger-math.tsis 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_bpsper 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.tsdecay-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_bpssemantics — not touched.- Lexer, BPS constants, determinism harness — not touched beyond consuming the new error class transparently via re-export pattern.
- Any non-
decaychange tointeger-math.ts. - Any change to call-sites of
decayoutside the rules subtree (none currently exist;decayis 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 < 0n → UnderflowError 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_EPOCHS → EpochCeilingError(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. |