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
2 changes: 2 additions & 0 deletions devlog/_plan/260912_accounts/000_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ Two design follow-ups encountered inherited-model capacity errors; one same-hand
## Roadmap cycle outcome

Independent design reflection and A re-audit passed with the source restrictions in 001_roadmap_audit.md. B freezes the contracts as documentation only. C checks document paths/numbering and git whitespace; local product suites NOT RUN. D next direction: execute 010_callback.md independently, then the remaining dependency-ordered cycles. Runtime behavior has not improved yet; the rejected hypotheses were native history identity by sentinel alone, attempt timing inferred from untimed attempts, and one-shot implying one physical request through a retrying primitive.

History P split:048_history_identity.md supplies stable publication identity and fenced writer capture before050 history. This is a new foundation cycle, registered in the same goalplan; intended manual chain history-identity → history → capacity. It is independent of reset-first. Staged login samples are omitted until a fenced post-publication observation; native history remains nondurable and excluded from capacity.
19 changes: 19 additions & 0 deletions devlog/_plan/260912_accounts/048_history_identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Bind quota history to credential publication identity

New foundation cycle history-identity, C4 credential metadata, before history and capacity. Current source saveCodexAccountCredential publishes a new generation, while normal refresh CAS also increments generation and preserves replacedAt. Neither generation equality nor a millisecond timestamp alone establishes durable quota-history continuity. Reuse the credential store and its mutation lock; no new store or token-derived fingerprint.

MODIFY src/types/accounts.ts CodexAccountCredentialRecord: optional private quotaHistoryIdentity UUID, not credential material and never projected to API/CLI. MODIFY src/codex/account-store.ts: every explicit save creates a fresh UUID; saveCodexAccountCredentialIfGeneration and commitRefreshedCodexCredentialWithAliases preserve each record's own UUID, including aliases. Deletes retain no old history identity. Existing credential projection excludes metadata automatically.

Add PoolQuotaWriter type in dependency-free src/codex/quota-types.ts:
```ts
export interface PoolQuotaWriter { accountId: string; credentialGeneration: number; historyIdentity: string }
```
Add capturePoolQuotaWriter(accountId, dispatched:{accessToken,chatgptAccountId,generation}) in account-store.ts. Under existing withCredentialMutationLockSync, read record and require exact dispatched credential and generation, live/nondeleted state. For a legacy/malformed missing UUID initialize one once and persist under that lock without changing credential generation; do not mint on normal reads. A mismatch returns undefined. Lock/persistence failures at this optional evidence boundary return undefined, never fail the request. Credentials remain transient and never enter returned proof. Existing valid UUID capture needs only read matching record, no mutation lock or rewrite; legacy slow path rechecks under lock.

Add isPoolQuotaWriterLive(writer): compare current live record's UUID and generation. Add poolQuotaHistoryIdentity(accountId): read valid current UUID only, never initialize or mutate. These separate append admission from retention, which matches UUID across ordinary refresh. Both are narrow production interfaces for the next history layer, not public management capabilities.

Tests extend existing codex-account-store.test.ts: new saves unique; same-millisecond explicit replacement changes UUID; refresh preserves; alias refresh preserves distinct destination identities; stale dispatched access/generation/account cannot capture; legacy initialization stable and does not advance generation; metadata omitted from getCodexAccountCredential/load compatibility projection; delete/recreate invalidates old writer. Local tests/build/typecheck/install NOT RUN. Hosted cumulative history/capacity tip verifies these regression sources. Source security review separate from runtime proof.

Field chain: explicit save/legacy capture creates UUID → existing atomic credential record serialization → existing read with UUID validity checked at history boundary → capture/live/retention helpers → next cycle's auth-context/WHAM/header history admission. All explicit record reconstructions are enumerated: save at161, validation spreads186/234 preserve, refresh279/338 preserve, alias366 preserves its own, deletion387 drops. Source ownership docs updated with private metadata semantics. No credential/token/string values enter docs or log output.

