# FixV17V18V26 — reserve inherit, quota-cooldown monotonicity, snapshot credential epochs Base: `b3877065` (master). Worktree: `w16x-fixv17v18v26` (branch `fix/v17-v18-v26`). Node v24.19.0. Files touched: `src/auth.js`, `test/auth-reserve-inherit.test.js`, `test/auth-quota-cooldown-monotonic.test.js`, `test/auth-snapshot-epoch.test.js`, `test/userstatus-teams-tier-log.test.js` (harness only). No push, no tag. Rows: `evidence/audit/VERDICT.md` V-17 (P1), V-18 (P1), V-26 (P2, NEEDS-EVIDENCE). Audit detail: `KimiAuditCore.md` CORE-004 (V-17+V-18) and CORE-007 (V-26). --- ## 1. Defects ### V-17 — `Number(null) === 0` turns "inherit" into an explicit zero reserve `onDemandReserve` has three states: a number, or `null` = inherit the global `onDemandReserveUsd` default (that is what `setAccountSpendPolicy` writes to clear an override, and what the dashboard renders as an empty field). Every reader used `Number.isFinite(Number(x))`, and `Number(null)`, `Number('')` and `Number(false)` are all `0`: - `resolveSpendPolicy` (`:4018`) resolved an inherited reserve to **0**, so a configured global floor of `$25` became "spend the balance to the last cent". Audit probe: global reserve 25 + `onDemandReserve: null` returned reserve 0. - `_serializeAccounts` materialized the inherited state as `onDemandReserve: 0` on the next save, so the corruption persisted. - `_deserializeAccount` read that `0` back as an explicit override: a restart could never recover "inherit". - `setAccountSpendPolicy` accepted `reserve: false` as `0`; `getAccountSpendState` reported `reserveOverride: 0` for an account that had no override. ### V-18 — a weekly snapshot could shorten and clear a live-402 cooldown One deadline (`account.quotaResetAt`) has two writers: `applyQuotaSnapshot` (proactive weekly/balance snapshot, `quotaCooldownMs`, 30 min default) and `markQuotaExhausted` (live 402 for a real request, honours an explicit upstream window). `applyQuotaSnapshot` then: - wrote `now + quotaCooldownMs()` **unconditionally**, so a 30-min snapshot overwrote a 3-hour live-402 deadline (audit probe: deadline `10801000` was cleared by `applyQuotaSnapshot(a, 80)`), and lowering `WINDSURFAPI_QUOTA_COOLDOWN_MS` retroactively shortened an armed cooldown; - cleared *any* existing `quotaResetAt` as soon as the weekly number (or the balance) looked healthy — weekly data says nothing about the dimension the live 402 came from (an on-demand/ACU exhaustion reads as healthy weekly quota). ### V-26 — background snapshots committed across credential epochs `setAccountTokens` / `reLoginAccount` install a new `apiKey` on the **same** row object. Two background readers captured the row, awaited an upstream RPC, and published the result without revalidating: - `fetchUserStatus` (`:5715`) committed `tier`, `userStatus`, `userStatusLastFetched` and per-model `capabilities` from the OLD credential onto the NEW one. The guarded sibling `_refreshCreditsImpl` (`:5322`) already rejects exactly this. - `refreshAccountBilling` (`:5552`) merged the old credential's cost tiers / promo / per-model rate into the pool-wide billing state under the same account id. Reproduced (below). The **delete + re-add ABA** sub-claim did **not** reproduce: `fetchUserStatus` resolves the row by id at entry and object identity (`isCurrentAccount`) is what a commit would have to pass, so a stale probe mutates a detached object that `saveAccounts()` never serializes. That case is pinned as a control; no code was added for it. --- ## 2. Fix (`src/auth.js` only) **V-17 — one three-state parser, used by every reader/writer** - `parseOptionalNumber()` (`:866`): a finite number or a non-empty numeric string is an override; `null` / `undefined` / `''` / booleans / objects / `NaN` / `Infinity` are absence. Never coerces first. - `serializeSpendPolicy()` (`:880`): writes `onDemandReserve` **only** when an explicit override exists (same rule already used for `spendOnDemand`), so accounts.json no longer materializes inherit as `0` (`:947`). - `_deserializeAccount` (`:1162`) and `getAccountSpendState` (`:4134`) read through the same parser; `resolveSpendPolicy` (`:4018`) falls back to the global default when the parsed override is absent or negative; `setAccountSpendPolicy` (`:4105`) rejects non-numeric input instead of writing a coerced `0`. **V-18 — monotonic writes + recorded provenance** - `QUOTA_RESET_SOURCES = {'snapshot','live402'}` (`:4005`) and `account.quotaResetSource`, persisted with a live deadline (`:938`) and restored with it (`:1151`). An unrecorded provenance (older accounts.json) is `null`, never guessed. - `applyQuotaSnapshot` dry branch (`:4057`): `Math.max(previous, now + cooldown)` — a later snapshot may extend a deadline, never pull it closer. The source is stamped `'snapshot'` only when this call actually authored the current deadline. - `applyQuotaSnapshot` recovery branch (`:4079`): clears only when the deadline is expired or was written by a snapshot. A `'live402'` (or unknown) deadline is retained with a log line and self-heals on expiry. **Deliberate behaviour change:** a spendable balance from a snapshot no longer clears a live-402 deadline either — the 402 is newer evidence about the dimension a QUOTA_EXHAUSTED denies, and the snapshot carries no per-dimension timestamp to prove otherwise. Impact is bounded by the window the 402 path itself chose (30 min default) and is self-healing. - `markQuotaExhausted` (`:4167`) stamps `'live402'` only when it extends the deadline, so a longer snapshot-armed deadline keeps its own provenance and a later recovery snapshot can still clear what it wrote. **V-26 — revalidate row identity + credential after every awaited RPC** - `fetchUserStatus` captures `tokenAtStart` at entry (`:5715`), sends the RPC with that credential, and discards the response when `!isCurrentAccount(account) || account.apiKey !== tokenAtStart` (`:5749`). Returning `null` is the function's existing "no authoritative status" contract (`probeAccount` falls back to canary probing, off by default). - `refreshAccountBilling` captures `tokenAtStart` (`:5552`), sends the RPC with it, and returns `{ ok: false, error: 'ACCOUNT_CHANGED' }` before `mergeCloudCostTiers` when the row or credential changed (`:5561`), so nothing is published and `_billingSyncAt` is not stamped. No new epoch counter was introduced: `isCurrentAccount` + captured key is the primitive this file already uses in `requestReauthCheck`, `probeAndRecoverConnectAccount`, `reLoginAccount`, `refreshAllFirebaseTokens`, `fetchAndMergeModelCatalog` and `_refreshCreditsImpl`. A second vocabulary would be a second convention for the same invariant. --- ## 3. Red-first (pristine base `b3877065`, only the three new test files copied in) ``` $ git worktree add --detach .../w16x-fixv17v18v26-base b3877065 $ cp test/auth-reserve-inherit.test.js test/auth-quota-cooldown-monotonic.test.js \ test/auth-snapshot-epoch.test.js .../w16x-fixv17v18v26-base/test/ $ node --import ./test/setup-env.mjs --test \ test/auth-reserve-inherit.test.js test/auth-quota-cooldown-monotonic.test.js \ test/auth-snapshot-epoch.test.js EXIT=1 ℹ tests 17 ℹ pass 4 ℹ fail 13 ✖ failing tests: ✖ weekly quota snapshot cannot shorten a live 402 cooldown ✖ a recovered weekly snapshot does not clear a live-402 cooldown ✖ a snapshot-written cooldown still clears on a recovered snapshot (control) ✖ a snapshot never shortens a longer snapshot cooldown either ✖ an expired live-402 cooldown is cleaned up by the next snapshot (self-healing) ✖ a spendable balance does not clear a live-402 deadline either (deliberate) ✖ the live-402 provenance survives a restart ✖ a legacy deadline with no recorded source is treated as not-snapshot-clearable ✖ reserve:null round-trips as inherit and is not coerced to zero ✖ an inherited reserve resolves to the configured global floor (not zero) ✖ non-numeric input is rejected instead of being coerced to zero ✖ a deferred old-key user-status snapshot cannot commit onto the new credential epoch ✖ a deferred old-key billing snapshot cannot publish onto the new credential epoch ``` The 4 base-passing cases are the deliberate controls: explicit `reserve: 0` stays zero, an explicit numeric reserve survives the round trip, the delete/re-add ABA probe (V-26 sub-claim, refuted above) and the same-epoch billing refresh. `a snapshot-written cooldown still clears on a recovered snapshot (control)` is red on base only because it also asserts the new `quotaResetSource` field is dropped with the deadline. Reproduction mechanics: `fetchUserStatus` and `refreshAccountBilling` are driven through their **real source text** with only the RPC boundary substituted (the `new Function` source-slice harness `test/s4-r3.test.js` already uses for `refreshCredits`; `refreshAccountBilling` uses the existing `__setModelCatalogDeps` seam). Each test asserts the RPC ran under the captured credential, rotates the key mid-flight via `setAccountTokens`, then resolves the deferred response. ## 4. Green after ``` $ node --import ./test/setup-env.mjs --test \ test/auth-reserve-inherit.test.js test/auth-quota-cooldown-monotonic.test.js \ test/auth-snapshot-epoch.test.js test/userstatus-teams-tier-log.test.js ✔ V-18 — quota cooldown is monotonic and source-aware ✔ V-17 — on-demand reserve: null means inherit, never zero ✔ V-26 — background snapshots are bound to one credential epoch ✔ #245 teams_tier log line — raw enum plus the tier it mapped to ✔ P2 — a refreshed allowlist denial supersedes an older success ℹ tests 24 ℹ pass 24 ℹ fail 0 ``` `test/userstatus-teams-tier-log.test.js` slices `fetchUserStatus` out of the production source and supplies every free variable; it gained one dep (`isCurrentAccount`, defined over its own `accounts` array). No assertion changed. ## 5. Mutation check — every new guard is load-bearing Each mutation was applied alone to `src/auth.js`, the owning suite re-run, and the file restored byte-identically (`restored identical: True`). | Mutation | Result | Test that goes red | |---|---|---| | V17-L1 `resolveSpendPolicy` back to `Number(x)` | exit 1, fail 1 | an inherited reserve resolves to the configured global floor (not zero) | | V17-L2 serializer back to `Number.isFinite(Number(x))` | exit 1, fail 1 | reserve:null round-trips as inherit and is not coerced to zero | | V17-L3 deserializer back to `Number.isFinite(Number(x))` | exit 1, fail 2 | reserve:null round-trips…; non-numeric input is rejected… | | V17-L4 setter back to `Number(reserve)` | exit 1, fail 1 | non-numeric input is rejected instead of being coerced to zero | | V18-L1 `Math.max` removed from the snapshot write | exit 1, fail 2 | weekly quota snapshot cannot shorten a live 402 cooldown | | V18-L2 source gate removed (`if (true)`) | exit 1, fail 4 | a recovered weekly snapshot does not clear a live-402 cooldown | | V18-L3 `'live402'` stamp removed | exit 1, fail 1 | the live-402 provenance survives a restart | | V18-L4 source persistence removed | exit 1, fail 1 | the live-402 provenance survives a restart | | V26-L1 `fetchUserStatus` epoch guard removed | exit 1, fail 1 | a deferred old-key user-status snapshot cannot commit onto the new credential epoch | | V26-L2 `refreshAccountBilling` epoch guard removed | exit 1, fail 1 | a deferred old-key billing snapshot cannot publish onto the new credential epoch | ## 6. Targeted suites ``` $ node --import ./test/setup-env.mjs --test test/auth-*.test.js test/quota-*.test.js \ test/devin-connect-breaker.test.js test/s4-r3.test.js test/sticky-tenant-quota.test.js ℹ tests 175 ℹ pass 175 ℹ fail 0 $ node --import ./test/setup-env.mjs --test test/connect-*.test.js test/dashboard-*.test.js \ test/relogin*.test.js test/*relogin*.test.js ℹ tests 931 ℹ pass 931 ℹ fail 0 ``` ## 7. Full suite ``` $ node scripts/run-tests.mjs EXIT=0 ℹ tests 7037 ℹ pass 6940 ℹ fail 0 ℹ cancelled 0 ℹ skipped 97 ℹ todo 0 Wall time: 63.91s ``` First full run after the source fix was `fail 7`, all in `test/userstatus-teams-tier-log.test.js`: that harness predates the new `isCurrentAccount` dependency in `fetchUserStatus` and had no binding for it (ReferenceError inside the sliced function). Adding the dep fixed all 7; no production behaviour was implicated. ## 8. Residual / not done - `[UNVERIFIED]` No live upstream traffic was exercised. All three rows are proven by unit tests over the real source; nothing here was validated against a real 402, a real GetUserStatus response, or a real plan rotation. - V-26: `_billingSyncPromises` still coalesces by account id only, so a refresh started before a rotation can be handed to a caller after it. The stale promise now settles as `ACCOUNT_CHANGED` and the next cadence tick (30 min default) fetches for the new credential. No wrong state is published; the cost is a delayed refresh, not a correctness gap, so no coalescing-key change was made. - V-18: a `'live402'` or unrecorded deadline is now cleared only by expiry. An accounts.json written by an older version therefore keeps its current cooldown until it expires (≤ 30 min default) even if the next snapshot sees recovery. - V-17: the dashboard form (`src/dashboard/index.html:10681`) maps an empty field to `null` (correct) and a garbage value to `Number(val)` → `NaN` → JSON `null` (also clears). The UI-side input validation was left untouched; the server no longer accepts a coerced zero. - Not in this lane: `src/models.js` reserve/pricing rows (CORE-004's second half), CORE-042 entitlement reset on key rotation, CORE-032 Firebase epoch.