Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions devlog/_plan/260904_main_card_badge_parity/000_evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 000 — Evidence: main account card is missing two badges

Unit: `260904_main_card_badge_parity`
Opened: 2026-09-04
Branch base: `dev` @ `8b60e4c44`

## Reported symptom

On the Codex Auth dashboard the MAIN account card shows neither the plan badge
(`pro`) nor the reset-credit ticket badge, while every pool card shows both.

## Live evidence (read-only, port 10100)

`GET /api/codex-auth/accounts` at 2026-09-04, main entry:

```json
{"id":"__main__","email":"k***1@gmail.com","plan":"pro","isMain":true,
"quota":{"weeklyPercent":28,"weeklyResetAt":1788749167,"updatedAt":1788490155601}}
```

A pool entry from the same response:

```json
{"id":"chatgpt-1786626108327","plan":"pro",
"quota":{"updatedAt":1788490159314,"weeklyPercent":26,"weeklyResetAt":1788748127,"resetCredits":2}}
```

On-disk cache `~/.opencodex/codex-quota-cache.json`, `__main__` entry:

```json
{"updatedAt":1788490155601,"weeklyPercent":28,"weeklyResetAt":1788749167,
"customWindows":[{"label":"GPT-5.3-Codex-Spark Weekly","percent":0,"resetAt":1789094955}],
"resetCredits":1}
```

So the store HAS `resetCredits: 1` for the main account, and the response DTO
drops it. That is the whole of defect 2.

## Two independent defects

**D1 — plan badge absent from the main card markup.** The server sends
`plan: "pro"`. `gui/src/components/codex-account-pool-cards.tsx:91` renders
`{a.plan && <span className="badge badge-green">{a.plan}</span>}` inside
`card-badges`. The equivalent block in
`gui/src/components/codex-account-pool-main-card.tsx:87-99` has no such line.
Purely a missing element; no data problem.

**D2 — resetCredits never reaches the main DTO.**
`CodexTicketBadge` (`codex-account-pool-helpers.tsx:28-51`) returns `null`
when `account.quota` is non-null but `quota.resetCredits === undefined`. The
main card passes `{...main, id:"__main__"}`, so it inherits whatever the DTO
carries — and the DTO carries no `resetCredits`.

## Why the two DTO paths diverge

Pool path, `src/codex/auth-api.ts`:

- `commitPoolQuotaResponse` writes the parsed snapshot with
`setAccountQuotaFromParsed(accountId, quota, writerGeneration)` (line 1195)
and then returns `quota: getAccountQuota(accountId)` (line 1197) — it reads
the value **back out of the merged store**.
- `poolAccountDto` (line 277) serializes that store-read object, so it carries
every field the store merged, including a `resetCredits` that arrived on an
earlier partial snapshot.

Main path, same file:

- `fetchMainAccountInfoWhileOwned` (line 807+) parses the same WHAM payload,
mirrors it into the store with `setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, ...)`
(line 860) — and then caches and returns `result.quota`, the **pre-merge parse
result**, not the store value.
- `listCodexAuthAccountsSnapshot` (line 1632-1647) builds the main DTO from
`mainInfo.quota`, spreading it and patching in only `updatedAt` from
`getAccountQuota(MAIN_CODEX_ACCOUNT_ID)`:

```ts
quota: mainInfo.quota ? {
...quotaForPlan({
...mainInfo.quota,
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
}, mainInfo.plan),
} : null,
```

That reaches into the store for exactly one field. Every other merged field —
`resetCredits` above all — is lost whenever the current `/wham/usage` response
omits `rate_limit_reset_credits.available_count`.

## Why the current response omits it

`parseUsageQuota` (`src/codex/quota.ts:561`) only sets `resetCredits` when the
payload carries `rate_limit_reset_credits.available_count`. `/wham/usage`
includes that summary inconsistently, and the dedicated
`/wham/rate-limit-reset-credits` endpoint is separately rate limited — a live
probe for `__main__` returned `{"error":"Upstream error 429"}`. The store is
specifically designed to survive that: `setAccountQuotaFromParsed`
(`quota.ts:339-340`) carries `existing.resetCredits` forward when the new
snapshot omits it. The pool DTO benefits from that carry-forward because it
re-reads the store. The main DTO does not, because it does not.

