Skip to content

Commit 072df52

Browse files
lidge-junjun
andauthored
fix(codex,gui): restore the plan and ticket badges on the main account card (#3423)
* fix(codex,gui): restore the plan and ticket badges on the main account card The main account card showed neither its plan badge nor its reset-credit ticket badge, while every pool card showed both. Two independent causes: The plan badge was simply absent from the main card's badge row. codex-account-pool-cards.tsx renders it for pool accounts; the main card never did, even though the server has always sent `plan`. The ticket badge had a data cause. `poolAccountDto` serializes the merged quota store, because `commitPoolQuotaResponse` re-reads `getAccountQuota()` after committing. The main DTO instead serialized the raw WHAM parse result and reached into the store for `updatedAt` alone, so a `resetCredits` the store had carried forward never reached the response. `/wham/usage` includes `rate_limit_reset_credits` only intermittently, so the badge vanished on every response that omitted it and `CodexTicketBadge` returned null. The fix carries only `resetCredits`, and not from the store. `__main__` is an alias: `auth.json` can be swapped for another account while the proxy is down, and `reconcileMainCodexAccountRuntimeState` cannot purge alias-keyed state on its first observation after a restart, so a disk-hydrated entry may belong to the previous login. The carried count is therefore an in-process observation tagged with the account id it was read from, released only while that identity still matches. Window fields are untouched, so the monthly-only clearing behaviour from #382 is unaffected. Verification: bun test tests/codex-auth-api.test.ts 199 pass / 0 fail; both new tests were driven red first (removing the DTO fix fails the carry test, removing the identity guard fails the leak test). typecheck, lint:gui and privacy:scan exit 0. * docs(devlog): record the main-card badge parity audit outcome and render evidence --------- Co-authored-by: jun <jun@lidge.dev>
1 parent 8b60e4c commit 072df52

9 files changed

Lines changed: 636 additions & 5 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# 000 — Evidence: main account card is missing two badges
2+
3+
Unit: `260904_main_card_badge_parity`
4+
Opened: 2026-09-04
5+
Branch base: `dev` @ `8b60e4c44`
6+
7+
## Reported symptom
8+
9+
On the Codex Auth dashboard the MAIN account card shows neither the plan badge
10+
(`pro`) nor the reset-credit ticket badge, while every pool card shows both.
11+
12+
## Live evidence (read-only, port 10100)
13+
14+
`GET /api/codex-auth/accounts` at 2026-09-04, main entry:
15+
16+
```json
17+
{"id":"__main__","email":"k***1@gmail.com","plan":"pro","isMain":true,
18+
"quota":{"weeklyPercent":28,"weeklyResetAt":1788749167,"updatedAt":1788490155601}}
19+
```
20+
21+
A pool entry from the same response:
22+
23+
```json
24+
{"id":"chatgpt-1786626108327","plan":"pro",
25+
"quota":{"updatedAt":1788490159314,"weeklyPercent":26,"weeklyResetAt":1788748127,"resetCredits":2}}
26+
```
27+
28+
On-disk cache `~/.opencodex/codex-quota-cache.json`, `__main__` entry:
29+
30+
```json
31+
{"updatedAt":1788490155601,"weeklyPercent":28,"weeklyResetAt":1788749167,
32+
"customWindows":[{"label":"GPT-5.3-Codex-Spark Weekly","percent":0,"resetAt":1789094955}],
33+
"resetCredits":1}
34+
```
35+
36+
So the store HAS `resetCredits: 1` for the main account, and the response DTO
37+
drops it. That is the whole of defect 2.
38+
39+
## Two independent defects
40+
41+
**D1 — plan badge absent from the main card markup.** The server sends
42+
`plan: "pro"`. `gui/src/components/codex-account-pool-cards.tsx:91` renders
43+
`{a.plan && <span className="badge badge-green">{a.plan}</span>}` inside
44+
`card-badges`. The equivalent block in
45+
`gui/src/components/codex-account-pool-main-card.tsx:87-99` has no such line.
46+
Purely a missing element; no data problem.
47+
48+
**D2 — resetCredits never reaches the main DTO.**
49+
`CodexTicketBadge` (`codex-account-pool-helpers.tsx:28-51`) returns `null`
50+
when `account.quota` is non-null but `quota.resetCredits === undefined`. The
51+
main card passes `{...main, id:"__main__"}`, so it inherits whatever the DTO
52+
carries — and the DTO carries no `resetCredits`.
53+
54+
## Why the two DTO paths diverge
55+
56+
Pool path, `src/codex/auth-api.ts`:
57+
58+
- `commitPoolQuotaResponse` writes the parsed snapshot with
59+
`setAccountQuotaFromParsed(accountId, quota, writerGeneration)` (line 1195)
60+
and then returns `quota: getAccountQuota(accountId)` (line 1197) — it reads
61+
the value **back out of the merged store**.
62+
- `poolAccountDto` (line 277) serializes that store-read object, so it carries
63+
every field the store merged, including a `resetCredits` that arrived on an
64+
earlier partial snapshot.
65+
66+
Main path, same file:
67+
68+
- `fetchMainAccountInfoWhileOwned` (line 807+) parses the same WHAM payload,
69+
mirrors it into the store with `setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, ...)`
70+
(line 860) — and then caches and returns `result.quota`, the **pre-merge parse
71+
result**, not the store value.
72+
- `listCodexAuthAccountsSnapshot` (line 1632-1647) builds the main DTO from
73+
`mainInfo.quota`, spreading it and patching in only `updatedAt` from
74+
`getAccountQuota(MAIN_CODEX_ACCOUNT_ID)`:
75+
76+
```ts
77+
quota: mainInfo.quota ? {
78+
...quotaForPlan({
79+
...mainInfo.quota,
80+
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
81+
}, mainInfo.plan),
82+
} : null,
83+
```
84+
85+
That reaches into the store for exactly one field. Every other merged field —
86+
`resetCredits` above all — is lost whenever the current `/wham/usage` response
87+
omits `rate_limit_reset_credits.available_count`.
88+
89+
## Why the current response omits it
90+
91+
`parseUsageQuota` (`src/codex/quota.ts:561`) only sets `resetCredits` when the
92+
payload carries `rate_limit_reset_credits.available_count`. `/wham/usage`
93+
includes that summary inconsistently, and the dedicated
94+
`/wham/rate-limit-reset-credits` endpoint is separately rate limited — a live
95+
probe for `__main__` returned `{"error":"Upstream error 429"}`. The store is
96+
specifically designed to survive that: `setAccountQuotaFromParsed`
97+
(`quota.ts:339-340`) carries `existing.resetCredits` forward when the new
98+
snapshot omits it. The pool DTO benefits from that carry-forward because it
99+
re-reads the store. The main DTO does not, because it does not.
100+
101+
This also matches upstream Codex, where `RateLimitsWithResetCredits`
102+
(`codex-rs/backend-client/src/types.rs:45-48`) models the reset-credit summary
103+
as `Option` alongside rate limits rather than as a field guaranteed on every
104+
usage read.
105+
106+
## Conclusion
107+
108+
D2 is a server-layer bug, not a GUI bug: the main account is the only account
109+
whose DTO bypasses the merged quota store. Fixing it in the GUI (for example by
110+
reading `/api/codex-auth/quota` separately) would paper over an asymmetry that
111+
also affects any other consumer of the main DTO.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# 010 — Phase 1: main-account DTO reads the merged quota store
2+
3+
Work phase: `wp1`. Depends on: nothing. Consumed by: `020`.
4+
5+
## Goal
6+
7+
The main account's DTO quota must be the same merged store object a pool
8+
account's DTO quota is, so every field the store carries forward (today
9+
`resetCredits`, tomorrow anything else) reaches the dashboard.
10+
11+
## Scope boundary
12+
13+
IN: `src/codex/auth-api.ts` main DTO construction; a focused test in
14+
`tests/codex-auth-api.test.ts`.
15+
OUT: `src/codex/quota.ts` merge semantics (already correct), the pool path,
16+
any WHAM fetch/refresh policy, credential handling.
17+
18+
## File change map
19+
20+
### `src/codex/auth-api.ts` — `listCodexAuthAccountsSnapshot`, main DTO (~line 1642)
21+
22+
Before:
23+
24+
```ts
25+
quota: mainInfo.quota ? {
26+
...quotaForPlan({
27+
...mainInfo.quota,
28+
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
29+
}, mainInfo.plan),
30+
} : null,
31+
```
32+
33+
After:
34+
35+
```ts
36+
quota: mainInfo.quota ? {
37+
...quotaForPlan(mergeMainQuotaWithStore(mainInfo.quota), mainInfo.plan),
38+
} : null,
39+
```
40+
41+
with a small local helper next to the DTO builders:
42+
43+
```ts
44+
/**
45+
* The main account is the only account whose DTO quota came from the raw parse
46+
* result rather than the merged store, so a resetCredits the store had carried
47+
* forward vanished from the response whenever the current /wham/usage payload
48+
* omitted `rate_limit_reset_credits`. Pool DTOs never had that hole because
49+
* commitPoolQuotaResponse re-reads getAccountQuota() after committing.
50+
*
51+
* Only resetCredits is filled from the store, deliberately. The window fields
52+
* have *clearing* semantics -- a monthly-only snapshot must drop a stale weekly
53+
* value (#382) -- so a blanket spread of the stored object would resurrect a
54+
* window the parse intended to clear whenever the store write was refused by
55+
* generation gating. resetCredits is the one field setAccountQuotaFromParsed
56+
* itself carries forward (quota.ts:339-340), so mirroring exactly that rule
57+
* here keeps the DTO consistent with the store instead of inventing a second,
58+
* looser merge policy.
59+
*/
60+
function mainQuotaWithStoredResetCredits(
61+
parsed: Omit<StoredAccountQuota, "updatedAt">,
62+
): StoredAccountQuota {
63+
const stored = getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
64+
return {
65+
...parsed,
66+
...(parsed.resetCredits === undefined && stored?.resetCredits !== undefined
67+
? { resetCredits: stored.resetCredits }
68+
: {}),
69+
updatedAt: stored?.updatedAt ?? Date.now(),
70+
};
71+
}
72+
```
73+
74+
Call site becomes `quotaForPlan(mainQuotaWithStoredResetCredits(mainInfo.quota), mainInfo.plan)`.
75+
76+
Precedence rationale: a freshly parsed `resetCredits` always wins, including a
77+
deliberate `0` (0 is defined, so it is present in `parsed` and the fill branch
78+
does not run). The store supplies the value only when the parse omitted the key
79+
entirely. Every other field is untouched, so no window-clearing behaviour
80+
changes.
81+
82+
### Audit finding folded in (blocker 1)
83+
84+
The first draft of this document proposed `{ ...stored, ...parsed }`. That is
85+
unsafe: `setAccountQuotaFromParsed` refuses to commit when
86+
`mayCommitAccountQuota` fails generation gating (`quota.ts:280`), so the store
87+
can legitimately hold a PRE-clear snapshot while `parsed` is monthly-only. The
88+
blanket spread would then re-introduce the stale `weeklyPercent` that #382
89+
exists to clear, and it would show up as a phantom weekly bar on the main card.
90+
Narrowing the merge to `resetCredits` removes that failure mode entirely.
91+
92+
### Identity-change safety (corrected — audit blocker 3)
93+
94+
The first two drafts claimed a swapped identity "cannot leak" a previous
95+
account's credits. **That claim was wrong**, and the second reviewer
96+
(muse-spark-1.3-contributor) refuted it with the exact path:
97+
98+
- In-process swaps ARE safe: `reconcileMainCodexAccountRuntimeState`
99+
(`account-lifecycle.ts:60-70`) purges alias-keyed `__main__` quota when it
100+
observes the account id change, and `mainSnapshotLive === false` forces
101+
`EMPTY_MAIN_ACCOUNT_INFO`, whose null quota short-circuits the DTO guard.
102+
- Across a RESTART it is not. `observedMainChatgptAccountId`
103+
(`account-lifecycle.ts:21`) is memory-only, and the first observation after a
104+
restart hits the `previousAccountId === undefined` early return with no purge
105+
(`:67`). If `~/.codex/auth.json` was swapped while the proxy was down, the
106+
disk-hydrated `__main__` quota entry still belongs to the PREVIOUS login, and
107+
a store-based fill would print its ticket count on the new account's card.
108+
Pool accounts never have this hole because their store key is the account id
109+
itself; `__main__` is an alias.
110+
111+
Fix: do not read the fill value from the store at all. Keep an in-process,
112+
identity-tagged observation of the last parsed count
113+
(`mainResetCreditsProvenance = { accountId, credits }`), recorded in
114+
`fetchMainAccountInfoWhileOwned` next to the existing `freshResetCredits`, and
115+
return it only when `getMainChatgptAccountId()` still matches. A restart simply
116+
starts with no observation, so the badge waits for the first response that
117+
carries the summary rather than showing a stale or foreign number.
118+
119+
```ts
120+
let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
121+
122+
function mainResetCreditsForCurrentIdentity(): number | undefined {
123+
if (!mainResetCreditsProvenance) return undefined;
124+
const currentAccountId = getMainChatgptAccountId();
125+
if (currentAccountId === null) return undefined;
126+
if (currentAccountId !== mainResetCreditsProvenance.accountId) {
127+
mainResetCreditsProvenance = null;
128+
return undefined;
129+
}
130+
return mainResetCreditsProvenance.credits;
131+
}
132+
```
133+
134+
`updatedAt` still comes from the store, unchanged from today's behaviour.
135+
136+
### Consume-route interaction (audit question Q2c)
137+
138+
`auth-api.ts:2135` deliberately refuses to report a preserved cached
139+
`resetCredits` as the consume response's `remaining`. That governs a
140+
*transactional* claim about a just-executed redeem and is a different guarantee
141+
from best-effort display state, so the DTO carry does not violate it. The real
142+
overlap, recorded rather than fixed: if the forced post-consume refresh omits
143+
the summary, the main card keeps showing the pre-consume count until the next
144+
response that carries it — exactly the staleness every pool card already has.
145+
146+
### Upstream omission semantics (residual, non-blocking)
147+
148+
If upstream ever omits `rate_limit_reset_credits` to MEAN zero, a carried
149+
non-zero would persist until the next explicit reading. Nothing in this
150+
repository settles that question, the risk is pre-existing in the store merge,
151+
and it is shared with every pool card. Named here rather than guessed at.
152+
153+
### quotaForPlan interaction
154+
155+
`quotaForPlan` already forwards `resetCredits` for 30-day plans
156+
(`auth-api.ts:272`) and `withSparkVisibility` only filters `customWindows`,
157+
which this helper does not touch. No change needed in either.
158+
159+
Note on `quotaForPlan`: it already passes `resetCredits` through for 30-day
160+
plans (`auth-api.ts:272`), so no change is needed there.
161+
162+
## Accept criteria
163+
164+
1. Given a main WHAM parse result without `resetCredits` and a store entry for
165+
`__main__` holding `resetCredits: 1`, the main DTO carries `resetCredits: 1`.
166+
2. Given a parse result WITH `resetCredits: 0` and a store entry holding
167+
`resetCredits: 3`, the DTO carries `0` — fresh wins, including zero.
168+
3. Given no store entry, the DTO is byte-identical to today's output.
169+
4. `updatedAt` behaviour is unchanged (store value, else now).
170+
5. Window fields are never taken from the store: a monthly-only parse with a
171+
stored weekly value still produces a DTO without `weeklyPercent`.
172+
6. A carried count is dropped when the physical main account id changes.
173+
174+
### Activation scenario for the conditional path
175+
176+
The new helper's store branch only runs when `getAccountQuota("__main__")`
177+
returns an entry. The test triggers it by calling
178+
`setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { resetCredits: 1 })` before
179+
listing accounts, and proves it ran by asserting `resetCredits` is present in
180+
the returned DTO where it is absent today.
181+
182+
## Verifier
183+
184+
`bun test tests/codex-auth-api.test.ts` — this file already exercises the main
185+
DTO and the `__main__` quota store (it references
186+
`getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits` at lines 2631, 2668,
187+
2706), so it observes the change target directly.
188+
189+
## Field chain (PLAN-FIELD-CHAIN-01)
190+
191+
`resetCredits: number | undefined` is not a new field; this phase changes which
192+
object the DTO reads. Chain for completeness:
193+
194+
- creation: `parseUsageQuota` (`quota.ts:562`) from
195+
`rate_limit_reset_credits.available_count`; also
196+
`updateAccountQuota(..., resetCredits)` (`quota.ts:473`).
197+
- store merge: `setAccountQuotaFromParsed` (`quota.ts:294, 339-340`).
198+
- serialization: `poolAccountDto` (store-read) and the main DTO (this fix).
199+
- deserialization: `hydrateAccountQuotasFromDisk` reads
200+
`codex-quota-cache.json`; N/A for the DTO, which is response-only.
201+
- consumers: `CodexTicketBadge` (`gui/.../codex-account-pool-helpers.tsx:29`),
202+
`src/cli/account-auth.ts`, the reset-credit consume route
203+
(`auth-api.ts:2135`).
204+
205+
## Bypass record (PLAN-BYPASS-NAMED-01)
206+
207+
This phase adds no enforcement. Tier: n/a. Executing surface: n/a. Known bypass:
208+
n/a. Residual risk: a future main DTO rewrite could reintroduce the raw-parse
209+
read; the regression test in criterion 1 is the early warning. Final enforcement
210+
layer: none.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# 020 — Phase 2: main card renders the plan badge
2+
3+
Work phase: `wp2`. Depends on: `010` (only for the ticket badge to have data;
4+
the plan badge itself is independent).
5+
6+
## Goal
7+
8+
The main account card shows its plan as a badge, exactly as pool cards do.
9+
10+
## Scope boundary
11+
12+
IN: `gui/src/components/codex-account-pool-main-card.tsx` badge row.
13+
OUT: card layout, the skeleton block, pool card markup, styling changes.
14+
15+
## File change map
16+
17+
### `gui/src/components/codex-account-pool-main-card.tsx` (~line 87)
18+
19+
Inside `<span className="card-badges">`, as the FIRST child — matching the pool
20+
card's order at `codex-account-pool-cards.tsx:91` so both cards read
21+
plan → paused → priority → pinned → ticket → health:
22+
23+
```tsx
24+
{main?.plan && <span className="badge badge-green">{main.plan}</span>}
25+
```
26+
27+
The existing ticket badge line moves after the pinned badge so the two cards
28+
share one badge order. Nothing else in the row changes.
29+
30+
### Skeleton parity (`~line 279`) — decision recorded
31+
32+
The load skeleton reserves a ticket slot and a `badge-primary` slot. The
33+
question was whether the new plan badge needs a matching muted strut.
34+
35+
**Decision: no strut.** The skeleton already omits the priority and pinned
36+
badges that the ready state can render, so approximate width parity is the
37+
established norm for this card rather than a regression introduced here. Adding
38+
a strut for the plan badge alone would make the skeleton wider than the common
39+
ready state (an account with no plan renders no badge at all), trading one
40+
small shift for a different one. Recorded per the audit's blocker 2, which
41+
asked for the strut or a stated reason.
42+
43+
## Accept criteria
44+
45+
1. With `main.plan === "pro"`, the rendered main card contains
46+
`<span class="badge badge-green">pro</span>`.
47+
2. With `main.plan` undefined, no plan badge element is rendered (no empty span).
48+
3. Badge order in the main card matches the pool card.
49+
50+
## Verifier
51+
52+
Rendered-DOM observation on the running dashboard (C-RENDER-GROUNDING-01) plus
53+
`bun run lint:gui` and `bun run typecheck`. There is no existing GUI unit-test
54+
harness for this component, so the DOM observation is the acceptance evidence and
55+
that is recorded rather than claimed as a gate.
56+
57+
## Bypass record
58+
59+
No enforcement added. Final enforcement layer: none; the visual check is human/DOM
60+
observation.

0 commit comments

Comments
 (0)