A implementation checks accepted: legacy tag init uses plain persist, preserving both generation and credentialMutationEpoch. UUID validation stays at history boundary; malformed optional metadata never discards usable credentials. Catch read/hardening failures as well as lock/write failures and return no optional proof. Capture excludes the reserved native-main sentinel. If a CAS caller supplies a different upstream account identity, rotate the history UUID instead of treating that as ordinary same-account refresh.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Quota history identity foundation

Adds a private random publication UUID to pool credential records. Explicit saves rotate it, refresh CAS preserves it for the same upstream account, and aliases retain distinct identities. Captured writer proofs require exact dispatched credential generation and access/account pairing; legacy identity initialization occurs under the existing lock without changing the credential generation or mutation epoch. Read/lock/write failure yields no optional proof. Metadata never enters credential-only projection.

Regression sources cover refresh versus same-time replacement, aliases, deletion/recreation, legacy stable initialization, stale capture, malformed metadata, secret-free proof and identity-changing CAS. The latter rotates owner history and does not propagate the new identity into old aliases. No new test file/dependency. Local suites/build/typecheck/install NOT RUN. Source checks are not runtime proof; hosted final cumulative history/capacity tip remains required.

Structural decision: proof type stays in quota-types.ts (type-only), credential record/lock/persistence stay in account-store.ts, future pure history leaf consumes plain evidence. Rejected generation-only retention because ordinary refresh increments it; rejected timestamp identity because publication can share a millisecond. The small foundation is the first ordinary manual-chain PR, then history, then capacity. No merge.
4 changes: 3 additions & 1 deletion devlog/_plan/260912_accounts/050_history.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ Field chain: creation is guarded quota commit; serialization is existing atomic

Reflection REF-04: fixed aggregate bounds: 64 account identities, 4096 rows, 2 MiB serialized history payload and 4 MiB whole cache read bound. During append/hydrate evict oldest observed rows, tie-break account key; prune accounts absent from authoritative roster. Never include dynamic raw account identities in logs. History retains actual per-window provenance (response-header or WHAM where available), reset boundary and window family; partial inherited values do not count. Overlarge/malformed cache read fails to empty history without blocking newest quota. Tests include many-account overflow, byte overflow, deterministic ties and remove/restart.

A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind stable configured account identity and stored credential generation, pruning mismatches on hydrate. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts.
A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind a stable private publication UUID; hydration prunes identity mismatches, while ordinary generation changes on refresh retain prior observations. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts.

