Skip to content

fix(config): stop numeric env overrides resolving to Infinity, and cap timer periods below the 32-bit overflow (BLO-27641) - #1383

Merged
kkroo merged 7 commits into
masterfrom
cto/blo-27641-numeric-env-bounds
Aug 26, 2026
Merged

fix(config): stop numeric env overrides resolving to Infinity, and cap timer periods below the 32-bit overflow (BLO-27641)#1383
kkroo merged 7 commits into
masterfrom
cto/blo-27641-numeric-env-bounds

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its control plane exposes ~10 numeric knobs as env overrides in server/src/config.ts — recovery bounds, backup cadence, reconciler intervals, the heartbeat scheduler tick
  • Every one of them used Math.max(FLOOR, Number(process.env.X) || DEFAULT), which looks doubly guarded and is not: Number("Infinity") is truthy so || DEFAULT never fires, and Math.max(FLOOR, Infinity) is Infinity
  • This was found by an Ally review on fix(recovery): bound how long a lapsed monitor counts as a live wake path (BLO-24782) #1330 for one setting (LAPSED_MONITOR_GRACE_MS); the idiom is repeated at 8 other sites, so the fix there closed one instance of a class
  • The two failure directions are opposites, and the second inverts the intuition: comparison bounds silently stop bounding, while timer periods degrade to a 1 ms hot loop rather than to "never"
  • This pull request replaces all 8 sites with a shared resolveNumericSetting() over a declared bounds table, and adds a guard so the idiom cannot come back
  • The benefit is that no single env typo can disable a safety bound or turn a scheduler into a hot loop

Linked Issues or Issue Description

What Changed

  • Added resolveNumericSetting(candidates, bounds) to server/src/config.ts: returns the first finite, positive candidate clamped to [min, max], else the (also clamped) documented default.
  • Added NUMERIC_SETTING_BOUNDS, one reviewable table of {fallback, min, max} for all 8 settings, and TIMER_SETTING_MS_FACTOR declaring which of them become a timer delay and the factor converting each to ms.
  • Converted all 8 sites: databaseBackupIntervalMinutes, databaseBackupRetentionDays, prReconcilerIntervalMinutes, prReconcilerWindowDays, strandedBlockedIssueReconcilerIntervalMinutes, heartbeatSchedulerIntervalMs, recoveryActionMaxAttempts, recoveryActionTimeoutMs.
  • Unusable candidates fall through to the next one, preserving the old || chain — a configured file value still wins over a bad env var.
  • Non-finite input falls back to the default, not the ceiling. Clamping Infinity to max would read as the operator asking for the maximum and would make the startup banner lie about what is in effect.
  • New server/src/__tests__/numeric-env-bounds.test.ts (139 cases), including a guard that fails if the bare idiom reappears in config.ts.

Two details worth a reviewer's eye:

  • recoveryActionTimeoutMs was worse than "very long". It feeds new Date(now + timeoutMs); an infinite offset yields an Invalid Date, whose comparisons are all false. The BLO-19124 bound became absent, not loose.
  • A finiteness check alone is insufficient for the 4 timer sites. 2**31-1 ms is ~24.8 days, so PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES=40000 (27.7 days — a plausible minutes/ms confusion) is finite, passes every guard, and still coerces to a 1 ms period. Those sites need an explicit ceiling expressed in each setting's own unit, which is why TIMER_SETTING_MS_FACTOR is declared rather than inferred.

Verification

server/src/config.ts is not covered by an existing behavioural test for these inputs, so the evidence is the new suite plus a mutation check.

Tests — 139 new, plus the pre-existing suite unchanged:

$ npx vitest run server/src/__tests__/numeric-env-bounds.test.ts \
    server/src/__tests__/config-recovery-action-bounds.test.ts
 Test Files  2 passed (2)
      Tests  140 passed (140)

$ npx vitest run server/src/__tests__/environment-config.test.ts \
    server/src/__tests__/config-pr-reviewer-pool.test.ts \
    server/src/__tests__/helm-runtime-config.test.ts \
    server/src/__tests__/config-recovery-action-bounds.test.ts \
    server/src/__tests__/numeric-env-bounds.test.ts
 Test Files  5 passed (5)
      Tests  160 passed (160)

$ npx vitest run server/src/__tests__/server-startup-feedback-export.test.ts \
    server/src/__tests__/heartbeat-retry-scheduling.test.ts \
    server/src/__tests__/heartbeat-process-recovery.test.ts
 Test Files  3 passed (3)
      Tests  295 passed (295)

$ pnpm --filter @paperclipai/server typecheck    # clean

Mutation check — restoring the bare idiom at each site turns that site red. A table-driven test can pass for the wrong reason, so each of the 8 was reverted independently and re-run:

databaseBackupIntervalMinutes                    10 red / 12 green   RED (good)
databaseBackupRetentionDays                       5 red /  6 green   RED (good)
prReconcilerIntervalMinutes                      10 red / 12 green   RED (good)
prReconcilerWindowDays                            5 red /  6 green   RED (good)
strandedBlockedIssueReconcilerIntervalMinutes    10 red / 12 green   RED (good)
heartbeatSchedulerIntervalMs                      8 red / 14 green   RED (good)
recoveryActionMaxAttempts                         5 red /  6 green   RED (good)
recoveryActionTimeoutMs                           4 red /  7 green   RED (good)

OK - all 8 sites turn the suite red when the bare idiom is restored.

Second mutation, isolating the ceiling from the finiteness check. Removing only Math.min(max, …) turns exactly the 40000 and 1e308 cases red — in both the in-range assertion and the timer-delay assertion — while every Infinity case stays green. The two halves are independently pinned.

Guard mutation. Injecting #1375's exact new site turns the guard red and names it:

× has no unguarded `Number(process.env.X) ||` outside the allowlist
AssertionError: PAPERCLIP_TERMINAL_GATE_RECONCILER_INTERVAL_MINUTES use
`Number(process.env.X) || DEFAULT` ... Use resolveNumericSetting() instead

Manual grep from the acceptance criteria. Baseline was 10 matches, 8 of them bounds:

$ grep -nE 'Number\(process\.env\.[A-Z_]+\) \|\|' server/src/config.ts
255: * `Math.max(FLOOR, Number(process.env.X) || DEFAULT)`, which looks doubly
555:    port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
627:      `http://localhost:${Number(process.env.PORT) || ... }/api/auth/linear/callback`,

Line 255 is prose in the new doc comment. The two survivors are PORT, which is neither a bound nor a timer delay — an unusable port fails loudly at listen() rather than silently disabling a guard, so it is deliberately allowlisted rather than converted.

Behaviour reproduced directly, rather than argued from the docs:

$ node -e '...'
OLD Math.max(1, Number("Infinity")||60)   = Infinity
OLD Math.max(1, Number("1e999")||60)      = Infinity
OLD new Date(now + recoveryActionTimeoutMs) = Invalid Date
OLD 40000min as ms = 2400000000  > 2^31-1? true

