evidence/FixV17V18V26.md
Rama master · réplica de lectura del repositorio original.
# 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.