Ctrl K
ComunidadGuías…

evidence/FixV17V18V26.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.