This also matches upstream Codex, where `RateLimitsWithResetCredits`
(`codex-rs/backend-client/src/types.rs:45-48`) models the reset-credit summary
as `Option` alongside rate limits rather than as a field guaranteed on every
usage read.

## Conclusion

D2 is a server-layer bug, not a GUI bug: the main account is the only account
whose DTO bypasses the merged quota store. Fixing it in the GUI (for example by
reading `/api/codex-auth/quota` separately) would paper over an asymmetry that
also affects any other consumer of the main DTO.
210 changes: 210 additions & 0 deletions devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# 010 — Phase 1: main-account DTO reads the merged quota store

Work phase: `wp1`. Depends on: nothing. Consumed by: `020`.

## Goal

The main account's DTO quota must be the same merged store object a pool
account's DTO quota is, so every field the store carries forward (today
`resetCredits`, tomorrow anything else) reaches the dashboard.

## Scope boundary

IN: `src/codex/auth-api.ts` main DTO construction; a focused test in
`tests/codex-auth-api.test.ts`.
OUT: `src/codex/quota.ts` merge semantics (already correct), the pool path,
any WHAM fetch/refresh policy, credential handling.

## File change map

### `src/codex/auth-api.ts` — `listCodexAuthAccountsSnapshot`, main DTO (~line 1642)

Before:

```ts
quota: mainInfo.quota ? {
...quotaForPlan({
...mainInfo.quota,
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
}, mainInfo.plan),
} : null,
```

After:

```ts
quota: mainInfo.quota ? {
...quotaForPlan(mergeMainQuotaWithStore(mainInfo.quota), mainInfo.plan),
} : null,
```

with a small local helper next to the DTO builders:

```ts
/**
* The main account is the only account whose DTO quota came from the raw parse
* result rather than the merged store, so a resetCredits the store had carried
* forward vanished from the response whenever the current /wham/usage payload
* omitted `rate_limit_reset_credits`. Pool DTOs never had that hole because
* commitPoolQuotaResponse re-reads getAccountQuota() after committing.
*
* Only resetCredits is filled from the store, deliberately. The window fields
* have *clearing* semantics -- a monthly-only snapshot must drop a stale weekly
* value (#382) -- so a blanket spread of the stored object would resurrect a
* window the parse intended to clear whenever the store write was refused by
* generation gating. resetCredits is the one field setAccountQuotaFromParsed
* itself carries forward (quota.ts:339-340), so mirroring exactly that rule
* here keeps the DTO consistent with the store instead of inventing a second,
* looser merge policy.
*/
function mainQuotaWithStoredResetCredits(
parsed: Omit<StoredAccountQuota, "updatedAt">,
): StoredAccountQuota {
const stored = getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
return {
...parsed,
...(parsed.resetCredits === undefined && stored?.resetCredits !== undefined
? { resetCredits: stored.resetCredits }
: {}),
updatedAt: stored?.updatedAt ?? Date.now(),
};
}
```

Call site becomes `quotaForPlan(mainQuotaWithStoredResetCredits(mainInfo.quota), mainInfo.plan)`.

Precedence rationale: a freshly parsed `resetCredits` always wins, including a
deliberate `0` (0 is defined, so it is present in `parsed` and the fill branch
does not run). The store supplies the value only when the parse omitted the key
entirely. Every other field is untouched, so no window-clearing behaviour
changes.

### Audit finding folded in (blocker 1)

The first draft of this document proposed `{ ...stored, ...parsed }`. That is
unsafe: `setAccountQuotaFromParsed` refuses to commit when
`mayCommitAccountQuota` fails generation gating (`quota.ts:280`), so the store
can legitimately hold a PRE-clear snapshot while `parsed` is monthly-only. The
blanket spread would then re-introduce the stale `weeklyPercent` that #382
exists to clear, and it would show up as a phantom weekly bar on the main card.
Narrowing the merge to `resetCredits` removes that failure mode entirely.

### Identity-change safety (corrected — audit blocker 3)

