evidence/Wave7-AuthTruth.md
Rama master · réplica de lectura del repositorio original.
# Wave 7 — auth truth: success freshness + billed-history reservation
Lane: `AuthTruthP2` · Worktree `w122-authtruth` · Branch `fix/auth-truth-p2` · Base `cf27abd` (master)
No push, no tag, no merge.
## Findings (wave review, `evidence/KimiWaveReview.md`)
### P2-4 — a historical `success` defeats current entitlement indefinitely
`reason:'success'` outranked the tier table and the current allowlist with no
freshness/tier boundary. `lastCheck` was persisted but never read by the gates,
and `fetchUserStatus` deliberately preserved a prior success when the refreshed
allowlist omitted that enum. Reproduced by the reviewer with
`{tier:'free', userStatusLastFetched:<now>, capabilities:{'claude-opus-4.6':
{ok:true,reason:'success',lastCheck:1}}}`: both `isModelAllowedForAccount` and
`getAvailableModelsForAccount` returned true, so a downgraded/trial-expired
account kept listing and routing a premium model the upstream no longer
entitled.
### P2-6 — a failure burst evicts the billed window and zeroes its summary
Failed attempts and billed rows shared the 100-row ring (`ACCOUNT_REQUEST_CAP`).
One billed row (5 prompt + 3 completion, credit 2, ACU 1) followed by 100 failed
rows yielded `{total:100, billed:0, failed:100, summary:{requests:0,
totalTokens:0, credit:0, acu:0}}` — the dashboard read as "this account served
nothing".
## Changes
### `src/auth.js` — one capability verdict for both gates
- `capabilityVerdict(account, modelKey)` (`:1954`) replaces the old
`AVAILABLE_CAPABILITY_REASONS` set. It returns `true` (positive), `false`
(authoritative negative) or `null` (no verdict → tier table):
- `not_entitled` → false (both gates; previously the list gate let a UID-only
`not_entitled` fall through to the Pro tier table).
- `user_status` → `cap.ok === true`.
- `success` → positive only while `lastCheck >= userStatusLastFetched`, i.e.
the measurement is at least as new as the account's last authoritative
snapshot. A stale success is a denial for enum-keyed models (the newer
snapshot omitted them authoritatively) and falls back to the tier table for
UID-only models (GetUserStatus cannot classify those).
- `isModelAllowedForAccount` (`:2003`) and `getAvailableModelsForAccount`
(`:2211`) now consume that single verdict, preserving the #159 invariant that
the list and the routing decision cannot disagree.
### `src/auth.js` — a refreshed denial supersedes an older success
`fetchUserStatus` (`:5581`) now writes `not_entitled` for every enum model the
refreshed allowlist omits, instead of preserving a prior `success`. A call that
succeeds *after* the snapshot still re-establishes `success` through
`updateCapability()` (its `lastCheck` is newer), so the denial is not a
permanent tombstone.
### `src/auth.js` — billed rows own the request-history window
`recordAccountRequest` (`:4279`) keeps the 100-row bound but changes eviction
order:
- incoming billed row → evict the oldest **failed** row; if every slot is
billed, evict the oldest billed row;
- incoming failed row → evict the oldest failed row; if every slot is billed,
drop the incoming failure instead of displacing a billed row.
A failure can therefore never evict a billed row. `total`, `billed`, `failed`,
`capacity` and the billed `summary` contract are unchanged; the summary is still
recomputed over survivors.
## Tests (red-first)
New/updated:
- `test/account-entitlement.test.js` — P2-4 block: stale success + fresh
snapshot → neither listed nor routed (exact reviewer shape); success after the
snapshot → listed and routed; no snapshot → success still trusted; refreshed
`not_entitled` outranks the Pro tier table in both gates. The #159 fixtures
now use a success newer than their snapshot.
- `test/userstatus-teams-tier-log.test.js` — executes the real `fetchUserStatus`
body: a refreshed allowlist omitting a previously-successful enum writes
`not_entitled`.
- `test/dashboard-account-requests.test.js` — one billed row + 100 failures →
`billed:1`, summary byte-for-byte unchanged; a failure into a full billed
window is dropped; a new billed row evicts the failure first.
- `test/mutations/auth-truth-p2.json` — three negative controls (freshness
boundary removed; refreshed denial preserves success; old shared eviction
order), anchors verified unique by `test/docs-consistency-guard.test.js`.
Red-before (base + tests only; `node --import ./test/setup-env.mjs --test …`):
```
test/account-entitlement.test.js ℹ tests 18 ℹ pass 16 ℹ fail 2
✖ does not list or route a success older than the latest GetUserStatus snapshot
✖ lets a refreshed not_entitled supersede an older success even on a Pro tier
test/userstatus-teams-tier-log.test.js ℹ tests 7 ℹ pass 6 ℹ fail 1
✖ writes not_entitled over a success the refreshed allowlist omits
actual: { ok: true, reason: 'success' }
expected: { ok: false, reason: 'not_entitled' }
test/dashboard-account-requests.test.js ℹ tests 15 ℹ pass 12 ℹ fail 3
✖ a failure burst cannot evict the billed window or zero its summary
✖ drops an incoming failure rather than evicting a billed row from a full billed window
✖ a newer billed row evicts the oldest failure before any billed row
```
Green-after:
```
test/account-entitlement.test.js ℹ tests 18 pass 18 fail 0
test/userstatus-teams-tier-log.test.js ℹ tests 7 pass 7 fail 0
test/dashboard-account-requests.test.js ℹ tests 15 pass 15 fail 0
```
Related suites (entitlement/list/ring consumers), all green:
```
billing-identity-rotation, cloud-catalog-filter, connect-entitlement,
connect-rate-table-wiring, dashboard-devin-cards-render,
dashboard-devin-panel-routes ℹ tests 82 fail 0
free-tier-cold-start, langserver-resource, probe-canary-optin,
s4-r3, probe-pending-gating, tier-unknown-optimistic ℹ tests 50 fail 0
dashboard-devin-panel, dashboard-devin-read, dashboard-account-kpi,
dashboard-account-query, dashboard-api ℹ tests 84 fail 0
docs-consistency-guard (mutation anchors) ℹ tests 47 fail 0
```
## Full suite
`node scripts/run-tests.mjs` on the final bytes:
```
ℹ tests 6840
ℹ suites 1290
ℹ pass 6743
ℹ fail 0
ℹ cancelled 0
ℹ skipped 97
ℹ todo 0
ℹ duration_ms 54116.0216
```
## Mutation check
The repo's mutation harness (`scripts/mutate-verify.mjs`) requires git at a
trusted absolute POSIX path and refuses to run on this Windows box. The three
layers were mutation-checked by hand instead: back up `src/auth.js` (sha256
`9701ff61…ce9e`), apply the spec's replacement, run the targeted suite, restore
the backup, verify the sha256, re-run.
| Mutation (spec `test/mutations/auth-truth-p2.json`) | Result |
|---|---|
| freshness boundary removed (`if (true) return true;`) | CAUGHT — `account-entitlement` 17/18, stale-success test fails |
| refreshed denial preserves the old success | CAUGHT — `userstatus-teams-tier-log` 6/7, supersession test fails |
| shared eviction order restored (`ring.splice(0, 1)`) | CAUGHT — `dashboard-account-requests` 12/15, all three eviction tests fail |
After restore: sha256 matches the backup and the three files pass 40/40.
## Residual
- `_discoveredFreeModels` is process-global: a `success` measured while an
account was `free`/`unknown` permanently widens the free tier table for every
account. That is pre-existing discovery behavior, not this finding's
`capabilities.success` path (a Pro/trial success is not registered because
`updateCapability` only registers free/unknown tiers). Not changed here.
- A success on a UID-only model that is newer than the last snapshot remains
trusted until the next snapshot; that is the intended live-evidence window.