P refinement depends on new048 history-identity cycle. Adopt HIST-01..06: generation gates each physical sample; private random publication UUID persists through refresh and changes on explicit save. Capture PoolQuotaWriter before upstream calls, refresh it after replay token resolution, and forward through every WHAM/WS/HTTP/compact/warmup path. Omit staged login/reauth samples until first post-publication fenced observation; do not retrofit ambient provenance. Native main is excluded from durable endpoint/capacity in this slice. Raw QuotaObservation carries observedAt, wham|response-header source, bounded windows with account|spark family and short|weekly|monthly name, percentage/resetAtMs/duration/primary provenance; no arbitrary upstream label. Envelope private identity binds samples but is omitted from read DTO. Retain best-effort single-writer atomic cache semantics; no multi-process merge/durability claim. Read endpoint GET /api/codex-auth/quota/history?accountId=<poolid>&limit=<1..200>; CLI ocx account history openai <poolid> [--limit N] [--json]. Unknown/deleted404, invalid/duplicate selector400, emptyhistory200. No upstream call on reads.
2 changes: 2 additions & 0 deletions devlog/_plan/260912_accounts/060_capacity.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ export type CodexCapacityEstimate = {
MODIFY history read API/CLI projection to attach per-window estimates with sample count and caveat; expose an existing account-card detail surface only if it can be honestly rendered and verified. Field chain: pure estimator creation; API JSON serialization; existing typed CLI/client deserialization; explicit informational display consumers. No persisted estimate schema needed. Tests feed independently hand-calculated intervals, 0% delta, reset rollover, missing timestamps/identity, cross-account records, retries, estimated usage, and extreme numeric input. Sync quota/usage ownership docs and user configuration guidance. Full closure of #3376 requires both history and meaningful capacity; reset-first alone stays partial. Local suites NOT RUN; hosted final cumulative tip is the verifier.

A2 accepted: use readUsageSnapshotForManagement; if truncatedPrefixBytes>0, entriesTruncated, entriesDropped>0, missing revision, or invalid timing then return insufficient-evidence with no estimate. Treat each request as interval [timestamp, timestamp+durationMs] (request-log.ts:1039/1072); include only requests wholly contained in a quota-observation interval. Boundary-spanning requests contribute nothing. For included requests count reported physical attempts matching the exact pool label once; do not count both request total and attempts. Without attempts accept request-level reported usage only with matching label and no recovery ambiguity. Native main is excluded from token capacity because its historical label cannot establish identity after replacement. Current pool logLabel must be unique; legacy fallback labels/id reuse require insufficient evidence unless continuity is proven by history generation. Same-reset positive deltas only. Hand-worked boundary-spanning, truncation, missing identity and retry rows are mandatory regression fixtures.

P future refinement from history sidecar: do not call estimate a mathematical lower bound. It is an observed effective token estimate under rounded/delayed quota and local coverage assumptions. Admit only single-send reported nonestimated attempts; present-but-empty attempt arrays cannot fall back to parent totals. Deduplicate requestId+ordinal and reject conflicting duplicates. Use interval (left,right] with whole request containment to avoid zero-duration double counting. Existing parser can skip malformed rows without a rejected counter: report retained-valid-ledger-only assumption explicitly or add rejected-row metadata before claiming complete coverage. Loglabel alone is not history identity; history publication UUID and current stable unique configured label must bind sample period. All source tests remain hosted-only.
78 changes: 78 additions & 0 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
import { isValidCodexAccountId } from "./account-id";
import type { PoolQuotaWriter } from "./quota-types";
import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";

type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
Expand Down Expand Up @@ -163,6 +165,7 @@ export function saveCodexAccountCredential(
generation: (current?.generation ?? 0) + 1,
refreshGrantFingerprint,
replacedAt: current ? Date.now() : undefined,
quotaHistoryIdentity: crypto.randomUUID(),
...preservedValidationMetadata(current),
...(options.validationPending ? {
codexValidationPending: true,
Expand Down Expand Up @@ -257,6 +260,75 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord
return loadCodexAccountRecordStore()[id] ?? null;
}

const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

function validQuotaHistoryIdentity(value: unknown): value is string {
return typeof value === "string" && QUOTA_HISTORY_IDENTITY_RE.test(value);
}

type DispatchedPoolCredential = Pick<CodexAccountCredentials, "accessToken" | "chatgptAccountId"> & { generation: number };

function matchesDispatchedPoolCredential(record: CodexAccountCredentialRecord | undefined | null, dispatched: DispatchedPoolCredential): record is CodexAccountCredentialRecord & { credential: CodexAccountCredentials } {
return !!record?.credential && record.deletedAt == null
&& dispatched.accessToken.length > 0 && dispatched.chatgptAccountId.length > 0
&& Number.isSafeInteger(dispatched.generation) && dispatched.generation >= 0
&& record.generation === dispatched.generation
&& record.credential.accessToken === dispatched.accessToken
&& record.credential.chatgptAccountId === dispatched.chatgptAccountId;
}

/** Optional evidence capture; a stale credential or unavailable store never gains a new writer. */
export function capturePoolQuotaWriter(accountId: string, dispatched: DispatchedPoolCredential): PoolQuotaWriter | undefined {
if (!isValidCodexAccountId(accountId)) return undefined;
try {
const current = readCodexAccountRecord(accountId);
if (!matchesDispatchedPoolCredential(current, dispatched)) return undefined;
if (validQuotaHistoryIdentity(current.quotaHistoryIdentity)) {
return { accountId, credentialGeneration: dispatched.generation, historyIdentity: current.quotaHistoryIdentity };
}
return withCredentialMutationLockSync(() => {
const store = loadCodexAccountRecordStore();
const locked = store[accountId];
if (!matchesDispatchedPoolCredential(locked, dispatched)) return undefined;
if (!validQuotaHistoryIdentity(locked.quotaHistoryIdentity)) {
locked.quotaHistoryIdentity = crypto.randomUUID();
// Identity metadata is not a new credential; preserve generation and mutation epoch.
persist(store);
}
return { accountId, credentialGeneration: dispatched.generation, historyIdentity: locked.quotaHistoryIdentity };
});
} catch {
// History is optional evidence. Permission, lock and disk errors cannot fail inference.
return undefined;
}
}

/** Read-only retention identity; unlike capture this never initializes legacy metadata. */
export function poolQuotaHistoryIdentity(accountId: string): string | undefined {
if (!isValidCodexAccountId(accountId)) return undefined;
try {
const record = readCodexAccountRecord(accountId);
return record?.credential && record.deletedAt == null && validQuotaHistoryIdentity(record.quotaHistoryIdentity)
? record.quotaHistoryIdentity : undefined;
} catch {
return undefined;
}
}

/** Recheck append admission after upstream I/O; refresh may retire a writer without erasing history. */
export function isPoolQuotaWriterLive(writer: PoolQuotaWriter): boolean {
if (!isValidCodexAccountId(writer.accountId)) return false;
try {
const record = readCodexAccountRecord(writer.accountId);
return !!record?.credential && record.deletedAt == null
&& record.generation === writer.credentialGeneration
&& validQuotaHistoryIdentity(writer.historyIdentity)
&& record.quotaHistoryIdentity === writer.historyIdentity;
} catch {
return false;
}
}

export function isCodexAccountGenerationLive(id: string, generation: number): boolean {
const record = readCodexAccountRecord(id);
return !!record?.credential && record.deletedAt == null && record.generation === generation;
Expand All @@ -281,6 +353,8 @@ export function saveCodexAccountCredentialIfGeneration(
generation: generation + 1,
refreshGrantFingerprint,
replacedAt: current.replacedAt,
quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
? current.quotaHistoryIdentity : crypto.randomUUID(),
...preservedValidationMetadata(current),
};
persistCredentialMutation(store);
Expand Down Expand Up @@ -340,6 +414,8 @@ export function commitRefreshedCodexCredentialWithAliases(
generation: generation + 1,
refreshGrantFingerprint,
replacedAt: current.replacedAt,
quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
? current.quotaHistoryIdentity : crypto.randomUUID(),
...preservedValidationMetadata(current),
};

Expand All @@ -354,6 +430,7 @@ export function commitRefreshedCodexCredentialWithAliases(
priorFingerprint !== undefined
&& priorCredential.refreshToken !== cred.refreshToken
&& !!priorCredential.chatgptAccountId
&& priorCredential.chatgptAccountId === cred.chatgptAccountId
) {
for (const [aliasId, alias] of Object.entries(store)) {
if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
Expand All @@ -369,6 +446,7 @@ export function commitRefreshedCodexCredentialWithAliases(
generation: aliasGeneration,
refreshGrantFingerprint,
replacedAt: alias.replacedAt,
quotaHistoryIdentity: alias.quotaHistoryIdentity,
...preservedValidationMetadata(alias),
};
propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
Expand Down
8 changes: 8 additions & 0 deletions src/codex/quota-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,11 @@ export type WhamUsageResponse = {
rate_limit_reset_credits?: { available_count: number } | null;
additional_rate_limits?: WhamAdditionalRateLimit[] | null;
};


/** Captured from the exact dispatched pool credential; never a management API field. */
export interface PoolQuotaWriter {
accountId: string;
credentialGeneration: number;
historyIdentity: string;
}
2 changes: 2 additions & 0 deletions src/types/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface CodexAccountCredentialRecord {
credential?: CodexAccountCredentials;
generation: number;
refreshGrantFingerprint?: string;
/** Private non-secret publication identity, stable across same-account token refresh. */
quotaHistoryIdentity?: string;
deletedAt?: number;
replacedAt?: number;
lastCodexValidatedAt?: number;
Expand Down
2 changes: 2 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c
privately to final dispatch; preliminary route selection does not inject Go-only headers.

Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate.

Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it.
Loading
Loading