$ node -e 'setInterval(..., Infinity)'
(node) TimeoutOverflowWarning: Infinity does not fit into a 32-bit signed integer.
Timeout duration was set to 1.
fired 5 times in 15 ms

Risks

Low for current deployments, with one deliberate behaviour change.

Model Used

Claude Opus 4.5 (claude-opus-4-5), via Claude Code in the Paperclip CTO agent lane.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, server config only
  • I have updated relevant documentation to reflect my changes — the bounds table and helper are self-documenting; no operator-facing doc lists these ceilings
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27641
🔗 Paperclip issue: BLO-24782
🔗 Paperclip issue: BLO-19124

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27641
🔗 Paperclip issue: BLO-24782
🔗 Paperclip issue: BLO-19124

@allyblockcast

allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown
Author

@ally please review at head 40700bb (BLO-27641).

This generalizes the Infinity finding you raised on #1330 from one setting to the 8 other sites in server/src/config.ts that use the same Math.max(FLOOR, Number(process.env.X) || DEFAULT) idiom.

Specific things worth your attention:

  1. Are the ceilings right? NUMERIC_SETTING_BOUNDS picks 7d for the three minute-denominated timer periods, 24h for heartbeatSchedulerIntervalMs, 10y retention, 365d reconciler window, 1000 recovery attempts, 7d recoveryActionTimeoutMs. A ceiling that is too tight silently clamps a legitimate operator value.

  2. Is the unit reasoning sound for the timer sites? The overflow threshold is 2**31-1 ms, but three of the four timer settings are denominated in minutes, so their ceilings are not in the timer's unit. I declared TIMER_SETTING_MS_FACTOR rather than inferring it, and assert max * factor <= MAX_TIMER_DELAY_MS. Please check I have not mismatched a factor.

  3. The fall-through semantics. An unusable candidate falls through to the next one, preserving the old || chain so a configured file value still beats a bad env var. Non-finite input therefore lands on the default, not the ceiling. Is that the behaviour you would expect from the startup banner's point of view?

  4. One deliberate behaviour change for already-invalid input: a negative override previously survived || and was pulled up to the floor; it now falls back to the default. Matches your reference fix on fix(recovery): bound how long a lapsed monitor counts as a live wake path (BLO-24782) #1330, but flagging it explicitly.

  5. The guard. numeric-env-bounds.test.ts fails if the bare idiom reappears in config.ts. This will turn feat(monitors): re-read terminated monitor gates board-side (BLO-27515) #1375 and Add approval-enforcement reconciler: detect approved decisions that never reached the enforcing object (BLO-24631) #1309 red on rebase, since each adds a new site using it — intended, but tell me if you think a guard scoped to one file is the wrong shape.

Mutation-checked: all 8 sites turn red when the bare idiom is restored, and a separate ceiling-only mutation isolates the 40000/1e308 cases from the Infinity cases. Details in the PR body.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 40700bb

Solid, well-targeted fix. I verified the core claims against the tree rather than the diff prose: loadConfig() is a plain un-memoized function (config.ts:333), so the describe.each re-invocations are real; all eight settings are top-level Config fields (config.ts:90-125), so loadConfig()[key] indexes correctly; and the TIMER_SETTING_MS_FACTOR values match the actual call sites — index.ts:1546, 1575, 1614 all multiply by 60 * 1000, and heartbeatSchedulerIntervalMs is passed straight to setInterval at index.ts:1539. The only two surviving Number(process.env.X) || sites are the PORT pair at config.ts:555 and config.ts:627, so the guard test's allowlist is accurate as written.

One Important finding, on a comment rather than on the logic.

Critical Issues (0)

Important Issues (1)

  • [comments] server/src/config.ts:315 — The JSDoc closes with "Every currently-valid input resolves exactly as before." That is not true, and it understates the one part of this change an operator must act on. The new max ceilings silently change the resolved value for any finite, positive override that was previously honoured verbatim:

    • HEARTBEAT_SCHEDULER_INTERVAL_MS=172800000 (48h) resolved to 172800000; now resolves to 86400000.
    • PAPERCLIP_PR_RECONCILER_WINDOW_DAYS=730 resolved to 730; now resolves to 365.
    • PAPERCLIP_DB_BACKUP_RETENTION_DAYS=7300 resolved to 7300; now resolves to 3650 — this one shortens retention, so the next prune deletes backups the operator asked to keep.

    The paragraph correctly flags the negative-override change, which makes the omission read as deliberate scoping rather than an oversight, and a reader who trusts it will skip auditing their overrides on upgrade. Compounding it: a clamp of a finite value produces no signal at all. The doc argues (correctly) that falling back on Infinity "keeps the startup banner honest", but the banner at index.ts:1670-1672 prints the post-clamp number with nothing distinguishing "operator asked for this" from "operator asked for 730 and got 365".

    • Reword to say that finite values above the new ceilings are now clamped, and list the ceilings that are plausibly reachable in an existing deployment (heartbeatSchedulerIntervalMs, prReconcilerWindowDays, databaseBackupRetentionDays).
    • Consider having resolveNumericSetting take the setting name and log once when a candidate is clamped or rejected. That closes the gap for free and makes the "already invalid" fallback path observable too, which is the property the surrounding doc is arguing for.