The first two drafts claimed a swapped identity "cannot leak" a previous
account's credits. **That claim was wrong**, and the second reviewer
(muse-spark-1.3-contributor) refuted it with the exact path:

- In-process swaps ARE safe: `reconcileMainCodexAccountRuntimeState`
(`account-lifecycle.ts:60-70`) purges alias-keyed `__main__` quota when it
observes the account id change, and `mainSnapshotLive === false` forces
`EMPTY_MAIN_ACCOUNT_INFO`, whose null quota short-circuits the DTO guard.
- Across a RESTART it is not. `observedMainChatgptAccountId`
(`account-lifecycle.ts:21`) is memory-only, and the first observation after a
restart hits the `previousAccountId === undefined` early return with no purge
(`:67`). If `~/.codex/auth.json` was swapped while the proxy was down, the
disk-hydrated `__main__` quota entry still belongs to the PREVIOUS login, and
Comment on lines +102 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove pre-disclosure identity-leak notes from devlog

These added lines document the exact unreleased cross-account disclosure scenario—swapping auth.json while the proxy is stopped can leave the previous account's alias-keyed quota available after restart—and the following section supplies mitigation details. Because the fix exists only in this reviewed commit, tracking this analysis under public devlog/_plan discloses the weakness before it ships; keep the analysis in .tmp/ and commit only the fix and regression test, or publish a sanitized retrospective under _fin after the fix is public.

AGENTS.md reference: AGENTS.md:L103-L115

Useful? React with 👍 / 👎.

a store-based fill would print its ticket count on the new account's card.
Pool accounts never have this hole because their store key is the account id
itself; `__main__` is an alias.

Fix: do not read the fill value from the store at all. Keep an in-process,
identity-tagged observation of the last parsed count
(`mainResetCreditsProvenance = { accountId, credits }`), recorded in
`fetchMainAccountInfoWhileOwned` next to the existing `freshResetCredits`, and
return it only when `getMainChatgptAccountId()` still matches. A restart simply
starts with no observation, so the badge waits for the first response that
carries the summary rather than showing a stale or foreign number.

```ts
let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;

function mainResetCreditsForCurrentIdentity(): number | undefined {
if (!mainResetCreditsProvenance) return undefined;
const currentAccountId = getMainChatgptAccountId();
if (currentAccountId === null) return undefined;
if (currentAccountId !== mainResetCreditsProvenance.accountId) {
mainResetCreditsProvenance = null;
return undefined;
}
return mainResetCreditsProvenance.credits;
}
```

`updatedAt` still comes from the store, unchanged from today's behaviour.

### Consume-route interaction (audit question Q2c)

`auth-api.ts:2135` deliberately refuses to report a preserved cached
`resetCredits` as the consume response's `remaining`. That governs a
*transactional* claim about a just-executed redeem and is a different guarantee
from best-effort display state, so the DTO carry does not violate it. The real
overlap, recorded rather than fixed: if the forced post-consume refresh omits
the summary, the main card keeps showing the pre-consume count until the next
response that carries it — exactly the staleness every pool card already has.

### Upstream omission semantics (residual, non-blocking)

If upstream ever omits `rate_limit_reset_credits` to MEAN zero, a carried
non-zero would persist until the next explicit reading. Nothing in this
repository settles that question, the risk is pre-existing in the store merge,
and it is shared with every pool card. Named here rather than guessed at.

### quotaForPlan interaction

`quotaForPlan` already forwards `resetCredits` for 30-day plans
(`auth-api.ts:272`) and `withSparkVisibility` only filters `customWindows`,
which this helper does not touch. No change needed in either.

Note on `quotaForPlan`: it already passes `resetCredits` through for 30-day
plans (`auth-api.ts:272`), so no change is needed there.

## Accept criteria

1. Given a main WHAM parse result without `resetCredits` and a store entry for
`__main__` holding `resetCredits: 1`, the main DTO carries `resetCredits: 1`.
2. Given a parse result WITH `resetCredits: 0` and a store entry holding
`resetCredits: 3`, the DTO carries `0` — fresh wins, including zero.
3. Given no store entry, the DTO is byte-identical to today's output.
4. `updatedAt` behaviour is unchanged (store value, else now).
5. Window fields are never taken from the store: a monthly-only parse with a
stored weekly value still produces a DTO without `weeklyPercent`.
6. A carried count is dropped when the physical main account id changes.

