-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(usage): add opt-in usage.jsonl byte ceiling #3635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,6 +120,7 @@ import { | |
| type RequestLogEntry, | ||
| } from "./request-log"; | ||
| import { sessionLaneIdFromRequest } from "./request-log-conversation"; | ||
| import { enforceUsageLedgerRetention } from "../usage/ledger-retention"; | ||
| export { | ||
| addFinalRequestLog, | ||
| filterRequestLogs, | ||
|
|
@@ -740,6 +741,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W | |
| // purpose: a lazy "arm on first save" loses exactly the hand edit made before that | ||
| // first save, which is the case the guard exists for. | ||
| armClaudeCodeBaseline(config); | ||
| // Opt-in usage.jsonl ceiling runs BEFORE log hydration so a multi-GB ledger is | ||
| // trimmed once at startup instead of being parsed and then rewritten. | ||
| try { enforceUsageLedgerRetention(); } catch { /* retention must not block listen */ } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Report retention enforcement failures. If an enabled retention policy cannot read, rewrite, or reset the derived index, both sites suppress the error. Startup then hydrates the original ledger, and later appends continue without enforcing the configured cap.
Keep the failure non-fatal to the listener and request path. 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| // usage.jsonl already persists every request; rehydrate the in-memory Logs ring so | ||
| // /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts. | ||
| hydrateRequestLogsFromDisk(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -178,6 +178,20 @@ export interface StorageCleanupPolicy { | |
| nextRun?: number; | ||
| } | ||
|
|
||
| /** | ||
| * Opt-in byte ceiling for the canonical `usage.jsonl` ledger. | ||
| * Persisted under `OcxConfig.usageLedgerRetention`. Default `enabled: false`. | ||
| * When enabled, older JSONL rows are dropped permanently so the file stays | ||
| * within `maxBytes`. The derived `routing-history.sqlite` index is deleted | ||
| * after a rewrite and rebuilt on the next open. | ||
| */ | ||
| export interface UsageLedgerRetention { | ||
| /** When false/unset, the ledger is never rewritten. Default false. */ | ||
| enabled: boolean; | ||
| /** Keep the newest complete JSONL rows within this many bytes. Floor 1 MiB. */ | ||
| maxBytes: number; | ||
|
Comment on lines
+188
to
+192
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Use a separate persisted type for
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */ | ||
| export interface OcxCustomModel { | ||
| /** 고유 ID (crypto.randomUUID()) */ | ||
|
|
@@ -666,6 +680,11 @@ export interface OcxConfig { | |
| * See `src/storage/policy.ts`. | ||
| */ | ||
| storageCleanupPolicy?: StorageCleanupPolicy; | ||
| /** | ||
| * Opt-in cap for `usage.jsonl` (and its disposable SQLite projection). | ||
| * Default OFF. Never enabled implicitly. | ||
| */ | ||
| usageLedgerRetention?: UsageLedgerRetention; | ||
| /** Generated API keys for external access to the proxy's /v1/responses endpoint. */ | ||
| apiKeys?: OcxApiKeyEntry[]; | ||
| /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,170 @@ | ||||||||||||||
| /** | ||||||||||||||
| * Opt-in byte ceiling for the canonical `usage.jsonl` ledger. | ||||||||||||||
| * | ||||||||||||||
| * Default OFF (`enabled` false / unset). The request-history indexer never | ||||||||||||||
| * truncates `usage.jsonl`; this module is the only writer allowed to rewrite | ||||||||||||||
| * it, and only when the operator enabled a ceiling. After a rewrite the | ||||||||||||||
| * derived `routing-history.sqlite` index is deleted so the next open rebuilds | ||||||||||||||
| * from the retained tail (ADR-1/ADR-8: the index is disposable). | ||||||||||||||
| * | ||||||||||||||
| * Older rows are dropped permanently. There is no quarantine copy: the ledger | ||||||||||||||
| * can be many gigabytes, and duplicating it would defeat the cap. | ||||||||||||||
| */ | ||||||||||||||
| import { | ||||||||||||||
| chmodSync, | ||||||||||||||
| closeSync, | ||||||||||||||
| existsSync, | ||||||||||||||
| fsyncSync, | ||||||||||||||
| openSync, | ||||||||||||||
| readSync, | ||||||||||||||
| renameSync, | ||||||||||||||
| statSync, | ||||||||||||||
| unlinkSync, | ||||||||||||||
| writeSync, | ||||||||||||||
| } from "node:fs"; | ||||||||||||||
| import { loadConfig } from "../config"; | ||||||||||||||
| import { getConfigDir } from "../config/paths"; | ||||||||||||||
| import { historyIndexPath } from "../routing/history/schema"; | ||||||||||||||
| import type { UsageLedgerRetention } from "../types/config"; | ||||||||||||||
|
|
||||||||||||||
| export const DEFAULT_USAGE_LEDGER_MAX_BYTES = 512 * 1024 * 1024; | ||||||||||||||
| export const MIN_USAGE_LEDGER_MAX_BYTES = 1024 * 1024; | ||||||||||||||
| export const USAGE_LEDGER_FILENAME = "usage.jsonl"; | ||||||||||||||
|
|
||||||||||||||
| const COPY_CHUNK_BYTES = 1024 * 1024; | ||||||||||||||
| const NEWLINE_PROBE_BYTES = 64 * 1024; | ||||||||||||||
|
|
||||||||||||||
| export type UsageLedgerRetentionSkip = | ||||||||||||||
| | "disabled" | ||||||||||||||
| | "missing" | ||||||||||||||
| | "under_limit"; | ||||||||||||||
|
|
||||||||||||||
| export interface UsageLedgerCompactResult { | ||||||||||||||
| skipped?: UsageLedgerRetentionSkip; | ||||||||||||||
| beforeBytes: number; | ||||||||||||||
| afterBytes: number; | ||||||||||||||
| droppedBytes: number; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export function defaultUsageLedgerRetention(): UsageLedgerRetention { | ||||||||||||||
| return { | ||||||||||||||
| enabled: false, | ||||||||||||||
| maxBytes: DEFAULT_USAGE_LEDGER_MAX_BYTES, | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export function normalizeUsageLedgerRetention(raw: unknown): UsageLedgerRetention { | ||||||||||||||
| const base = defaultUsageLedgerRetention(); | ||||||||||||||
| if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base; | ||||||||||||||
| const o = raw as Record<string, unknown>; | ||||||||||||||
| const enabled = o.enabled === true; | ||||||||||||||
| let maxBytes = base.maxBytes; | ||||||||||||||
| if (typeof o.maxBytes === "number" && Number.isFinite(o.maxBytes) && Math.floor(o.maxBytes) === o.maxBytes) { | ||||||||||||||
| maxBytes = Math.max(MIN_USAGE_LEDGER_MAX_BYTES, o.maxBytes); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+62
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject unsafe Line 62 accepts Use Proposed fix- if (typeof o.maxBytes === "number" && Number.isFinite(o.maxBytes) && Math.floor(o.maxBytes) === o.maxBytes) {
+ if (typeof o.maxBytes === "number" && Number.isSafeInteger(o.maxBytes)) {
maxBytes = Math.max(MIN_USAGE_LEDGER_MAX_BYTES, o.maxBytes);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| return { enabled, maxBytes }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export function usageLedgerPath(configDir?: string): string { | ||||||||||||||
| const dir = (configDir ?? getConfigDir()).replace(/[\\/]+$/, ""); | ||||||||||||||
| return `${dir}/${USAGE_LEDGER_FILENAME}`; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export function discardHistoryIndex(configDir?: string): void { | ||||||||||||||
| const db = historyIndexPath(configDir ?? getConfigDir()); | ||||||||||||||
| for (const path of [db, `${db}-wal`, `${db}-shm`]) { | ||||||||||||||
| try { | ||||||||||||||
| unlinkSync(path); | ||||||||||||||
| } catch { | ||||||||||||||
| /* absent is the success case */ | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Keep the newest complete JSONL rows whose bytes fit in `maxBytes`. | ||||||||||||||
| * No-op when the file is missing or already within the ceiling. | ||||||||||||||
| */ | ||||||||||||||
| export function compactUsageLedgerToMaxBytes( | ||||||||||||||
| path: string, | ||||||||||||||
| maxBytes: number, | ||||||||||||||
| ): UsageLedgerCompactResult { | ||||||||||||||
| if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { | ||||||||||||||
| throw new RangeError("usage ledger maxBytes must be a positive integer"); | ||||||||||||||
| } | ||||||||||||||
| if (!existsSync(path)) { | ||||||||||||||
| return { skipped: "missing", beforeBytes: 0, afterBytes: 0, droppedBytes: 0 }; | ||||||||||||||
| } | ||||||||||||||
| const beforeBytes = statSync(path).size; | ||||||||||||||
| if (beforeBytes <= maxBytes) { | ||||||||||||||
| return { skipped: "under_limit", beforeBytes, afterBytes: beforeBytes, droppedBytes: 0 }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const fd = openSync(path, "r"); | ||||||||||||||
| try { | ||||||||||||||
| let start = beforeBytes - maxBytes; | ||||||||||||||
| if (start > 0) { | ||||||||||||||
| const probeLen = Math.min(NEWLINE_PROBE_BYTES, beforeBytes - start); | ||||||||||||||
| const probe = Buffer.alloc(probeLen); | ||||||||||||||
| const n = readSync(fd, probe, 0, probeLen, start); | ||||||||||||||
| const nl = probe.subarray(0, n).indexOf(0x0a); | ||||||||||||||
| // Drop the possibly-partial first row. If this window has no newline, | ||||||||||||||
| // keep the raw tail rather than deleting the whole ledger. | ||||||||||||||
| if (nl >= 0) start = start + nl + 1; | ||||||||||||||
|
Comment on lines
+110
to
+113
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Keep only complete JSONL rows. At 🤖 Prompt for AI Agents |
||||||||||||||
| } else { | ||||||||||||||
| start = 0; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const tmp = `${path}.tmp-retention`; | ||||||||||||||
| const out = openSync(tmp, "w", 0o600); | ||||||||||||||
| try { | ||||||||||||||
| const buf = Buffer.alloc(COPY_CHUNK_BYTES); | ||||||||||||||
| let pos = start; | ||||||||||||||
| while (pos < beforeBytes) { | ||||||||||||||
| const n = readSync(fd, buf, 0, buf.length, pos); | ||||||||||||||
| if (n <= 0) break; | ||||||||||||||
| writeSync(out, buf, 0, n); | ||||||||||||||
| pos += n; | ||||||||||||||
| } | ||||||||||||||
| fsyncSync(out); | ||||||||||||||
| } finally { | ||||||||||||||
| closeSync(out); | ||||||||||||||
| } | ||||||||||||||
| renameSync(tmp, path); | ||||||||||||||
| try { chmodSync(path, 0o600); } catch { /* best-effort */ } | ||||||||||||||
| } finally { | ||||||||||||||
| closeSync(fd); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const afterBytes = existsSync(path) ? statSync(path).size : 0; | ||||||||||||||
| return { | ||||||||||||||
| beforeBytes, | ||||||||||||||
| afterBytes, | ||||||||||||||
| droppedBytes: Math.max(0, beforeBytes - afterBytes), | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| let cachedPolicy: UsageLedgerRetention | null = null; | ||||||||||||||
|
|
||||||||||||||
| export function resetUsageLedgerRetentionCacheForTests(): void { | ||||||||||||||
| cachedPolicy = null; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function currentPolicy(): UsageLedgerRetention { | ||||||||||||||
| if (cachedPolicy) return cachedPolicy; | ||||||||||||||
| cachedPolicy = normalizeUsageLedgerRetention(loadConfig().usageLedgerRetention); | ||||||||||||||
| return cachedPolicy; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** Startup / post-append hook. Never throws to the request path. */ | ||||||||||||||
| export function enforceUsageLedgerRetention(configDir?: string): UsageLedgerCompactResult { | ||||||||||||||
| const dir = configDir ?? getConfigDir(); | ||||||||||||||
| const policy = currentPolicy(); | ||||||||||||||
| const path = usageLedgerPath(dir); | ||||||||||||||
| if (!policy.enabled) { | ||||||||||||||
| return { skipped: "disabled", beforeBytes: 0, afterBytes: 0, droppedBytes: 0 }; | ||||||||||||||
| } | ||||||||||||||
| const result = compactUsageLedgerToMaxBytes(path, policy.maxBytes); | ||||||||||||||
| if (!result.skipped) discardHistoryIndex(dir); | ||||||||||||||
|
Comment on lines
+164
to
+168
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Close and rebuild the history index during ledger compaction. When 🤖 Prompt for AI Agents |
||||||||||||||
| return result; | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory"; | |
| import { recordOwnedConfigPath } from "../lib/config-ownership"; | ||
| import { sanitizeLogMetadataString } from "../lib/redact"; | ||
| import { usageDisplayTotalTokens } from "./totals"; | ||
| import { enforceUsageLedgerRetention } from "./ledger-retention"; | ||
| import type { AttemptTierOutcome, OcxUsage } from "../types"; | ||
| import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; | ||
| import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; | ||
|
|
@@ -569,6 +570,7 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void { | |
| const path = usageLogPath(); | ||
| appendFileSync(path, `${JSON.stringify(normalizeUsageEntry(entry))}\n`, { encoding: "utf-8", mode: 0o600 }); | ||
| try { chmodSync(path, 0o600); } catch { /* best-effort on platforms that ignore chmod */ } | ||
| try { enforceUsageLedgerRetention(); } catch { /* retention must not fail the request */ } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Move ledger retention off the request stack. When Run retention as a coalesced off-thread job owned by the server lifecycle. Serialize it with appends so a rewrite cannot overwrite a concurrent append. Keep retention failures non-fatal to requests. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| export type UsageLogRevision = { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | ||
| import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs"; | ||
| import { mkdtempSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { removeTreeWithRetry } from "./helpers/remove-tree"; | ||
| import { | ||
| compactUsageLedgerToMaxBytes, | ||
| discardHistoryIndex, | ||
| MIN_USAGE_LEDGER_MAX_BYTES, | ||
| normalizeUsageLedgerRetention, | ||
| usageLedgerPath, | ||
| } from "../src/usage/ledger-retention"; | ||
| import { HISTORY_DB_FILENAME } from "../src/routing/history/schema"; | ||
|
|
||
| let testDir = ""; | ||
| let previousHome: string | undefined; | ||
|
|
||
| beforeEach(() => { | ||
| previousHome = process.env.OPENCODEX_HOME; | ||
| testDir = mkdtempSync(join(tmpdir(), "ocx-ledger-ret-")); | ||
| process.env.OPENCODEX_HOME = testDir; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| if (previousHome === undefined) delete process.env.OPENCODEX_HOME; | ||
| else process.env.OPENCODEX_HOME = previousHome; | ||
| if (testDir) removeTreeWithRetry(testDir); | ||
| }); | ||
|
|
||
| function writeLines(path: string, lines: string[]): void { | ||
| writeFileSync(path, lines.map((line) => `${line}\n`).join(""), { encoding: "utf-8", mode: 0o600 }); | ||
| } | ||
|
|
||
| describe("normalizeUsageLedgerRetention", () => { | ||
| test("stays disabled unless enabled is exactly true", () => { | ||
| expect(normalizeUsageLedgerRetention(undefined).enabled).toBe(false); | ||
| expect(normalizeUsageLedgerRetention({ enabled: 1 }).enabled).toBe(false); | ||
| expect(normalizeUsageLedgerRetention({ enabled: "true" }).enabled).toBe(false); | ||
| expect(normalizeUsageLedgerRetention({ enabled: true }).enabled).toBe(true); | ||
| }); | ||
|
|
||
| test("clamps maxBytes to the 1 MiB floor", () => { | ||
| expect(normalizeUsageLedgerRetention({ enabled: true, maxBytes: 12 }).maxBytes).toBe(MIN_USAGE_LEDGER_MAX_BYTES); | ||
| expect(normalizeUsageLedgerRetention({ enabled: true, maxBytes: 8 * 1024 * 1024 }).maxBytes).toBe(8 * 1024 * 1024); | ||
| }); | ||
| }); | ||
|
|
||
| describe("compactUsageLedgerToMaxBytes", () => { | ||
| test("no-ops when the ledger is missing or already under the ceiling", () => { | ||
| const path = usageLedgerPath(testDir); | ||
| expect(compactUsageLedgerToMaxBytes(path, 1024).skipped).toBe("missing"); | ||
| writeLines(path, ['{"requestId":"a"}']); | ||
| const under = compactUsageLedgerToMaxBytes(path, 1024); | ||
| expect(under.skipped).toBe("under_limit"); | ||
| expect(readFileSync(path, "utf-8")).toContain('"a"'); | ||
| }); | ||
|
|
||
| test("keeps the newest complete JSONL rows", () => { | ||
| const path = usageLedgerPath(testDir); | ||
| const old = `{"id":"old","pad":"${"x".repeat(200)}"}`; | ||
| const mid = `{"id":"mid","pad":"${"y".repeat(200)}"}`; | ||
| const newest = `{"id":"new","pad":"${"z".repeat(200)}"}`; | ||
| writeLines(path, [old, mid, newest]); | ||
| const before = statSync(path).size; | ||
| const twoNewest = Buffer.byteLength(`${mid}\n${newest}\n`, "utf-8"); | ||
| // Land the cut inside the oldest row so the first kept newline is the row boundary. | ||
| const result = compactUsageLedgerToMaxBytes(path, twoNewest + 10); | ||
| expect(result.skipped).toBeUndefined(); | ||
| expect(result.beforeBytes).toBe(before); | ||
| expect(result.afterBytes).toBeLessThan(before); | ||
| const kept = readFileSync(path, "utf-8"); | ||
| expect(kept).toContain('"id":"new"'); | ||
| expect(kept).not.toContain('"id":"old"'); | ||
| }); | ||
| }); | ||
|
|
||
| describe("discardHistoryIndex", () => { | ||
| test("deletes the sqlite projection and wal companions", () => { | ||
| mkdirSync(testDir, { recursive: true }); | ||
| const db = join(testDir, HISTORY_DB_FILENAME); | ||
| writeFileSync(db, "sqlite"); | ||
| writeFileSync(`${db}-wal`, "wal"); | ||
| writeFileSync(`${db}-shm`, "shm"); | ||
| discardHistoryIndex(testDir); | ||
| expect(existsSync(db)).toBe(false); | ||
| expect(existsSync(`${db}-wal`)).toBe(false); | ||
| expect(existsSync(`${db}-shm`)).toBe(false); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 198
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 26699
🤖 get_repo_knowledge executed:
get_repo_knowledge lidge-jun/opencodex /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/conventions /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/learningsLength of output: 15692
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 22045
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 50376
🌐 Web query:
Zod 4.4.3 z.object unknown keys default strip official documentation💡 Result:
In Zod, including version 4.4.3, the default behavior for z.object schemas is to strip unknown keys during parsing [1][2][3]. This means any keys present in the input that are not explicitly defined in the schema are silently removed from the output [1][4][3]. You can control this behavior using the following methods:.strip: Explicitly sets the schema to the default behavior, where unknown keys are removed [4][5][6]..strict: Causes the schema to throw a ZodError if any unknown keys are encountered [1][4][3]..passthrough: Allows unknown keys to remain in the parsed output without validation [4][7][3]..catchall(schema): Allows unknown keys but validates them against the provided schema [1][4][5]. This method overrides the behavior of.strip,.strict, and.passthrough [5][8][6]. For most use cases, the default 'strip' behavior is recommended as a safe default to prevent data leakage [3].
Citations:
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 17643
Reject unknown retention keys before applying defaults.
Zod 4.4.3 strips unknown keys from
z.object. Atsrc/config.ts:1105-1108,{ enabled: true, maxByets: ... }becomes{ enabled: true }. The normalizer then applies the 512 MiB default, andenforceUsageLedgerRetentioncan permanently delete older ledger rows using an unintended limit.Add
.strict()to the nested object before.optional().catch(undefined).🤖 Prompt for AI Agents