diff --git a/devlog/_plan/260912_accounts/000_plan.md b/devlog/_plan/260912_accounts/000_plan.md index df54929c99..ed3e4574c1 100644 --- a/devlog/_plan/260912_accounts/000_plan.md +++ b/devlog/_plan/260912_accounts/000_plan.md @@ -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. diff --git a/devlog/_plan/260912_accounts/048_history_identity.md b/devlog/_plan/260912_accounts/048_history_identity.md new file mode 100644 index 0000000000..d8273fab4e --- /dev/null +++ b/devlog/_plan/260912_accounts/048_history_identity.md @@ -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. diff --git a/devlog/_plan/260912_accounts/049_history_identity_delivery.md b/devlog/_plan/260912_accounts/049_history_identity_delivery.md new file mode 100644 index 0000000000..6af02c6026 --- /dev/null +++ b/devlog/_plan/260912_accounts/049_history_identity_delivery.md @@ -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. diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md index fab9e603be..dd96c9019e 100644 --- a/devlog/_plan/260912_accounts/050_history.md +++ b/devlog/_plan/260912_accounts/050_history.md @@ -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=&limit=<1..200>; CLI ocx account history openai [--limit N] [--json]. Unknown/deleted404, invalid/duplicate selector400, emptyhistory200. No upstream call on reads. diff --git a/devlog/_plan/260912_accounts/060_capacity.md b/devlog/_plan/260912_accounts/060_capacity.md index 931e0b6646..f4c5acb023 100644 --- a/devlog/_plan/260912_accounts/060_capacity.md +++ b/devlog/_plan/260912_accounts/060_capacity.md @@ -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. diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 285db1708c..4b151707a1 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -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; @@ -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, @@ -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 & { 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; @@ -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); @@ -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), }; @@ -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; @@ -369,6 +446,7 @@ export function commitRefreshedCodexCredentialWithAliases( generation: aliasGeneration, refreshGrantFingerprint, replacedAt: alias.replacedAt, + quotaHistoryIdentity: alias.quotaHistoryIdentity, ...preservedValidationMetadata(alias), }; propagatedAliases.push({ id: aliasId, generation: aliasGeneration }); diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index 6c06de6ae9..cc73a7d5e3 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -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; +} diff --git a/src/types/accounts.ts b/src/types/accounts.ts index 6b60c76f68..3d2ba914ec 100644 --- a/src/types/accounts.ts +++ b/src/types/accounts.ts @@ -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; diff --git a/structure/catalog.md b/structure/catalog.md index 68fb9b9bb3..0da8c2a4ca 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -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. diff --git a/structure/codex-home.md b/structure/codex-home.md index a25eb8d326..4fd4ca328c 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -241,3 +241,5 @@ A config restoration that was attempted and failed retains its failed artifact i result; unattempted catalog and history artifacts remain skipped. Successful preimage compensation preserves config/profile/journal bytes without relabeling the failure as a skipped operation. Incomplete compensation still raises the explicit partial-write error. + +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. diff --git a/structure/config.md b/structure/config.md index abafd78404..7aaa8e16c9 100644 --- a/structure/config.md +++ b/structure/config.md @@ -216,6 +216,8 @@ described in [Responses transport](transports/responses.md), not upstream policy `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +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. + The Cline client keeps connection settings and models in a separate native file pair; client path overrides and reversible writes follow [Cline paired files](clients/integrations.md#cline-paired-files). `claudeCode.stabilizePromptCache` is a default-off operator setting for diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 47bf09b643..750d31dec4 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -575,6 +575,7 @@ privately to final dispatch; preliminary route selection does not inject Go-only The connected browser shell reuses `SESSION_UNAVAILABLE_EVENT` and its shared-session readiness state. Terminal 401 recovery failure exposes pairing without a restart instruction; a newer session or aborted request cannot publish an unavailable notice. Successful pairing changes dashboard resource revalidation dependencies, so retained failed stores are explicitly refreshed. Dashboard reads distinguish authentication, permission denial, request failure, invalid payload and transport failure; protected data is hidden for authentication/denial, while other failed refreshes label retained data as stale. +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. Cline journal Undo eligibility reads both native configuration files through the paired integration IO adapter. Its snapshot fingerprint cannot be checked against providers.json alone; [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a4275374cd..a11c41481f 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -329,6 +329,7 @@ Provider configuration documents distinguish actual summaries from raw reasoning Hub/browser pairing instructions distinguish machine enrollment, session authentication, permission denial and network failure. The hosted dashboard preview is the render artifact used to review these states. +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. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 9bac0dc9b5..7576afeb64 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -474,3 +474,9 @@ separately, nothing is dispatched upstream after either, and notes writes are ne Context relay dispatch rechecks the native experimental opt-in after body and credential waits. A disabled gate prevents upstream dispatch even when the request entered while enabled. Final materialized headers pass the proxy-credential exclusion check before owner matching. + +## Quota history publication identity + +`src/codex/account-store.ts` assigns each explicit pool credential publication a private random `quotaHistoryIdentity`. Same-account token refresh preserves it, including each alias record's own identity; replacement or deletion retires it. A refresh CAS with a changed upstream account identity rotates the tag and does not propagate that changed identity to old aliases. Credential-only projections omit this metadata. + +`capturePoolQuotaWriter` captures the exact dispatched access/account pair and generation. Legacy identity initialization rechecks under the credential mutation lock, persists metadata without advancing credential generation or mutation epoch, and fails to no optional evidence on read/lock/write errors. Append admission uses the captured generation and tag; history retention compares the tag across ordinary refresh. Native main is excluded from this pool proof. These interfaces supply the bounded observation layer; the identity alone is neither a quota sample nor proof of capacity. diff --git a/structure/runtime.md b/structure/runtime.md index 6cec43426f..539285eefe 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -257,6 +257,8 @@ Responses route normalization resolves provider summary defaults from the origin Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +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. + Cline CLI joins the existing export/client integration registries. Explicit CLI sync and POST /api/sync refresh its owned pair; unattended catalog refresh excludes it. See [Cline paired files](clients/integrations.md#cline-paired-files). `claudeCode.stabilizePromptCache` is a default-off operator setting for diff --git a/structure/subagents.md b/structure/subagents.md index cea31404e5..c146202ade 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -265,5 +265,6 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - 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. diff --git a/tests/codex-integration/codex-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index 77a7750d14..e44ce355e0 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -80,6 +80,90 @@ describe("codex-account-store CRUD", () => { expect(store.readCodexAccountRecord("pending")?.lastCodexValidationStatus).toBe("ok"); }); + test("quota history identity survives refresh but explicit publication retires the writer", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "history-access", refreshToken: "history-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "history-account" }; + const generation = store.saveCodexAccountCredential("history", credential); + const writer = store.capturePoolQuotaWriter("history", { ...credential, generation })!; + expect(writer.historyIdentity).toMatch(/^[a-f0-9-]{36}$/); + expect(store.isPoolQuotaWriterLive(writer)).toBe(true); + const refreshed = { ...credential, accessToken: "history-refreshed-access", refreshToken: "history-refreshed-grant" }; + expect(store.saveCodexAccountCredentialIfGeneration("history", generation, refreshed)).toBe(true); + expect(store.poolQuotaHistoryIdentity("history")).toBe(writer.historyIdentity); + expect(store.isPoolQuotaWriterLive(writer)).toBe(false); + const refreshedWriter = store.capturePoolQuotaWriter("history", { ...refreshed, generation: generation + 1 })!; + expect(refreshedWriter.historyIdentity).toBe(writer.historyIdentity); + expect(store.getCodexAccountCredential("history")).toEqual(refreshed); + const clock = spyOn(Date, "now").mockReturnValue(1_800_000_000_000); + try { + store.saveCodexAccountCredential("history", refreshed); + const replaced = store.poolQuotaHistoryIdentity("history"); + expect(replaced).not.toBe(writer.historyIdentity); + store.saveCodexAccountCredential("history", refreshed); + expect(store.poolQuotaHistoryIdentity("history")).not.toBe(replaced); + } finally { clock.mockRestore(); } + expect(store.isPoolQuotaWriterLive(refreshedWriter)).toBe(false); + }); + + test("quota history aliases retain distinct publication identities through refresh", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "alias-access", refreshToken: "alias-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "alias-account" }; + const generation = store.saveCodexAccountCredential("owner", credential); + store.saveCodexAccountCredential("alias", credential); + const ownerIdentity = store.poolQuotaHistoryIdentity("owner"); + const aliasIdentity = store.poolQuotaHistoryIdentity("alias"); + expect(ownerIdentity).not.toBe(aliasIdentity); + const refreshed = { ...credential, accessToken: "alias-refreshed", refreshToken: "alias-new-refresh" }; + expect(store.commitRefreshedCodexCredentialWithAliases("owner", generation, refreshed).committed).toBe(true); + expect(store.poolQuotaHistoryIdentity("owner")).toBe(ownerIdentity); + expect(store.poolQuotaHistoryIdentity("alias")).toBe(aliasIdentity); + store.removeCodexAccountCredential("alias"); + expect(store.poolQuotaHistoryIdentity("alias")).toBeUndefined(); + store.saveCodexAccountCredential("alias", refreshed); + expect(store.poolQuotaHistoryIdentity("alias")).not.toBe(aliasIdentity); + }); + + test("legacy history identity initializes once without advancing credential generation or epoch", async () => { + const store = await import("../../src/codex/account-store"); + const { codexCredentialMutationEpoch } = await import("../../src/codex/credential-mutation-epoch"); + const credential = { accessToken: "legacy-history-access", refreshToken: "legacy-history-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "legacy-history-account" }; + writeFileSync(ACCOUNTS_PATH, JSON.stringify({ legacy: credential })); + const epoch = codexCredentialMutationEpoch(); + expect(store.poolQuotaHistoryIdentity("legacy")).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, generation: 1 })).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, accessToken: "wrong", generation: 0 })).toBeUndefined(); + expect(store.capturePoolQuotaWriter("legacy", { ...credential, chatgptAccountId: "wrong", generation: 0 })).toBeUndefined(); + const writer = store.capturePoolQuotaWriter("legacy", { ...credential, generation: 0 })!; + expect(store.capturePoolQuotaWriter("legacy", { ...credential, generation: 0 })).toEqual(writer); + expect(store.readCodexAccountRecord("legacy")?.generation).toBe(0); + expect(codexCredentialMutationEpoch()).toBe(epoch); + expect(store.loadCodexAccountStore()).toEqual({ legacy: credential }); + expect(JSON.stringify(writer)).not.toContain(credential.accessToken); + expect(JSON.stringify(writer)).not.toContain(credential.refreshToken); + expect(store.capturePoolQuotaWriter("__main__", { ...credential, generation: 0 })).toBeUndefined(); + }); + + test("malformed optional history metadata cannot discard an otherwise usable credential", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "metadata-access", refreshToken: "metadata-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "metadata-account" }; + writeFileSync(ACCOUNTS_PATH, JSON.stringify({ metadata: { credential, generation: 3, quotaHistoryIdentity: 42 } })); + expect(store.getCodexAccountCredential("metadata")).toEqual(credential); + expect(store.poolQuotaHistoryIdentity("metadata")).toBeUndefined(); + expect(store.capturePoolQuotaWriter("metadata", { ...credential, generation: 3 })?.historyIdentity).toMatch(/^[a-f0-9-]{36}$/); + }); + + test("an identity-changing CAS does not retain history or propagate credentials to old aliases", async () => { + const store = await import("../../src/codex/account-store"); + const credential = { accessToken: "old-account-access", refreshToken: "shared-old-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "old-account" }; + const generation = store.saveCodexAccountCredential("owner", credential); + store.saveCodexAccountCredential("alias", credential); + const identity = store.poolQuotaHistoryIdentity("owner"); + const result = store.commitRefreshedCodexCredentialWithAliases("owner", generation, { ...credential, accessToken: "new-account-access", refreshToken: "new-refresh", chatgptAccountId: "new-account" }); + expect(result).toMatchObject({ committed: true, propagatedAliases: [] }); + expect(store.poolQuotaHistoryIdentity("owner")).not.toBe(identity); + expect(store.getCodexAccountCredential("alias")).toEqual(credential); + }); + test("save and load credential round-trip", async () => { const { saveCodexAccountCredential, getCodexAccountCredential } = await import("../../src/codex/account-store"); const cred = { accessToken: "tk_a", refreshToken: "rf_a", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acc_a" };