### Activation scenario for the conditional path

The new helper's store branch only runs when `getAccountQuota("__main__")`
returns an entry. The test triggers it by calling
`setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { resetCredits: 1 })` before
listing accounts, and proves it ran by asserting `resetCredits` is present in
the returned DTO where it is absent today.

## Verifier

`bun test tests/codex-auth-api.test.ts` — this file already exercises the main
DTO and the `__main__` quota store (it references
`getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits` at lines 2631, 2668,
2706), so it observes the change target directly.

## Field chain (PLAN-FIELD-CHAIN-01)

`resetCredits: number | undefined` is not a new field; this phase changes which
object the DTO reads. Chain for completeness:

- creation: `parseUsageQuota` (`quota.ts:562`) from
`rate_limit_reset_credits.available_count`; also
`updateAccountQuota(..., resetCredits)` (`quota.ts:473`).
- store merge: `setAccountQuotaFromParsed` (`quota.ts:294, 339-340`).
- serialization: `poolAccountDto` (store-read) and the main DTO (this fix).
- deserialization: `hydrateAccountQuotasFromDisk` reads
`codex-quota-cache.json`; N/A for the DTO, which is response-only.
- consumers: `CodexTicketBadge` (`gui/.../codex-account-pool-helpers.tsx:29`),
`src/cli/account-auth.ts`, the reset-credit consume route
(`auth-api.ts:2135`).

## Bypass record (PLAN-BYPASS-NAMED-01)

This phase adds no enforcement. Tier: n/a. Executing surface: n/a. Known bypass:
n/a. Residual risk: a future main DTO rewrite could reintroduce the raw-parse
read; the regression test in criterion 1 is the early warning. Final enforcement
layer: none.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 020 — Phase 2: main card renders the plan badge

Work phase: `wp2`. Depends on: `010` (only for the ticket badge to have data;
the plan badge itself is independent).

## Goal

The main account card shows its plan as a badge, exactly as pool cards do.

## Scope boundary

IN: `gui/src/components/codex-account-pool-main-card.tsx` badge row.
OUT: card layout, the skeleton block, pool card markup, styling changes.

## File change map

### `gui/src/components/codex-account-pool-main-card.tsx` (~line 87)

Inside `<span className="card-badges">`, as the FIRST child — matching the pool
card's order at `codex-account-pool-cards.tsx:91` so both cards read
plan → paused → priority → pinned → ticket → health:

```tsx
{main?.plan && <span className="badge badge-green">{main.plan}</span>}
```

The existing ticket badge line moves after the pinned badge so the two cards
share one badge order. Nothing else in the row changes.

### Skeleton parity (`~line 279`) — decision recorded

The load skeleton reserves a ticket slot and a `badge-primary` slot. The
question was whether the new plan badge needs a matching muted strut.

**Decision: no strut.** The skeleton already omits the priority and pinned
badges that the ready state can render, so approximate width parity is the
established norm for this card rather than a regression introduced here. Adding
a strut for the plan badge alone would make the skeleton wider than the common
ready state (an account with no plan renders no badge at all), trading one
small shift for a different one. Recorded per the audit's blocker 2, which
asked for the strut or a stated reason.

## Accept criteria

1. With `main.plan === "pro"`, the rendered main card contains
`<span class="badge badge-green">pro</span>`.
2. With `main.plan` undefined, no plan badge element is rendered (no empty span).
3. Badge order in the main card matches the pool card.

## Verifier

Rendered-DOM observation on the running dashboard (C-RENDER-GROUNDING-01) plus
`bun run lint:gui` and `bun run typecheck`. There is no existing GUI unit-test
harness for this component, so the DOM observation is the acceptance evidence and
that is recorded rather than claimed as a gate.

## Bypass record

No enforcement added. Final enforcement layer: none; the visual check is human/DOM
observation.
Loading
Loading