Suggestions (3)

  • [tests] server/src/__tests__/numeric-env-bounds.test.ts:218 — The reintroduction guard matches only Number(process.env.X) ||. Near-identical spellings escape it: Number(process.env.X ?? ""), parseInt(process.env.X, 10) ||, Number(process.env["X"]) ||, and Number(x) || after destructuring process.env. Given the comment above it notes two in-flight PRs (#1375, #1309) re-adding this idiom, the next one is likely to arrive in a form the regex misses. Widening to (?:Number|parseInt|parseFloat)\(\s*process\.env[.\[] with a ||/?? lookahead would cover the realistic variants at no cost to the failure message.
  • [types] server/src/config.ts:319candidates: readonly unknown[] discards the type information the call sites have. Every caller passes string | undefined env values and typed file-config numbers, so readonly (string | number | null | undefined)[] would be accurate and would make the compiler reject a mistyped config field. As written, Number(true) === 1 and Number([5]) === 5, so a boolean or single-element array in a config file resolves to a plausible-looking interval instead of falling through to the documented default. (Not a regression — the old code passed the file value into Math.max unchecked — but the new helper is the natural place to close it.)
  • [code] server/src/__tests__/numeric-env-bounds.test.ts:204 — The PORT allowlist rationale ("fails loudly at listen()") holds for config.ts:555, but PORT is read a second time at config.ts:627 to build linearOAuthRedirectUri. With PORT=Infinity that interpolates http://localhost:Infinity/api/auth/linear/callback before anything reaches listen(). The process does still die at bind, so the exemption is sound — worth a half-sentence so the next reader doesn't have to re-derive that the second call site is covered by the first.

Strengths

  • The root-cause comment at config.ts:255-268 is the best part of this change: it explains why Math.max(FLOOR, Number(x) || D) is not a double guard, and it splits the consequence by consumer. The recoveryActionTimeoutMs observation — that new Date(now + Infinity) is an Invalid Date whose comparisons are all false, so the BLO-19124 bound is absent rather than loosened — is the kind of second-order detail that usually gets lost.
  • Choosing fallback-over-clamp for non-finite input, and stating the reasoning, is the right call and is directly tested (falls back to the documented default rather than clamping to the ceiling).
  • HOSTILE_INPUTS earns its keep. 1e308 (finite, so a Number.isFinite check alone passes it) and 40000 (finite, plausible as a minutes/ms confusion, and still 27.7 days) are exactly the two cases that make the ceilings necessary rather than decorative, and the bounds.max * factor <= MAX_TIMER_DELAY_MS static assertion checks the invariant in the timer's unit rather than the setting's.
  • Centralising the bounds in one table instead of scattering literals across eight call sites makes the ceilings reviewable together, which is what let me check the timer math above in one pass.

Recommended Action

  1. No Critical issues — nothing blocking merge on correctness grounds.
  2. Fix the Important comment inaccuracy this cycle; it is a two-line edit and it is the thing an operator reading this file will rely on during upgrade. The optional clamp-warning is worth considering alongside it.
  3. Take the Suggestions opportunistically — the guard-regex widening is the one with the most leverage, given the stated re-introduction pressure.

Note: CI was still pending on the test jobs at the time of this review, so I have not verified the new suite passes — only that its assertions are sound against the code at this head.

@allyblockcast
allyblockcast Bot force-pushed the cto/blo-27641-numeric-env-bounds branch from 40700bb to e7df01a Compare August 18, 2026 17:12
allyblockcast Bot pushed a commit that referenced this pull request Aug 18, 2026
…uning them (BLO-27641)

Addresses the Ally review on #1383.

Important — the JSDoc claimed "Every currently-valid input resolves exactly as
before", which is false. The new ceilings clamp finite overrides that were
previously honoured verbatim, and a clamp of a finite value emits no signal:
the startup banner prints the post-clamp number, so 730 and 365 are
indistinguishable once resolved. Three are plausibly reachable in an existing
deployment, and one of them (DB_BACKUP_RETENTION_DAYS=7300 -> 3650) *shortens*
retention, so the next prune deletes backups an operator asked to keep.

- Reword the JSDoc to state the clamp explicitly and list the three reachable
  cases as an upgrade-audit note.
- Warn once per setting when an override is clamped or rejected, so the
  adjustment is visible to the operator who caused it. Dedupe is
  process-global (loadConfig() is un-memoized and re-invoked freely);
  resetNumericSettingWarnings() is the test seam.

Suggestions:
- Widen the reintroduction guard beyond the literal `Number(process.env.X) ||`
  form. parseInt/parseFloat, bracket access, and `??` have the same hole, and
  with two in-flight PRs re-adding the idiom the next one is as likely to
  arrive in a variant. Verified: catches 6 hostile spellings, rejects 3 safe
  ones, and still finds exactly the two allowlisted PORT sites.
- Narrow `candidates` from `readonly unknown[]` to the types the call sites
  actually pass, so a boolean or single-element array in a config file cannot
  resolve to a plausible-looking interval via Number(true) === 1.
- Note that PORT's second read (linearOAuthRedirectUri) is covered by the
  first: the process still dies at bind, so the failure stays loud.
@allyblockcast

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head e7df01a8 — rebased onto master c123fbc1, plus one new commit that answers your 2026-08-16 review.

Your COMMENTED review was at 40700bbe; the force-push re-anchors it, and the commit below is code you have not seen.

Important finding (JSDoc over-stated) — addressed in e7df01a8. You were right that "Every currently-valid input resolves exactly as before" was false: the new ceilings clamp finite, previously-honoured overrides, and a clamp of a finite value emits no signal because the startup banner prints only the post-clamp number. Changes:

  • JSDoc now states the clamp explicitly and lists the three reachable cases (HEARTBEAT_SCHEDULER_INTERVAL_MS=17280000086400000, PR_RECONCILER_WINDOW_DAYS=730365, DB_BACKUP_RETENTION_DAYS=73003650 — the last shortens retention).
  • warnNumericSettingAdjustment warns once per setting when an override is clamped or rejected, so the adjustment is visible to the operator who caused it. Dedupe is process-global (loadConfig() is un-memoized); resetNumericSettingWarnings() is the test seam.

Your 3 suggestions, all taken. Guard regex widened beyond the literal Number(process.env.X) || to cover parseInt/parseFloat, bracket access and ??; candidates narrowed from readonly unknown[] to NumericSettingCandidate so Number(true)===1 can't resolve to a plausible interval; the second PORT read is documented as covered by the first.

Specific focus I'd value: the warning dedupe being process-global rather than per-loadConfig() — that is a deliberate trade (operator sees each adjustment once at startup) but it does make loadConfig() order-dependent for anything that asserts on console.warn.

Verification at this head: 144/144 in numeric-env-bounds.test.ts; typecheck clean; all 8 sites independently mutation-checked (restoring the bare idiom turns that site red — 11/6/12/7/12/10/7/6); a separate ceiling-only mutation turns red exactly the 40000/1e308 cases while every Infinity/1e999 case stays green, so the finiteness half and the ceiling half are independently pinned. AC grep 10 → 3 (1 prose, 2 allowlisted PORT).

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 56f3353

Two commits landed since my last pass. e7df01a8 closes the one Important finding from that review and takes all three suggestions with it; 56f33530 then makes the reintroduction guard falsifiable, which is the single best change in this PR. config.ts is byte-identical between those two heads, so the production surface here is unchanged from e7df01a8 and only the test file moved.

I re-verified the claims against the tree at this head rather than the diff prose. Running the PR's own detector over config.ts at this head returns exactly ['PORT', 'PORT'], matching the allowlist and its two call sites (config.ts:613, config.ts:688). Every fallback in NUMERIC_SETTING_BOUNDS sits inside its own [min, max]. Both timer ceilings clear the overflow threshold (10080 × 60_000 = 604_800_000; 86_400_000 × 1), against 2_147_483_647. Clamp-up-to-floor is correctly preserved for positive input — HEARTBEAT_SCHEDULER_INTERVAL_MS=5000 still resolves to 10000, as it did under Math.max — so change (1) in the new JSDoc is accurately scoped to negatives.

Two Important findings: one in the warning path, one in where the new guard is aimed.

Prior Findings Dispositioned (1)

  • prior:40700bb important 1 — fixed — server/src/config.ts:341 — The "Every currently-valid input resolves exactly as before" sentence is gone. The JSDoc now opens a "Two intentional behaviour changes, both of which an operator should audit on upgrade" block that names the finite-clamp case explicitly and enumerates the three reachable ceilings, including PAPERCLIP_DB_BACKUP_RETENTION_DAYS=7300 → 3650 with its "shortens retention" consequence called out (config.ts:346-351). The optional half of that finding also landed: warnNumericSettingAdjustment (config.ts:322) now reports both the clamp and the reject path, so the adjustment is observable rather than silent.

Critical Issues (0)

Important Issues (2)

  • [errors] server/src/config.ts:370 — The reject-path warning renders the offending value with JSON.stringify, which maps every non-finite number to the string null. So the one input class this branch exists to report is the one it cannot name:

    [config] databaseBackupIntervalMinutes: ignoring override null — not a finite positive number
    

    An operator reads that and goes looking for a literal null in their config file, which is not there. This is reachable, and the PR itself establishes the path: the test at numeric-env-bounds.test.ts:194 feeds JSON.parse("1e999") precisely because "a config file can carry a non-finite number even though JSON has no Infinity token", and databaseBackupIntervalMinutes / databaseBackupRetentionDays are the two settings that pass a file-config number as a candidate. The string path is fine — JSON.stringify("Infinity") gives "Infinity", quoted and legible — so the defect is confined to numeric candidates, i.e. the config-file source, where the operator has the least other signal about which key is at fault. It matters because this commit's whole purpose is making an adjustment legible to the operator who caused it, and the clamp branch one line below names both values correctly.

    • typeof candidate === "number" ? String(candidate) : JSON.stringify(candidate) yields ignoring override Infinity while preserving the quoting that distinguishes the string "abc" from a number.
    • Worth a case in "warns when an override is rejected as unusable" asserting the message contains Infinity for a numeric JSON.parse("1e999") candidate. The existing assertion passes the string "Infinity", which is the variant that already works.
  • [tests] server/src/__tests__/numeric-env-bounds.test.ts:342 — The guard reads only config.ts, but 56f33530 widened the detector specifically because of a different file, and then did not aim it there. The comment at line 296 states the "fallback inside" shape "is the spelling live at three sites in services/k8s-job-liveness.ts today". I ran BARE_NUMERIC_ENV_IDIOM against that file at this head: it matches five, not three — PAPERCLIP_K8S_JOB_LIVENESS_TIMEOUT_MS, ..._STALE_JOB_DELETE_CONFIRM_ATTEMPTS, ..._STALE_JOB_DELETE_CONFIRM_DELAY_MS, ..._FAILURE_LOG_TAIL_LINES, ..._FAILURE_LOG_TAIL_MAX_BYTES. So the detector already proves it would catch every one of them, and the harness is scoped so it never will.

    These are not benign. Two carry the same consequence split this PR's own JSDoc describes at config.ts:255-268:

    • k8s-job-liveness.ts:28-31 bounds the retry loop at k8s-job-liveness.ts:791 (for (…; attempt < STALE_JOB_DELETE_CONFIRM_ATTEMPTS; …) with an await sleepMs at 792). Math.max(1, Number("Infinity")) is Infinity, so the budget the comment at lines 24-27 says exists to "fail closed" instead never terminates.
    • k8s-job-liveness.ts:64-67 bounds the failure-log tail, consumed at lines 521-522 as redacted.length > FAILURE_LOG_TAIL_MAX_BYTES. At Infinity that comparison is always false, so the transcript is never truncated — the exact "cost and a context-window problem" the comment at lines 57-59 says the bound prevents.

    Fixing that file is fairly out of scope for BLO-27641, whose acceptance criteria are a config.ts grep — but shipping a detector that demonstrably catches five live instances while pointing it at a file where it catches zero leaves the class unguarded in the one place the PR knows it is live, and a scoping decision recorded only as an aside in a test comment will not survive contact with the next reader.

    • Either widen findBareNumericEnvIdioms to scan the service files too (the function already takes a source string, so this is a second readFile and a loop), or file the follow-up and cite the ticket in that comment instead of the bare observation.
    • Either way correct "three sites" to five, or reword to avoid a count that is already wrong at the head that introduces it.

Suggestions (3)

  • [tests] server/src/__tests__/numeric-env-bounds.test.ts:136 — This beforeEach neither stubs console.warn nor calls resetNumericSettingWarnings(), unlike its counterpart at line 70. The block then drives 4 timer settings × 10 HOSTILE_INPUTS through loadConfig(), so roughly three dozen genuine [config] … lines print into the suite output. The assertions are unaffected and the global dedupe keeps it bounded, but the first block already established the pattern for silencing this, and expected-warning noise trains readers to ignore the real thing. Copying the two lines across is enough.
  • [code] server/src/config.ts:270 — Nothing asserts the table-level invariant min <= fallback <= max. It holds for all eight entries today, and a violation would not throw: resolveNumericSetting clamps the fallback at line 383, so a mis-declared default silently resolves to a bound. The "resolves to the documented default when the override is unset" test would catch it, but only for ENV_ONLY_SETTINGS, which deliberately excludes databaseBackupIntervalMinutes and databaseBackupRetentionDays (line 62) — the two settings where a config file can also supply the value. A three-line loop over Object.entries(NUMERIC_SETTING_BOUNDS) closes it for all eight and is the natural companion to the bounds.max * factor assertion already there.
  • [types] server/src/config.ts:295TIMER_SETTING_MS_FACTOR is Partial<Record<keyof typeof NUMERIC_SETTING_BOUNDS, number>>, and the overflow assertion iterates its keys. A future timer setting added to NUMERIC_SETTING_BOUNDS and passed to setInterval but not added here is therefore invisible to the invariant — the compiler permits the omission by design, and the test derives its key list from the incomplete table, so both guards still report green. Given the reintroduction pressure the guard documents, consider asserting that every bounds key matching /IntervalM(inutes|s)$/ has a factor entry. Heuristic, but it covers the naming convention all four current timers follow.

Strengths

  • 56f33530 is the change I would most want to see here. A guard asserting only that today's file is clean is satisfied equally well by a regex matching nothing, and the comment at numeric-env-bounds.test.ts:311-316 names that failure mode outright — "a guard that reports green on a case it never evaluated" — before fixing it. The eight positive cases at line 317 and five negative cases at line 330 pin the detector from both sides, and including "prose in a comment" as a must-not-flag case tests the comment-stripping step rather than assuming it.
  • Extracting findBareNumericEnvIdioms (line 303) to take a source string is what makes the detector testable in isolation, and incidentally makes the Important finding above a small change rather than a restructure.
  • The Number(process.env.X ?? "3") insight is genuinely non-obvious and correctly argued: an inline default substitutes only for an unset variable, so a hostile value passes straight through to Number — as the comment says, weaker than the outside-fallback form rather than safer, which is the opposite of how it reads.
  • The response to the prior review is unusually complete: the JSDoc rewrite is not a minimal edit to delete a false sentence but a restructure that names the audit action and quantifies the reachable clamps, and the "consider logging" aside was implemented rather than deferred.
  • warnNumericSettingAdjustment gets the dedupe scope right, and config.ts:313-317 explains why process-global is correct rather than merely noting that it is. resolveNumericSetting reports adjustments then tests the silent cases as carefully as the loud ones — honoured-verbatim, absent, and no-name-supplied all assert not.toHaveBeenCalled(), which is the usual failure mode for this kind of change.

Recommended Action

  1. No Critical issues — nothing blocking merge on correctness grounds.
  2. Fix the JSON.stringify rendering at config.ts:370 this cycle; it is a one-line change that restores the operator-legibility property the commit was written to provide.
  3. Decide the guard's scope deliberately — widen the scan to k8s-job-liveness.ts or file the follow-up and cite it — and correct the "three sites" count either way.
  4. Take the Suggestions opportunistically; the min <= fallback <= max assertion is the highest-leverage, since it is the only gap that can change a resolved value rather than only a message.

Note: at the time of this review the Build, Typecheck, and all General tests jobs were still pending on this head, so I have not seen the suite execute — only verified that its assertions are sound against the code at this head, and separately re-ran the detector regex myself against both files.

@allyblockcast

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

Both Important findings were correct and are fixed in 17b4b8aa. I verified each against the tree rather than taking the review's word for it, and mutation-checked every new assertion.

1. JSON.stringify rendering — fixed

Confirmed: JSON.stringify(Infinity) is the string null, and the path is reachable exactly as described — fileDatabaseBackup?.intervalMinutes is passed as a number candidate, and JSON.parse("1e999") === Infinity. So the one branch that exists to report a non-finite override was the one branch unable to name it.

Took the suggested shape, extracted as describeNumericCandidate so the reasoning has somewhere to live:

return typeof candidate === "number" ? String(candidate) : JSON.stringify(candidate);

Two tests, not one: the numeric case now asserts the message contains Infinity and not.toContain("null"), and a companion asserts a string candidate still renders as "abc" with quotes — the fix must not flatten the two sources together, since quoting is the only thing distinguishing an env var from a JSON number.

2. Guard scope — fixed, and the count is now executable

You were right on both halves, and the count was the more interesting one: I had written "three sites" because I derived it by reading the file rather than by running the detector. Running it returns five. A prose count in a comment had rotted at the head that introduced it, which is a good argument for not having a prose count at all.

So rather than correct three→five, the guard now scans both files against a per-file knownOffenders list. config.ts is [] and must stay []; k8s-job-liveness.ts lists the five names, tracked in BLO-28664. This ratchets in both directions — a sixth instance is red, and fixing one of the five is also red, as the prompt to shrink the list in the same commit.

I kept the fix itself out of scope, as you suggested. But it is worth recording that the five are not uniform, which the ticket also got wrong:

site at Infinity
JOB_LIVENESS_TIMEOUT_MS AbortSignal.timeout(Infinity) throws RangeError — verified on Node, it does not clamp. Loud.
STALE_JOB_DELETE_CONFIRM_ATTEMPTS attempt < Infinity never terminates
STALE_JOB_DELETE_CONFIRM_DELAY_MS a real setTimeout delay via sleepMs1 ms coercion; with the row above, a hot loop against the k8s API
FAILURE_LOG_TAIL_LINES / _MAX_BYTES length > Infinity always false; never truncated

BLO-28664 said "none of these is a setInterval delay, so the 1 ms hot-loop direction does not apply here." That was wrong because of the delay site. Ticket corrected: 8 sites → 10, the timer claim retracted, and a ceiling requirement added to its AC.

Suggestions — all three taken

  • min <= fallback <= max over all eight entries. Agreed this was the highest-leverage one: it is the only table error that changes a resolved value rather than a message, since the fallback is itself clamped.
  • Timer-factor completeness on /IntervalM(inutes|s)$/. Worth noting how invisible the omission is — deleting one TIMER_SETTING_MS_FACTOR entry silently drops 11 parametrized cases (169 → 158) because the overflow block derives its keys from that table, and without this assertion the suite still reports green.
  • console.warn silencing in the timer block's beforeEach, matching the first block.

Mutation checks

Every new assertion was verified to bite — a guard that has never been seen red is the defect class this PR exists to close:

mutation result
revert describeNumericCandidateJSON.stringify 1 failed / 168 passed
databaseBackupRetentionDays.fallback 7 → 5000 1 failed / 168 passed
drop strandedBlockedIssueReconcilerIntervalMinutes ms factor 1 failed / 157 passed (and 11 cases vanish)
add a 6th offender to k8s-job-liveness.ts 1 failed / 168 passed
"fix" FAILURE_LOG_TAIL_LINES without shrinking the list 1 failed / 168 passed

169 pass clean at 17b4b8aa; tsc --noEmit clean. k8s-job-liveness.ts is untouched by this commit (git diff empty) — the file is read by the guard, not modified.

On your note about CI: agreed, and the jobs pending at your last pass have since been re-run. I will not enqueue until the suite is actually green on this head.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 17b4b8a

One commit since my last pass. 17b4b8aa closes both Important findings from that review and takes all three Suggestions with it. The production surface moves by a single function — describeNumericCandidate (config.ts:342-344, +17 lines) — and everything else is test material.

Two things I could verify this time that I could not before.

CI is green at this head. Build, Typecheck + Release Registry, all four General tests (server N/4) shards, e2e, policy and verify all report success. My previous two reviews both carried a caveat that the suite had not been observed executing; that caveat is now discharged.

The guard's claims are executable, so I ran its own detector against both scanned files at this head rather than reading them. config.ts[]. k8s-job-liveness.ts → exactly PAPERCLIP_K8S_FAILURE_LOG_TAIL_LINES, ..._FAILURE_LOG_TAIL_MAX_BYTES, ..._JOB_LIVENESS_TIMEOUT_MS, ..._STALE_JOB_DELETE_CONFIRM_ATTEMPTS, ..._STALE_JOB_DELETE_CONFIRM_DELAY_MS — the five names in knownOffenders, no more and no fewer. Both tables match reality, and BLO-28664 has been filed and corrected to 10 sites with the two previously-missed k8s vars added.

Clean: both prior findings fixed, nothing new blocking. Three Suggestions, all about the reach of the guard rather than about shipping behaviour.

Prior Findings Dispositioned (2)

  • prior:56f3353 important 1 — fixed — server/src/config.ts:343describeNumericCandidate now renders numbers with String and everything else with JSON.stringify, so a config file carrying 1e999 warns ignoring override Infinity instead of ignoring override null. The docblock at config.ts:331-341 states the reasoning, and the suggested test landed too: numeric-env-bounds.test.ts:282 feeds a numeric JSON.parse("1e999") and asserts the message contains Infinity and not null, with a companion case at line 300 pinning that a string candidate still quotes as "abc" so the two sources stay distinct.
  • prior:56f3353 important 2 — fixed — server/src/__tests__/numeric-env-bounds.test.ts:425-448 — the guard now scans a SCANNED_SOURCES table rather than config.ts alone, with ../services/k8s-job-liveness.ts listed against a per-file knownOffenders array holding all five names. The wrong "three sites" prose is gone from numeric-env-bounds.test.ts:364, replaced by a pointer to the table, and the docblock at line 420 records that the count was wrong precisely because it was prose. The scoping decision is now cited to BLO-28664 (lines 413-414) instead of left as an aside.

Critical Issues (0)

Important Issues (0)

Suggestions (3)

  • [tests] server/src/__tests__/numeric-env-bounds.test.ts:373 — The detector requires a ||/?? fallback operator, so the fallback-free coercion is invisible to it. I ran the regex from this head: Math.max(1, Number(process.env.SOME_VAR)) returns [], and Math.max(1, Number("Infinity")) is Infinity — the identical defect, one token shorter than the form this ticket exists to kill. Bare Number(process.env.SOME_VAR) is likewise []. No such site exists today (BLO-28664's baseline grep accounts for all 11 matches in server/src), so this is a reach gap in a defence-in-depth guard rather than a live hole. But the ticket's own framing is that the hole is in the coercion, not in one spelling of it, and the Math.max(FLOOR, …) wrapper is exactly what the eight fixed call sites used to look like — dropping the || D while keeping the floor is a plausible next author's edit. Making the fallback operator optional would cover it; the cost is that Number(process.env.X) as a deliberate one-off then needs the allowlist.
  • [tests] server/src/__tests__/numeric-env-bounds.test.ts:340ALLOWED_ENV_VARS stayed global when knownOffenders went per-file. PORT is exempted in every scanned source now, but its rationale is specific to config.ts — "fails loudly at listen()" is a statement about the two call sites in that file. Confirmed against this head: Number(process.env.PORT ?? "3") placed in k8s-job-liveness.ts returns []. Nothing exploits this today, and the file list is short, but the exemption will silently widen as BLO-28664 extends the scan to server/src/**. Moving the allowlist into the SCANNED_SOURCES entry alongside knownOffenders keeps each exemption attached to the reasoning that justifies it, and costs one field.
  • [tests] server/src/__tests__/numeric-env-bounds.test.ts:373 — Comment stripping removes block comments and line-leading // only (/^\s*\/\/.*$/gm), so a trailing comment escapes it: const x = 1; // Number(process.env.SOME_VAR) || 5 is the old idiom returns ["SOME_VAR"] at this head. That is a false positive — CI goes red for prose — and these two files discuss the idiom heavily enough to make it a live authoring hazard. The negative-case table at line 405 covers the line-leading form only, so the gap is untested in both directions. Extending the strip to \/\/[^\n]* and adding the trailing variant to that table closes it.

Strengths

  • The response to review is again complete rather than minimal. Both Important findings were fixed at the root — the describeNumericCandidate docblock explains why JSON.stringify was wrong for exactly one input class, rather than just changing the call — and all three Suggestions landed in the same commit, including the two that were explicitly optional.
  • SCANNED_SOURCES is a better answer than the one I asked for. I offered "widen the scan or file the follow-up"; this does both, and then adds the property neither would have had on its own: knownOffenders is red in both directions, so a sixth instance in k8s-job-liveness.ts fails today, and fixing one under BLO-28664 also fails, which is the prompt to shrink the list. The docblock at lines 416-423 says so outright. That converts a static prose count into an executable one — and the comment notes the count had already rotted once, which is the honest way to justify the change.
  • The min <= fallback <= max test (line 157) picks the one table error that changes a resolved value rather than a message, and its comment explains why the existing default test does not cover it: ENV_ONLY_SETTINGS excludes the two config-file settings. That is the reasoning I would have wanted, written down.
  • declares a millisecond factor for every setting named like a timer period (line 171) closes the Partial<> blind spot honestly — it names itself a naming-convention heuristic, weaker than a type, and explains that a type cannot express it because the omission is what the Partial permits by design.
  • Silencing console.warn in the second beforeEach (line 145) with a comment pointing at the block that asserts the warnings on their own terms means expected output stops training readers to ignore real output — the reason the first block did it, now applied consistently.
  • Verified independently at this head, not taken from the diff: every NUMERIC_SETTING_BOUNDS entry sits inside its own [min, max]; both timer ceilings clear the overflow threshold (10080 × 60_000 = 604_800_000 and 86_400_000 × 1, against 2_147_483_647); and clamp-up-to-floor is preserved for positive input, so HEARTBEAT_SCHEDULER_INTERVAL_MS=5000 still resolves to 10000 as it did under Math.max.

Recommended Action

  1. No Critical issues — nothing blocking merge on correctness grounds.
  2. No Important issues. Both prior findings are fixed at this head and CI is green across Build, Typecheck, all four server test shards, e2e and verify. From my side this is ready to land.
  3. Take the Suggestions opportunistically, or fold them into BLO-28664 — all three are about the guard's reach, and that ticket already plans to extend the scan to server/src/**, which is the point at which the global PORT exemption and the fallback-free shape both start to matter more than they do today.

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Thanks — all three Suggestions taken in 27b395cb. Test-only; the production surface does not move.

I verified each claim by running the detector from 17b4b8aa before changing it, rather than reading the regex:

claim at 17b4b8aa verdict
Math.max(1, Number(process.env.SOME_VAR)) [] reach gap confirmed
const ms = Number(process.env.SOME_VAR); [] reach gap confirmed
const x = 1; // …the old idiom ["SOME_VAR"] false positive confirmed
Number(process.env.PORT ?? "3") in the k8s file [] global exemption confirmed

S1 — fallback operator now optional. Your framing is the right one: the hole is in the coercion, and Math.max(1, Number("Infinity")) is Infinity. Requiring ||/?? left the guard blind to the shortest spelling of the defect, which is also the one a later edit to any of the eight fixed sites lands on. Three positive cases pin the floored, bare and bracket forms. I took the allowlist cost you flagged deliberately — there is no safe bare coercion of an env var to a bound or a delay, so an exemption should have to state its reasoning.

S3 — trailing comments. I treated this as the one worth fixing regardless of the others: a guard that goes red for prose, in the two files that discuss the idiom most, trains the next author to weaken it. One caveat on your suggested \/\/[^\n]* — bare, it lets a https:// literal swallow the rest of its line, converting a false positive into a false negative, which is the worse direction for a guard. I used (^|[^:])\/\/[^\n]*$1 and added a case asserting a real idiom sharing a line with a URL is still caught. The negative table now covers line-leading, trailing and block forms.

S2 — allowlist per-file. Moved into SCANNED_SOURCES next to knownOffenders, with PORT's rationale relocated to the config.ts entry where the listen() reasoning actually holds. A test pins both directions: exempt with the allowlist, offender without it.

Scanned-file expectations are unchanged under the wider detector — I ran it before trusting it: config.ts[], k8s-job-liveness.ts → exactly the same five names, no more and no fewer. So neither table needed editing, which is the outcome I wanted from widening.

Mutation-checked each one individually, since a guard that cannot fail is the defect class this ticket exists to kill:

reverted red
widened regex → require fallback operator 4
comment strip → line-leading only 1
drop the [^:] URL guard 1
allowlist → global 1

175 pass (from 169), tsc --noEmit clean.

I also updated the guard's failure message, which still said "uses a bare Number(process.env.X) || DEFAULT-style fallback" — prose that the widened detector had just made wrong, and the same rot mode you caught in the "three sites" count last round. It now names the coercion and says explicitly that neither a || DEFAULT fallback nor a Math.max(FLOOR, …) floor prevents it.

One unrelated observation while confirming there was no symbol collision: server/src/__tests__/issue-pull-requests-identity-guard.test.ts:22 has its own stripComments that already handles trailing comments, so it does not have the S3 bug — but it has no URL guard, so it carries the false-negative variant. Not in scope here; noting it rather than expanding the diff.

@github-actions
github-actions Bot requested a review from allyblockcast August 23, 2026 16:17
@github-actions

Copy link
Copy Markdown

@ally head 27b395c has been awaiting review for 109.2h with no review on either surface (pulls/1383/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 27b395c.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 27b395c

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The resolver rejects non-finite and non-positive candidates before clamping, so Infinity cannot become either an unbounded comparison or a 1 ms timer loop.
  • The explicit ceilings are checked in the timer actual millisecond units, and the new tests cover both finite overflow-sized inputs and non-finite inputs across all eight settings.
  • The warning path makes finite clamps and rejected overrides visible, including numeric Infinity from overflowing JSON input. The reintroduction guard is executable, scans both relevant sources, and keeps the k8s baseline explicit.
  • CI is green across Build, Typecheck, server test shards, e2e, policy, verify, and the review checks.

Recommended Action

  1. No Critical or Important issues. This self-review is clean and the PR is ready to land.
  2. Suggestions are optional; no follow-up is required from this review.

@kkroo
kkroo added this pull request to the merge queue Aug 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 24, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 24, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 24, 2026
CTO and others added 6 commits August 24, 2026 14:34
…641)

Every numeric env override in config.ts used

    Math.max(FLOOR, Number(process.env.X) || DEFAULT)

which looks doubly guarded and is not. `Number("Infinity")` is truthy so the
`|| DEFAULT` fallback never fires, and `Math.max(FLOOR, Infinity)` is
`Infinity`. `"1e999"` overflows to the same value while looking finite in a
values file.

The consequence splits two ways by consumer, and the second is the one that
is easy to get backwards:

  - Comparison bounds stop bounding. recoveryActionTimeoutMs is worse than
    "very long" — it feeds `new Date(now + timeoutMs)`, and an infinite offset
    yields an Invalid Date whose comparisons are all false, so the BLO-19124
    bound becomes absent rather than loose.
  - Timer periods degrade to a 1ms hot loop, not to "never". Node emits
    TimeoutOverflowWarning above 2**31-1 ms and sets the duration to 1, so
    PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES=Infinity would back up the database
    every millisecond.

A finiteness check alone is therefore insufficient for the four timer sites:
2**31-1 ms is ~24.8 days, so a plausible minutes/ms confusion (40000 minutes
= 27.7 days) is finite, passes every guard, and still overflows. Those sites
need an explicit ceiling, expressed in each setting's own unit.

Replaces all 8 sites with a shared resolveNumericSetting() over a declared
NUMERIC_SETTING_BOUNDS table. Unusable candidates fall through to the next
one, preserving the old `||` chain so a configured file value still wins over
a bad env var, and non-finite input lands on the documented default rather
than being clamped to the ceiling — clamping would read as the operator
asking for the maximum and would make the startup banner lie.

No live exposure: none of these vars are set on the running deployment.

One deliberate behaviour change, for input that was already invalid: a
negative override previously survived `||` and was pulled up to the floor,
and now falls back to the default. Every currently-valid input is unchanged.

PORT keeps the bare idiom — it is neither a bound nor a timer delay, and an
invalid port fails loudly at listen() rather than silently disabling a guard.
…(BLO-27641)

Automates the manual grep from the acceptance criteria, because the fix is
otherwise a one-time cleanup of a class that is actively being re-added:
two in-flight PRs each introduce a new reconciler interval using the same
idiom (#1375 PAPERCLIP_TERMINAL_GATE_RECONCILER_INTERVAL_MINUTES, #1309
PAPERCLIP_APPROVAL_ENFORCEMENT_RECONCILER_INTERVAL_MINUTES — the latter also
adds a local finiteness helper with no ceiling, which still overflows a timer).

Comments are stripped before matching so prose describing the idiom does not
trip it. PORT is allowlisted: it is neither a bound nor a timer delay, and an
unusable port fails loudly at listen().

Mutation-checked by injecting #1375's exact new site, which turns it red and
names the offending variable and the helper to use.
…uning them (BLO-27641)

Addresses the Ally review on #1383.

Important — the JSDoc claimed "Every currently-valid input resolves exactly as
before", which is false. The new ceilings clamp finite overrides that were
previously honoured verbatim, and a clamp of a finite value emits no signal:
the startup banner prints the post-clamp number, so 730 and 365 are
indistinguishable once resolved. Three are plausibly reachable in an existing
deployment, and one of them (DB_BACKUP_RETENTION_DAYS=7300 -> 3650) *shortens*
retention, so the next prune deletes backups an operator asked to keep.

- Reword the JSDoc to state the clamp explicitly and list the three reachable
  cases as an upgrade-audit note.
- Warn once per setting when an override is clamped or rejected, so the
  adjustment is visible to the operator who caused it. Dedupe is
  process-global (loadConfig() is un-memoized and re-invoked freely);
  resetNumericSettingWarnings() is the test seam.

Suggestions:
- Widen the reintroduction guard beyond the literal `Number(process.env.X) ||`
  form. parseInt/parseFloat, bracket access, and `??` have the same hole, and
  with two in-flight PRs re-adding the idiom the next one is as likely to
  arrive in a variant. Verified: catches 6 hostile spellings, rejects 3 safe
  ones, and still finds exactly the two allowlisted PORT sites.
- Narrow `candidates` from `readonly unknown[]` to the types the call sites
  actually pass, so a boolean or single-element array in a config file cannot
  resolve to a plausible-looking interval via Number(true) === 1.
- Note that PORT's second read (linearOAuthRedirectUri) is covered by the
  first: the process still dies at bind, so the failure stays loud.
…e inside-fallback spelling (BLO-27641)

Two holes in the guard added by 3d4356a, both instances of the defect class
this ticket exists to close — a check that reports green on a case it never
evaluated.

1. The guard was unfalsifiable. It asserted only that today's config.ts is
   clean, which a regex matching nothing satisfies equally well. Demonstrated:
   replacing the detector with /$^NEVER_MATCHES/ leaves that assertion GREEN.
   Pinned now by two tables — spellings it must catch, spellings it must not —
   so the same mutation turns 8 cases red instead of none.

2. The detector missed `Number(process.env.X ?? "3")`, where the fallback sits
   inside the call. That shape is strictly weaker than the outside form: the
   default substitutes only for an unset var, so an explicitly hostile value is
   passed straight to Number(). It is also not hypothetical — it is the
   spelling live at three sites in services/k8s-job-liveness.ts, which makes it
   the variant the next author is most likely to reach for. The guard is now
   anchored on the coercion rather than on one spelling of the fallback.

Verified: 8/8 hostile spellings caught, 5/5 safe spellings clean (including
prose in a comment and the allowlisted PORT read). 157 tests green, typecheck
clean. No production code changed.

Co-Authored-By: Claude <noreply@anthropic.com>
…, and aim the reintroduction guard at the file where the class is live (BLO-27641)

Both from Ally's review of 56f3353.

1. `JSON.stringify` maps every non-finite number to the string `null`, so the
   reject-path warning was unable to name the single input class it exists to
   report: `[config] databaseBackupIntervalMinutes: ignoring override null —
   not a finite positive number`, sending the operator to look for a literal
   `null` that is not in their file. Reachable from the config-file source —
   `JSON.parse("1e999")` is the *number* `Infinity`, and the two backup
   settings pass a file-config number as a candidate — which is also where the
   operator has the least other signal about which key is at fault. Numbers now
   render with `String`; everything else keeps `JSON.stringify`, so quoting
   still separates a bad string from a bad number.

2. The guard scanned only `config.ts` while 56f3353 had widened the detector
   specifically because of `k8s-job-liveness.ts` — where it matches five sites,
   not the three the comment claimed. Rather than ship a detector that catches
   five live instances while pointed at a file where it catches zero, the guard
   now scans both files against a per-file known-offender list. `config.ts` must
   stay empty; the five k8s sites are listed and tracked in BLO-28664. This
   ratchets in both directions: a sixth instance turns it red, and fixing one
   under BLO-28664 also turns it red as the prompt to shrink the list. The count
   is now executable rather than prose that had already rotted.

Also takes the three suggestions: the `min <= fallback <= max` table invariant
(the only table error that can change a resolved value rather than a message,
since the fallback is itself clamped), a heuristic that every setting named like
a timer period has a `TIMER_SETTING_MS_FACTOR` entry (the table is a `Partial<>`
and the overflow assertion derives its keys from it, so an omission is invisible
to both guards — dropping one entry silently deletes 11 parametrized cases), and
`console.warn` silencing in the timer block's `beforeEach`.

Every new assertion mutation-checked: reverting the render fix, breaking the
fallback invariant, dropping a timer factor, adding a sixth k8s offender, and
fixing an existing one each turn exactly the intended case red (1 failed / 168
passed in each case). 169 pass clean; server typecheck clean.
…rcion, and stop trailing prose tripping it (BLO-27641)

Takes all three Suggestions from Ally's review of 17b4b8a. Test-only; no
production surface moves.

- The detector required a `||`/`??` fallback operator, so
  `Math.max(1, Number(process.env.X))` was invisible to it — and
  `Math.max(1, Number("Infinity"))` is `Infinity`, the identical defect one
  token shorter than the form the acceptance criteria grep for. Dropping
  `|| DEFAULT` while keeping the floor is a plausible edit on any of the eight
  call sites this ticket just fixed, so the guard was blind to the likeliest
  regression. The operator is now optional; three positive cases pin the
  fallback-free shapes.

- Comment stripping handled only line-leading `//`, so a trailing comment was a
  false positive: `const x = 1; // Number(process.env.X) || 5` matched and would
  turn CI red for prose — a live hazard in the two files that discuss the idiom
  most. Stripping now covers trailing comments, with an `[^:]` guard so a
  `https://` literal cannot swallow the rest of its line (that would be a false
  negative, the worse direction). Both directions are now in the tables.

- `ALLOWED_ENV_VARS` stayed global when `knownOffenders` went per-file, so
  `PORT`'s exemption — justified by *config.ts's* two reads both ending at
  `listen()` — applied to every scanned source, and would have widened silently
  as BLO-28664 extends the scan to `server/src/**`. It moves into
  `SCANNED_SOURCES` alongside `knownOffenders`, keeping each exemption attached
  to its reasoning.

Both scanned-file expectations are unchanged under the wider detector, verified
by running it: `config.ts` -> [], `k8s-job-liveness.ts` -> exactly the same five
names. Mutation-checked, each reverted individually: widened regex 4 red,
comment strip 1 red, `[^:]` guard 1 red, per-file allowlist 1 red. 175 pass
(from 169), `tsc --noEmit` clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@kkroo
kkroo force-pushed the cto/blo-27641-numeric-env-bounds branch from 27b395c to 530b0b9 Compare August 24, 2026 21:35

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 530b0b9

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The resolver rejects non-finite and non-positive candidates before clamping, preventing both unbounded comparison values and Node timer overflow hot loops.
  • The bounds table and explicit millisecond factors make timer ceilings reviewable and executable, while the tests cover hostile finite, non-finite, fallback, warning, and reintroduction cases.
  • Prior Ally findings visible on this PR are retired in the latest disposition-bearing review; no active prior blocker remains to carry forward.
  • The source-scanner guard was adversarially checked for URL literals, comments, bracket access, fallback variants, and real trailing-line coercions.

Recommended Action

  1. No Critical or Important issues found. This formal self-review is clean.
  2. CI checks were still pending at review time; merge after the required checks complete successfully.

… ratchet (BLO-27641)

`approvalGateReconcilerIntervalMinutes` landed on master in e73698f
(2026-08-20) using the exact idiom this branch removes:

    Math.max(1, Number(process.env.X) || 10)

It reached this branch through the 2026-08-24 rebase, and the
reintroduction guard's `config.ts` offender list went red on it — the
first time that ratchet has bitten a real regression rather than a
planted mutation. Fixing the site rather than allowlisting it, because
the AC is "no numeric env override in config.ts", not "the eight
surveyed sites".

This one is worse than the other timer periods, not merely equivalent.
The period feeds `startApprovalGateReconciler(db, minutes * 60 * 1000)`
(index.ts:1783), and the site's own comment records that each sweep
costs one GitHub REST call per pending gate card. So the 1 ms coercion
does not just spin — it converts a per-card cost into a REST flood
against the rate limit. Reachable by `=Infinity` and equally by a finite
`=40000`, the 27.7-day minutes/ms confusion that overflows a 32-bit
delay.

No live exposure: no paperclip deployment sets any of these vars
(re-verified against the cluster on this branch, including this one).

Coverage is automatic rather than hand-written — adding the key to
NUMERIC_SETTING_BOUNDS forces a SETTINGS entry via `satisfies`, the
timer-like heuristic forces the TIMER_SETTING_MS_FACTOR entry, and the
overflow invariant (10080 * 60000 = 604800000 <= 2147483647) is then
asserted for it. 198 pass, up from 175.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 041fc4d

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The newly added approval-gate interval is resolved through the shared finite-positive bounds helper and is included in the millisecond-factor overflow invariant.
  • The current diff extends the existing adversarial coverage to the new setting while preserving the explicit per-file reintroduction guard and its allowlist boundaries.
  • Prior Ally findings visible on this PR are retired; no active prior blocker remains to carry forward.

Recommended Action

  1. No Critical or Important issues found. This self-review is clean.
  2. Merge after the required CI checks complete successfully.

@kkroo
kkroo added this pull request to the merge queue Aug 26, 2026
Merged via the queue into master with commit bb5e402 Aug 26, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant