@bitkyc08/opencodex 2.55.0 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,940 @@
1
+ /**
2
+ * Durable token spend reservation, above the send-count workflow guard (#4546).
3
+ *
4
+ * The count cap treats a 1k-token send and a 150k-token send as the same unit, and the
5
+ * in-memory ledger forgets everything on restart: an exhausted root came back with a fresh
6
+ * allowance after every relaunch, and a second process never saw the first one's spend at
7
+ * all. This ledger reserves TOKENS before dispatch and rebuilds its state from a journal
8
+ * under the opencodex home directory, so an exhausted scope is still exhausted after a
9
+ * restart.
10
+ *
11
+ * A reservation is always the request's whole input plus its ENFORCEABLE output ceiling --
12
+ * the caller's max_output_tokens, or the model's documented cap when the caller sent none.
13
+ * Never an optimistic estimate, and never shrunk by a cache-hit expectation: a prefix that
14
+ * misses is billed in full, so the safety figure reserves as if it misses. Cache
15
+ * expectations may inform efficiency reporting; they do not move this number.
16
+ *
17
+ * Admission requires, at every scope that applies at once -- root workflow, authenticated
18
+ * identity, and account pool:
19
+ *
20
+ * settled spend + in-flight reservations + unresolved spend + this reservation <= limit
21
+ *
22
+ * Unresolved spend is the conservative residue of a send whose usage frame was lost: the
23
+ * tokens may have been billed, so the reservation is moved to unresolved rather than
24
+ * released. Minting a new root id mints no new budget because the identity and pool scopes
25
+ * still hold the spend.
26
+ *
27
+ * SUPPORTED TOPOLOGY: this guarantees a single proxy process against its own journal. The
28
+ * file is append-friendly, but nothing here serializes two live processes writing it, so a
29
+ * second proxy sharing the same OPENCODEX_HOME is explicitly outside the guarantee -- that
30
+ * needs a shared store with cross-process atomicity and is declared out of scope rather
31
+ * than implied.
32
+ *
33
+ * Five properties this file owes its callers. Each one was absent in the first draft, and a
34
+ * budget that can be bypassed is worse than no budget because it looks like protection:
35
+ *
36
+ * 1. IDENTITY OF A SEND. A send id is either KNOWN -- and then reserving it again is refused
37
+ * rather than waved through booking nothing -- or FULLY forgotten, and then it books a
38
+ * fresh reservation. There is no third state where the ledger recognises an id and
39
+ * charges nothing for it, which is what let one id authorise unlimited physical sends.
40
+ * 2. DURABILITY BEFORE ADMISSION. Under a configured limit the reserve record must be on
41
+ * disk before the request is admitted. Failing open on a disk-full or permission error
42
+ * forgets the request across a restart, which is the exact case durability exists for.
43
+ * Observe-only mode still admits, and says so through `durable: false`.
44
+ * 3. REPLAY VALIDATES. Every journal record is checked field by field before it moves a
45
+ * counter. A corrupt record in the MIDDLE of the file would silently undercount, so it
46
+ * fails accounting closed instead; only an unparseable FINAL line -- a torn tail write --
47
+ * is dropped quietly.
48
+ * 4. BOUNDED RETENTION. Cleanup runs automatically, writes durable tombstones so replay
49
+ * cannot resurrect what it removed, and compacts the journal to a checkpoint. When
50
+ * nothing can be evicted safely, admission is refused rather than made room for by
51
+ * forgetting an exhausted scope -- forgetting one is the laundering this layer prevents.
52
+ * 5. NOTHING IDENTIFYING ON DISK. Root ids come from a client header and identity ids are
53
+ * credential ids, so the journal stores salted aliases only, under owner-only permissions
54
+ * that are re-applied to an EXISTING file rather than trusted from its creation.
55
+ */
56
+
57
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
58
+ import { createHash, randomBytes } from "node:crypto";
59
+ import { dirname, join } from "node:path";
60
+ // Definition-site import, not the ../config barrel -- same reasoning as
61
+ // src/quota/reset-seen-store.ts: the barrel pulls ~154 modules into a hot path.
62
+ import { getConfigDir } from "../config/paths";
63
+ import { assertNotRealHomeUnderTest } from "./test-home-guard";
64
+ // Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
65
+ import { hardenSecretPath } from "./windows-secret-acl";
66
+
67
+ export const SPEND_LEDGER_JOURNAL_FILENAME = "spend-ledger.jsonl";
68
+ /**
69
+ * Per-install alias salt, beside the journal. Losing it is exactly as bad as losing the
70
+ * journal -- both reset accounting, both live in the same 0700 directory -- so it is not a
71
+ * new weakness, and keeping it out of the journal stops a copied or attached journal from
72
+ * being reversible by dictionary attack on guessable pool and identity ids.
73
+ */
74
+ export const SPEND_LEDGER_SALT_FILENAME = "spend-ledger.salt";
75
+
76
+ export type SpendScope = "root" | "identity" | "pool";
77
+
78
+ export interface SpendScopeLimit {
79
+ /**
80
+ * Approved token ceiling for the scope. Undefined means OBSERVE ONLY: spend is still
81
+ * accounted and reported, but nothing is refused. That is the unconfigured default --
82
+ * an install that never opted in keeps the count caps and is not newly refused.
83
+ */
84
+ readonly maxTokens?: number;
85
+ }
86
+
87
+ export interface SpendReservationPolicy {
88
+ readonly root: SpendScopeLimit;
89
+ readonly identity: SpendScopeLimit;
90
+ readonly pool: SpendScopeLimit;
91
+ /**
92
+ * How long a dormant scope's accounting is retained. A scope may be dropped only when it
93
+ * is BOTH inactive (no open reservation) AND not exhausted inside this window; dropping
94
+ * an exhausted scope would hand it a fresh allowance on next use.
95
+ */
96
+ readonly retentionMs: number;
97
+ /**
98
+ * Hard ceiling on tracked scopes. Retention alone bounds nothing: a caller minting a fresh
99
+ * root id per request fills the map long before the window elapses. At the ceiling the
100
+ * ledger evicts the oldest scope that is safe to forget -- idle, under its limit, past
101
+ * retention -- and if there is none it REFUSES the new scope. Refusing is the only answer
102
+ * left: the alternative is evicting an exhausted scope, which hands it a fresh allowance.
103
+ */
104
+ readonly maxTrackedScopes?: number;
105
+ /** Hard ceiling on remembered send ids, with the same evict-or-refuse rule. */
106
+ readonly maxTrackedSends?: number;
107
+ /**
108
+ * Journal records after which the file is compacted into a single checkpoint. Without
109
+ * this the file grows forever even while the in-memory maps stay bounded, and replay
110
+ * resurrects every entry cleanup removed.
111
+ */
112
+ readonly compactAfterRecords?: number;
113
+ }
114
+
115
+ const DEFAULT_MAX_TRACKED_SCOPES = 4_096;
116
+ const DEFAULT_MAX_TRACKED_SENDS = 16_384;
117
+ const DEFAULT_COMPACT_AFTER_RECORDS = 8_192;
118
+
119
+ /**
120
+ * Unconfigured default: every limit undefined, so token accounting runs in observe-only
121
+ * mode and the count caps remain the only enforcement. Real numbers belong behind
122
+ * explicit operator configuration.
123
+ */
124
+ export const DEFAULT_SPEND_RESERVATION_POLICY: SpendReservationPolicy = {
125
+ root: {},
126
+ identity: {},
127
+ pool: {},
128
+ retentionMs: 7 * 24 * 60 * 60_000,
129
+ };
130
+
131
+ export interface SpendScopes {
132
+ readonly rootId?: string;
133
+ readonly identityId?: string;
134
+ readonly poolId?: string;
135
+ }
136
+
137
+ export interface SpendUsage {
138
+ readonly inputTokens: number;
139
+ readonly outputTokens: number;
140
+ }
141
+
142
+ export interface SpendReservationRequest {
143
+ /** Stable id of the physical send. Settlement is idempotent on this key. */
144
+ readonly sendId: string;
145
+ readonly scopes: SpendScopes;
146
+ readonly inputTokens: number;
147
+ /** Enforceable output ceiling -- max_output_tokens or the model's documented cap. */
148
+ readonly outputCeilingTokens: number;
149
+ readonly at?: number;
150
+ }
151
+
152
+ /**
153
+ * Why a reservation was refused. Every member refuses a DISPATCH: none of them is an
154
+ * "already fine, carry on" answer, because that is precisely how a duplicate send id used
155
+ * to buy an unlimited number of physical sends while the scope totals never moved.
156
+ */
157
+ export type SpendDenial =
158
+ | {
159
+ readonly reason: "spend-limit-exceeded";
160
+ readonly scope: SpendScope;
161
+ readonly scopeId: string;
162
+ readonly limit: number;
163
+ readonly projected: number;
164
+ }
165
+ /** This send id is already known -- open, settled, lost or abandoned. */
166
+ | { readonly reason: "duplicate-send-id"; readonly sendId: string }
167
+ /** The reserve record could not be written, and a configured limit needs it to survive. */
168
+ | { readonly reason: "reserve-not-durable"; readonly sendId: string }
169
+ /** Replay rejected records mid-file, so no scope total can be proven complete. */
170
+ | { readonly reason: "journal-corrupt"; readonly corruptRecords: number }
171
+ /** Tracking is full and nothing may be forgotten safely. */
172
+ | { readonly reason: "tracking-capacity-exhausted"; readonly scope?: SpendScope };
173
+
174
+ export type SpendReservationDecision =
175
+ | {
176
+ readonly reserved: true;
177
+ readonly sendId: string;
178
+ readonly tokens: number;
179
+ /**
180
+ * False only in observe-only mode, where the reservation was admitted although its
181
+ * journal record did not reach disk. A restart will not remember this spend; the flag
182
+ * is how a caller learns that instead of discovering it after the fact.
183
+ */
184
+ readonly durable: boolean;
185
+ }
186
+ | { readonly reserved: false; readonly denial: SpendDenial };
187
+
188
+ interface ScopeState {
189
+ settled: number;
190
+ reserved: number;
191
+ unresolved: number;
192
+ lastSeenAt: number;
193
+ }
194
+
195
+ /**
196
+ * `open` means admitted but not yet handed to a transport: it may still be abandoned for
197
+ * free. `dispatched` means bytes left for upstream, so from there a missing usage frame is
198
+ * unresolved SPEND rather than a release -- it may have been billed. Only a dispatched send
199
+ * can become `lost`; only an undispatched one can become `abandoned`.
200
+ */
201
+ type ReservationStatus = "open" | "dispatched" | "settled" | "lost" | "abandoned";
202
+
203
+ interface ScopeRef {
204
+ readonly scope: SpendScope;
205
+ readonly alias: string;
206
+ }
207
+
208
+ interface Reservation {
209
+ readonly targets: readonly ScopeRef[];
210
+ readonly tokens: number;
211
+ status: ReservationStatus;
212
+ readonly at: number;
213
+ /** When the status last changed; drives eviction of resolved entries. */
214
+ resolvedAt: number;
215
+ }
216
+
217
+ /**
218
+ * Journal shape. Every id on disk is a salted alias, never a root header value, credential
219
+ * id or pool name. `forget` and `drop` are the tombstones that make bounded cleanup
220
+ * durable -- without them replay rebuilds exactly what cleanup removed -- and `checkpoint`
221
+ * is a whole-state snapshot that lets the file be compacted instead of growing forever.
222
+ */
223
+ type JournalRecord =
224
+ | { v: 1; kind: "reserve"; send: string; targets: ScopeRef[]; tokens: number; at: number }
225
+ | { v: 1; kind: "dispatch"; send: string; at: number }
226
+ | { v: 1; kind: "settle"; send: string; tokens: number; at: number }
227
+ | { v: 1; kind: "lost"; send: string; at: number }
228
+ | { v: 1; kind: "abandon"; send: string; at: number }
229
+ | { v: 1; kind: "forget"; send: string; at: number }
230
+ | { v: 1; kind: "drop"; scope: SpendScope; alias: string; at: number }
231
+ | {
232
+ v: 1;
233
+ kind: "checkpoint";
234
+ at: number;
235
+ scopes: { scope: SpendScope; alias: string; settled: number; unresolved: number; seenAt: number }[];
236
+ sends: { send: string; status: ReservationStatus; targets: ScopeRef[]; tokens: number; at: number; resolvedAt: number }[];
237
+ };
238
+
239
+ const isCountable = (value: unknown): value is number =>
240
+ typeof value === "number" && Number.isFinite(value) && value >= 0;
241
+
242
+ const isAlias = (value: unknown): value is string =>
243
+ typeof value === "string" && value.length > 0 && value.length <= 256;
244
+
245
+ const isScopeName = (value: unknown): value is SpendScope =>
246
+ value === "root" || value === "identity" || value === "pool";
247
+
248
+ const isStatus = (value: unknown): value is ReservationStatus =>
249
+ value === "open" || value === "dispatched" || value === "settled"
250
+ || value === "lost" || value === "abandoned";
251
+
252
+ const parseTargets = (value: unknown): ScopeRef[] | undefined => {
253
+ if (!Array.isArray(value) || value.length > 3) return undefined;
254
+ const targets: ScopeRef[] = [];
255
+ for (const entry of value) {
256
+ if (typeof entry !== "object" || entry === null) return undefined;
257
+ const { scope, alias } = entry as { scope?: unknown; alias?: unknown };
258
+ if (!isScopeName(scope) || !isAlias(alias)) return undefined;
259
+ targets.push({ scope, alias });
260
+ }
261
+ return targets;
262
+ };
263
+
264
+ /**
265
+ * Validate one journal line into a record, or reject it.
266
+ *
267
+ * Exported because this is the boundary where a hostile or damaged file meets the accounting:
268
+ * `JSON.parse(line) as JournalRecord` type-asserts a lie, and a bare `null` line or a
269
+ * `{"v":1,"kind":"reserve"}` with no fields crashed the rebuild rather than being rejected.
270
+ * Every field is checked, including that numbers are finite and non-negative.
271
+ */
272
+ export function parseSpendJournalRecord(line: string): JournalRecord | undefined {
273
+ let raw: unknown;
274
+ try {
275
+ raw = JSON.parse(line);
276
+ } catch {
277
+ return undefined;
278
+ }
279
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
280
+ const record = raw as Record<string, unknown>;
281
+ if (record.v !== 1) return undefined;
282
+ if (!isCountable(record.at)) return undefined;
283
+ const at = record.at;
284
+ switch (record.kind) {
285
+ case "reserve": {
286
+ const targets = parseTargets(record.targets);
287
+ if (!isAlias(record.send) || targets === undefined || !isCountable(record.tokens)) return undefined;
288
+ return { v: 1, kind: "reserve", send: record.send, targets, tokens: record.tokens, at };
289
+ }
290
+ case "settle":
291
+ if (!isAlias(record.send) || !isCountable(record.tokens)) return undefined;
292
+ return { v: 1, kind: "settle", send: record.send, tokens: record.tokens, at };
293
+ case "dispatch":
294
+ if (!isAlias(record.send)) return undefined;
295
+ return { v: 1, kind: "dispatch", send: record.send, at };
296
+ case "lost":
297
+ if (!isAlias(record.send)) return undefined;
298
+ return { v: 1, kind: "lost", send: record.send, at };
299
+ case "abandon":
300
+ if (!isAlias(record.send)) return undefined;
301
+ return { v: 1, kind: "abandon", send: record.send, at };
302
+ case "forget":
303
+ if (!isAlias(record.send)) return undefined;
304
+ return { v: 1, kind: "forget", send: record.send, at };
305
+ case "drop":
306
+ if (!isScopeName(record.scope) || !isAlias(record.alias)) return undefined;
307
+ return { v: 1, kind: "drop", scope: record.scope, alias: record.alias, at };
308
+ case "checkpoint": {
309
+ if (!Array.isArray(record.scopes) || !Array.isArray(record.sends)) return undefined;
310
+ const scopes: { scope: SpendScope; alias: string; settled: number; unresolved: number; seenAt: number }[] = [];
311
+ for (const entry of record.scopes) {
312
+ if (typeof entry !== "object" || entry === null) return undefined;
313
+ const e = entry as Record<string, unknown>;
314
+ if (!isScopeName(e.scope) || !isAlias(e.alias)) return undefined;
315
+ if (!isCountable(e.settled) || !isCountable(e.unresolved) || !isCountable(e.seenAt)) return undefined;
316
+ scopes.push({ scope: e.scope, alias: e.alias, settled: e.settled, unresolved: e.unresolved, seenAt: e.seenAt });
317
+ }
318
+ const sends: { send: string; status: ReservationStatus; targets: ScopeRef[]; tokens: number; at: number; resolvedAt: number }[] = [];
319
+ for (const entry of record.sends) {
320
+ if (typeof entry !== "object" || entry === null) return undefined;
321
+ const e = entry as Record<string, unknown>;
322
+ const targets = parseTargets(e.targets);
323
+ if (!isAlias(e.send) || !isStatus(e.status) || targets === undefined) return undefined;
324
+ if (!isCountable(e.tokens) || !isCountable(e.at) || !isCountable(e.resolvedAt)) return undefined;
325
+ sends.push({ send: e.send, status: e.status, targets, tokens: e.tokens, at: e.at, resolvedAt: e.resolvedAt });
326
+ }
327
+ return { v: 1, kind: "checkpoint", at, scopes, sends };
328
+ }
329
+ default:
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Append-mostly persistence. `read` returns raw lines so replay can tell a torn TAIL write
336
+ * from corruption earlier in the file; only the former is safe to drop quietly. `append`
337
+ * THROWS when the record did not reach storage -- that signal is what lets admission refuse
338
+ * rather than admit a request a restart would forget. `rewrite` is optional: a store that
339
+ * cannot replace its contents atomically simply never compacts.
340
+ */
341
+ export interface SpendJournal {
342
+ read(): string[];
343
+ append(line: string): void;
344
+ rewrite?(lines: string[]): void;
345
+ }
346
+
347
+ /**
348
+ * Re-apply owner-only permissions to a file that already exists.
349
+ *
350
+ * `mode` in a write option is honoured only when the file is CREATED, so a journal that was
351
+ * created loose -- by an older build, a restored backup, or a lax umask -- would keep its
352
+ * mode forever. Best-effort by design: a non-owner cannot chmod, and failing every append
353
+ * over it would be worse than the loose mode it is fixing.
354
+ *
355
+ * `force` marks the points where the WINDOWS ACL can actually be wrong: creation, compaction,
356
+ * and each process's replay. Windows chmod cannot drop inherited ACEs, so icacls is the real
357
+ * boundary there, and its memo keys on the file's ctime -- which every append changes. Running
358
+ * it per reservation would therefore spawn a process per send while protecting nothing an
359
+ * append can alter. On POSIX the mode is checked on every write and repaired the moment it
360
+ * drifts, which costs one stat.
361
+ */
362
+ function hardenLedgerFile(path: string, options: { readonly force?: boolean } = {}): void {
363
+ if (process.platform === "win32") {
364
+ if (options.force) hardenSecretPath(path, { required: false });
365
+ return;
366
+ }
367
+ try {
368
+ if ((statSync(path).mode & 0o777) === 0o600) return;
369
+ chmodSync(path, 0o600);
370
+ } catch { /* best-effort: a non-owner cannot chmod */ }
371
+ }
372
+
373
+ export function createFileSpendJournal(path: string): SpendJournal {
374
+ const ensureDir = (): string => {
375
+ const dir = dirname(path);
376
+ // The guard runs before any mutation so a rejected write leaves nothing behind.
377
+ assertNotRealHomeUnderTest(dir);
378
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
379
+ return dir;
380
+ };
381
+ return {
382
+ read(): string[] {
383
+ if (!existsSync(path)) return [];
384
+ // Replay is once per process and is the moment a journal inherited from an older build
385
+ // or a restored backup first passes through here.
386
+ hardenLedgerFile(path, { force: true });
387
+ return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0);
388
+ },
389
+ append(line: string): void {
390
+ ensureDir();
391
+ const created = !existsSync(path);
392
+ appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 });
393
+ hardenLedgerFile(path, { force: created });
394
+ },
395
+ rewrite(lines: string[]): void {
396
+ ensureDir();
397
+ // Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction
398
+ // leaves either the old journal or the new one, never a half-written ledger.
399
+ const temp = `${path}.compact-${process.pid}`;
400
+ writeFileSync(temp, lines.map((line) => line + "\n").join(""), { encoding: "utf8", mode: 0o600 });
401
+ hardenLedgerFile(temp, { force: true });
402
+ renameSync(temp, path);
403
+ hardenLedgerFile(path, { force: true });
404
+ },
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Load the per-install alias salt, minting it on first use.
410
+ *
411
+ * The salt must be STABLE across restarts or replay cannot match a live request to its own
412
+ * recorded spend, which would hand every scope a fresh allowance -- so it is a file, not a
413
+ * per-process value.
414
+ */
415
+ export function loadOrCreateSpendLedgerSalt(path: string): string {
416
+ if (existsSync(path)) {
417
+ hardenLedgerFile(path, { force: true });
418
+ const existing = readFileSync(path, "utf8").trim();
419
+ if (/^[0-9a-f]{32,}$/.test(existing)) return existing;
420
+ }
421
+ const dir = dirname(path);
422
+ assertNotRealHomeUnderTest(dir);
423
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
424
+ const salt = randomBytes(32).toString("hex");
425
+ writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600 });
426
+ hardenLedgerFile(path, { force: true });
427
+ return salt;
428
+ }
429
+
430
+ export interface ScopeSpendSnapshot {
431
+ readonly settled: number;
432
+ readonly reserved: number;
433
+ readonly unresolved: number;
434
+ readonly exhausted: boolean;
435
+ }
436
+
437
+ export interface SpendReservationLedger {
438
+ reserve(request: SpendReservationRequest): SpendReservationDecision;
439
+ /**
440
+ * The send left for upstream. Until this is called the reservation may be abandoned for
441
+ * free; after it, a missing usage frame becomes unresolved spend. Returns false when the
442
+ * send is unknown or no longer open.
443
+ */
444
+ markDispatched(sendId: string): boolean;
445
+ /**
446
+ * The send never happened -- local validation, routing, or a refusal before any byte left
447
+ * this process. The reservation is RELEASED and books nothing, because inventing debt the
448
+ * account never incurred is its own way of breaking the budget. Refused once the send is
449
+ * dispatched: from there only settle or markLost is honest.
450
+ */
451
+ abandon(sendId: string): boolean;
452
+ /**
453
+ * Settle with real usage. Returns false when the send is unknown or already resolved --
454
+ * double settlement is as wrong as none, so a repeat call changes nothing.
455
+ */
456
+ settle(sendId: string, usage: SpendUsage): boolean;
457
+ /**
458
+ * Usage never arrived. The reservation moves to unresolved spend -- it may have been
459
+ * billed -- rather than being released. Idempotent on the same key as settle.
460
+ */
461
+ markLost(sendId: string): boolean;
462
+ snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined;
463
+ exhausted(scope: SpendScope, scopeId: string): boolean;
464
+ /**
465
+ * Drop dormant scopes per the retention rule in SpendReservationPolicy. Cleanup also runs
466
+ * automatically on every reservation, so nothing depends on a caller remembering this.
467
+ */
468
+ prune(now?: number): void;
469
+ /** Whether this send id is already known, and therefore refused. */
470
+ knows(sendId: string): boolean;
471
+ /** Journal writes that failed; a nonzero count means durability is degraded. */
472
+ readonly persistFailures: number;
473
+ /**
474
+ * Records replay rejected in the MIDDLE of the journal. Nonzero means no scope total can
475
+ * be proven complete, so configured limits refuse rather than undercount.
476
+ */
477
+ readonly corruptRecords: number;
478
+ /** True when durability is degraded in either direction: failed writes or a corrupt file. */
479
+ readonly degraded: boolean;
480
+ }
481
+
482
+ const scopeKey = (scope: SpendScope, alias: string): string => scope + "\0" + alias;
483
+
484
+ const sanitizeTokens = (value: number): number =>
485
+ Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
486
+
487
+ export function createSpendReservationLedger(options: {
488
+ readonly journal?: SpendJournal;
489
+ readonly policy?: SpendReservationPolicy;
490
+ readonly now?: () => number;
491
+ /**
492
+ * Per-install alias salt. Production passes the file-backed value from
493
+ * `loadOrCreateSpendLedgerSalt`; an empty default is for in-memory journals, which have
494
+ * no file anyone could correlate.
495
+ */
496
+ readonly salt?: string;
497
+ } = {}): SpendReservationLedger {
498
+ const policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
499
+ const journal = options.journal;
500
+ const now = options.now ?? (() => Date.now());
501
+ const salt = options.salt ?? "";
502
+ const maxTrackedScopes = policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
503
+ const maxTrackedSends = policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
504
+ const compactAfterRecords = policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
505
+ const scopes = new Map<string, ScopeState>();
506
+ const reservations = new Map<string, Reservation>();
507
+ let persistFailures = 0;
508
+ let corruptRecords = 0;
509
+ let recordsOnDisk = 0;
510
+
511
+ /**
512
+ * Salted alias for one identifier. The raw value -- a client-supplied root header, a
513
+ * credential id, a pool name -- never leaves this function, so nothing identifying is
514
+ * written to disk or held in a map key.
515
+ */
516
+ const aliasFor = (kind: SpendScope | "send", id: string): string =>
517
+ createHash("sha256").update(salt).update("\u0000").update(kind).update("\u0000").update(id)
518
+ .digest("hex").slice(0, 32);
519
+
520
+ const scopeState = (scope: SpendScope, alias: string): ScopeState => {
521
+ const key = scopeKey(scope, alias);
522
+ let state = scopes.get(key);
523
+ if (!state) {
524
+ state = { settled: 0, reserved: 0, unresolved: 0, lastSeenAt: 0 };
525
+ scopes.set(key, state);
526
+ }
527
+ return state;
528
+ };
529
+
530
+ const limitFor = (scope: SpendScope): number | undefined => policy[scope].maxTokens;
531
+
532
+ const isExhausted = (scope: SpendScope, state: ScopeState): boolean => {
533
+ const limit = limitFor(scope);
534
+ return limit !== undefined && state.settled + state.reserved + state.unresolved >= limit;
535
+ };
536
+
537
+ /** The scopes a request touches, as aliases. Creates no state: a refusal must leave none. */
538
+ const refsFor = (targets: SpendScopes): ScopeRef[] => {
539
+ const refs: ScopeRef[] = [];
540
+ if (targets.rootId !== undefined) refs.push({ scope: "root", alias: aliasFor("root", targets.rootId) });
541
+ if (targets.identityId !== undefined) refs.push({ scope: "identity", alias: aliasFor("identity", targets.identityId) });
542
+ if (targets.poolId !== undefined) refs.push({ scope: "pool", alias: aliasFor("pool", targets.poolId) });
543
+ return refs;
544
+ };
545
+
546
+ /**
547
+ * Returns whether the record reached storage. With no journal there is nothing to fail,
548
+ * and the caller's durability question is vacuously satisfied.
549
+ */
550
+ const append = (record: JournalRecord): boolean => {
551
+ if (!journal) return true;
552
+ try {
553
+ journal.append(JSON.stringify(record));
554
+ recordsOnDisk += 1;
555
+ return true;
556
+ } catch {
557
+ // In-memory state still bounds this process; the counter is how a caller learns the
558
+ // restart guarantee degraded instead of discovering it after the fact.
559
+ persistFailures += 1;
560
+ return false;
561
+ }
562
+ };
563
+
564
+ const applyReserve = (send: string, targets: readonly ScopeRef[], tokens: number, at: number): void => {
565
+ if (reservations.has(send)) return;
566
+ reservations.set(send, { targets, tokens, status: "open", at, resolvedAt: at });
567
+ for (const ref of targets) {
568
+ const state = scopeState(ref.scope, ref.alias);
569
+ state.reserved += tokens;
570
+ state.lastSeenAt = Math.max(state.lastSeenAt, at);
571
+ }
572
+ };
573
+
574
+ const isLive = (status: ReservationStatus): boolean => status === "open" || status === "dispatched";
575
+
576
+ /**
577
+ * Resolve a live reservation. `settled` books the real figure, `lost` keeps the whole
578
+ * reservation as unresolved spend because it may have been billed, and `abandoned`
579
+ * releases it because no byte ever left this process.
580
+ */
581
+ const applyResolve = (send: string, outcome: "settled" | "lost" | "abandoned", tokens: number, at: number): void => {
582
+ const reservation = reservations.get(send);
583
+ if (!reservation || !isLive(reservation.status)) return;
584
+ reservation.status = outcome;
585
+ reservation.resolvedAt = at;
586
+ for (const ref of reservation.targets) {
587
+ const state = scopeState(ref.scope, ref.alias);
588
+ state.reserved = Math.max(0, state.reserved - reservation.tokens);
589
+ if (outcome === "lost") state.unresolved += reservation.tokens;
590
+ else if (outcome === "settled") state.settled += tokens;
591
+ state.lastSeenAt = Math.max(state.lastSeenAt, at);
592
+ }
593
+ };
594
+
595
+ const applyDispatch = (send: string, at: number): void => {
596
+ const reservation = reservations.get(send);
597
+ if (!reservation || reservation.status !== "open") return;
598
+ reservation.status = "dispatched";
599
+ reservation.resolvedAt = at;
600
+ };
601
+
602
+ /** Tombstone replay: the entry is gone, so a later reuse of the id books a fresh charge. */
603
+ const applyForget = (send: string): void => {
604
+ const reservation = reservations.get(send);
605
+ if (!reservation || isLive(reservation.status)) return;
606
+ reservations.delete(send);
607
+ };
608
+
609
+ const applyDrop = (scope: SpendScope, alias: string): void => {
610
+ const state = scopes.get(scopeKey(scope, alias));
611
+ if (!state || state.reserved > 0) return;
612
+ scopes.delete(scopeKey(scope, alias));
613
+ };
614
+
615
+ const applyCheckpoint = (record: Extract<JournalRecord, { kind: "checkpoint" }>): void => {
616
+ scopes.clear();
617
+ reservations.clear();
618
+ for (const entry of record.scopes) {
619
+ scopes.set(scopeKey(entry.scope, entry.alias), {
620
+ settled: entry.settled,
621
+ reserved: 0,
622
+ unresolved: entry.unresolved,
623
+ lastSeenAt: entry.seenAt,
624
+ });
625
+ }
626
+ for (const entry of record.sends) {
627
+ // `reserved` is rebuilt from the live entries rather than trusted from the snapshot,
628
+ // so the two can never disagree about the same tokens.
629
+ if (isLive(entry.status)) {
630
+ applyReserve(entry.send, entry.targets, entry.tokens, entry.at);
631
+ if (entry.status === "dispatched") applyDispatch(entry.send, entry.resolvedAt);
632
+ continue;
633
+ }
634
+ reservations.set(entry.send, {
635
+ targets: entry.targets,
636
+ tokens: entry.tokens,
637
+ status: entry.status,
638
+ at: entry.at,
639
+ resolvedAt: entry.resolvedAt,
640
+ });
641
+ }
642
+ };
643
+
644
+ // Rebuild from the journal before serving: an exhausted scope must still be exhausted
645
+ // after a restart, which is the whole reason this store exists.
646
+ if (journal) {
647
+ const lines = journal.read();
648
+ recordsOnDisk = lines.length;
649
+ for (let index = 0; index < lines.length; index += 1) {
650
+ const line = lines[index] as string;
651
+ const record = parseSpendJournalRecord(line);
652
+ if (!record) {
653
+ // A rejected FINAL line is a torn tail write -- the process died between the write
654
+ // and its newline -- and is dropped quietly, because that record never completed and
655
+ // therefore never authorised anything. A rejected line ANYWHERE ELSE is different:
656
+ // the records after it did complete, so skipping it silently undercounts a scope and
657
+ // hands back budget. It is counted, and a configured limit refuses on it below.
658
+ if (index < lines.length - 1) corruptRecords += 1;
659
+ continue;
660
+ }
661
+ switch (record.kind) {
662
+ case "reserve": applyReserve(record.send, record.targets, sanitizeTokens(record.tokens), record.at); break;
663
+ case "dispatch": applyDispatch(record.send, record.at); break;
664
+ case "settle": applyResolve(record.send, "settled", sanitizeTokens(record.tokens), record.at); break;
665
+ case "lost": applyResolve(record.send, "lost", 0, record.at); break;
666
+ case "abandon": applyResolve(record.send, "abandoned", 0, record.at); break;
667
+ case "forget": applyForget(record.send); break;
668
+ case "drop": applyDrop(record.scope, record.alias); break;
669
+ case "checkpoint": applyCheckpoint(record); break;
670
+ }
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Bounded cleanup. It runs before every admission, so nothing depends on a caller
676
+ * remembering `prune()` -- the first draft exported one and no production path called it.
677
+ * Every removal writes a tombstone: without one, replay rebuilds precisely what cleanup
678
+ * removed and the file keeps growing while the maps look bounded.
679
+ *
680
+ * `force` is the at-capacity pass. It ignores the retention window but never the safety
681
+ * rule: an ACTIVE or EXHAUSTED scope is not a candidate at any pressure, because dropping
682
+ * one hands it a fresh allowance under the same id. When that leaves nothing to remove,
683
+ * the caller refuses admission rather than making room by forgetting a spent scope.
684
+ */
685
+ const evictScopes = (at: number, force: boolean): number => {
686
+ const cutoff = at - policy.retentionMs;
687
+ const candidates: { key: string; scope: SpendScope; alias: string; seenAt: number }[] = [];
688
+ for (const [key, state] of scopes) {
689
+ const separator = key.indexOf("\0");
690
+ const scope = key.slice(0, separator) as SpendScope;
691
+ if (state.reserved > 0) continue;
692
+ if (isExhausted(scope, state)) continue;
693
+ if (!force && state.lastSeenAt >= cutoff) continue;
694
+ candidates.push({ key, scope, alias: key.slice(separator + 1), seenAt: state.lastSeenAt });
695
+ }
696
+ if (force) {
697
+ candidates.sort((a, b) => a.seenAt - b.seenAt);
698
+ candidates.length = Math.min(candidates.length, 1);
699
+ }
700
+ for (const candidate of candidates) {
701
+ scopes.delete(candidate.key);
702
+ append({ v: 1, kind: "drop", scope: candidate.scope, alias: candidate.alias, at });
703
+ }
704
+ return candidates.length;
705
+ };
706
+
707
+ /**
708
+ * Forget resolved send ids. A forgotten id is forgotten COMPLETELY: reusing it later books
709
+ * a fresh reservation against every scope, which is conservative. The state this must never
710
+ * produce is the middle one -- an id the ledger recognises but charges nothing for.
711
+ */
712
+ const evictSends = (at: number, force: boolean): number => {
713
+ const cutoff = at - policy.retentionMs;
714
+ const candidates: { send: string; resolvedAt: number }[] = [];
715
+ for (const [send, reservation] of reservations) {
716
+ if (isLive(reservation.status)) continue;
717
+ if (!force && reservation.resolvedAt >= cutoff) continue;
718
+ candidates.push({ send, resolvedAt: reservation.resolvedAt });
719
+ }
720
+ if (force) {
721
+ candidates.sort((a, b) => a.resolvedAt - b.resolvedAt);
722
+ candidates.length = Math.min(candidates.length, 1);
723
+ }
724
+ for (const candidate of candidates) {
725
+ reservations.delete(candidate.send);
726
+ append({ v: 1, kind: "forget", send: candidate.send, at });
727
+ }
728
+ return candidates.length;
729
+ };
730
+
731
+ /**
732
+ * Replace the journal with a single checkpoint once it has grown past its record budget.
733
+ * Bounded maps are not enough on their own: the file behind them is what replay reads, and
734
+ * an uncompacted file grows forever on unique root and send ids.
735
+ */
736
+ const compact = (at: number): void => {
737
+ const rewrite = journal?.rewrite;
738
+ if (!journal || !rewrite || recordsOnDisk < compactAfterRecords) return;
739
+ const checkpoint: JournalRecord = {
740
+ v: 1,
741
+ kind: "checkpoint",
742
+ at,
743
+ scopes: [...scopes].map(([key, state]) => {
744
+ const separator = key.indexOf("\0");
745
+ return {
746
+ scope: key.slice(0, separator) as SpendScope,
747
+ alias: key.slice(separator + 1),
748
+ settled: state.settled,
749
+ unresolved: state.unresolved,
750
+ seenAt: state.lastSeenAt,
751
+ };
752
+ }),
753
+ sends: [...reservations].map(([send, reservation]) => ({
754
+ send,
755
+ status: reservation.status,
756
+ targets: [...reservation.targets],
757
+ tokens: reservation.tokens,
758
+ at: reservation.at,
759
+ resolvedAt: reservation.resolvedAt,
760
+ })),
761
+ };
762
+ try {
763
+ rewrite.call(journal, [JSON.stringify(checkpoint)]);
764
+ recordsOnDisk = 1;
765
+ } catch {
766
+ // Compaction is maintenance, not accounting: a failed rewrite leaves the previous
767
+ // journal intact and every figure in it still replayable.
768
+ persistFailures += 1;
769
+ }
770
+ };
771
+
772
+ /** The denial when tracking cannot fit this request, or undefined when it can. */
773
+ const makeRoom = (refs: readonly ScopeRef[], at: number): SpendDenial | undefined => {
774
+ evictSends(at, false);
775
+ evictScopes(at, false);
776
+ while (reservations.size >= maxTrackedSends) {
777
+ if (evictSends(at, true) === 0) return { reason: "tracking-capacity-exhausted" };
778
+ }
779
+ let fresh = 0;
780
+ for (const ref of refs) if (!scopes.has(scopeKey(ref.scope, ref.alias))) fresh += 1;
781
+ while (scopes.size + fresh > maxTrackedScopes) {
782
+ if (evictScopes(at, true) === 0) {
783
+ return { reason: "tracking-capacity-exhausted", scope: refs[0]?.scope };
784
+ }
785
+ }
786
+ return undefined;
787
+ };
788
+
789
+ return {
790
+ get persistFailures() { return persistFailures; },
791
+ get corruptRecords() { return corruptRecords; },
792
+ get degraded() { return persistFailures > 0 || corruptRecords > 0; },
793
+
794
+ reserve(request: SpendReservationRequest): SpendReservationDecision {
795
+ const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
796
+ const at = request.at ?? now();
797
+ const send = aliasFor("send", request.sendId);
798
+ const refs = refsFor(request.scopes);
799
+ const enforced = refs.some((ref) => limitFor(ref.scope) !== undefined);
800
+
801
+ // A send id this ledger already knows is REFUSED. Returning success while booking
802
+ // nothing -- the old behaviour -- let one id authorise an unlimited number of physical
803
+ // sends with the scope totals never moving.
804
+ if (reservations.has(send)) {
805
+ return { reserved: false, denial: { reason: "duplicate-send-id", sendId: request.sendId } };
806
+ }
807
+ // Replay could not prove these totals are complete, so a configured ceiling cannot be
808
+ // enforced on them. Observe-only accounting continues and reports the degradation.
809
+ if (enforced && corruptRecords > 0) {
810
+ return { reserved: false, denial: { reason: "journal-corrupt", corruptRecords } };
811
+ }
812
+ const capacity = makeRoom(refs, at);
813
+ if (capacity) return { reserved: false, denial: capacity };
814
+
815
+ // Check every scope before mutating any: a refusal must not leave a partial
816
+ // reservation booked on the scopes that would have passed. Reading state without
817
+ // creating it matters here -- a denied request must not leave a tracked scope behind.
818
+ for (const ref of refs) {
819
+ const limit = limitFor(ref.scope);
820
+ if (limit === undefined) continue;
821
+ const state = scopes.get(scopeKey(ref.scope, ref.alias));
822
+ const projected = (state ? state.settled + state.reserved + state.unresolved : 0) + tokens;
823
+ if (projected > limit) {
824
+ const scopeId = ref.scope === "root"
825
+ ? request.scopes.rootId
826
+ : ref.scope === "identity" ? request.scopes.identityId : request.scopes.poolId;
827
+ return {
828
+ reserved: false,
829
+ denial: { reason: "spend-limit-exceeded", scope: ref.scope, scopeId: scopeId ?? "", limit, projected },
830
+ };
831
+ }
832
+ }
833
+
834
+ // Durability BEFORE admission. The record goes to disk first, and under a configured
835
+ // limit a failed write refuses the request rather than admitting one that a restart
836
+ // would forget -- which is exactly the disk-full and permission case durability is for.
837
+ const durable = append({ v: 1, kind: "reserve", send, targets: refs, tokens, at });
838
+ if (!durable && enforced) {
839
+ return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
840
+ }
841
+ applyReserve(send, refs, tokens, at);
842
+ compact(at);
843
+ return { reserved: true, sendId: request.sendId, tokens, durable };
844
+ },
845
+
846
+ markDispatched(sendId: string): boolean {
847
+ const send = aliasFor("send", sendId);
848
+ const reservation = reservations.get(send);
849
+ if (!reservation || reservation.status !== "open") return false;
850
+ const at = now();
851
+ applyDispatch(send, at);
852
+ append({ v: 1, kind: "dispatch", send, at });
853
+ return true;
854
+ },
855
+
856
+ abandon(sendId: string): boolean {
857
+ const send = aliasFor("send", sendId);
858
+ const reservation = reservations.get(send);
859
+ // Only an UNDISPATCHED reservation may be released for free. Once bytes have left for
860
+ // upstream the tokens may already be billed, so the caller owes settle or markLost.
861
+ if (!reservation || reservation.status !== "open") return false;
862
+ const at = now();
863
+ applyResolve(send, "abandoned", 0, at);
864
+ append({ v: 1, kind: "abandon", send, at });
865
+ return true;
866
+ },
867
+
868
+ settle(sendId: string, usage: SpendUsage): boolean {
869
+ const send = aliasFor("send", sendId);
870
+ const reservation = reservations.get(send);
871
+ if (!reservation || !isLive(reservation.status)) return false;
872
+ const tokens = sanitizeTokens(usage.inputTokens) + sanitizeTokens(usage.outputTokens);
873
+ const at = now();
874
+ applyResolve(send, "settled", tokens, at);
875
+ append({ v: 1, kind: "settle", send, tokens, at });
876
+ return true;
877
+ },
878
+
879
+ markLost(sendId: string): boolean {
880
+ const send = aliasFor("send", sendId);
881
+ const reservation = reservations.get(send);
882
+ if (!reservation || !isLive(reservation.status)) return false;
883
+ const at = now();
884
+ applyResolve(send, "lost", 0, at);
885
+ append({ v: 1, kind: "lost", send, at });
886
+ return true;
887
+ },
888
+
889
+ knows(sendId: string): boolean {
890
+ return reservations.has(aliasFor("send", sendId));
891
+ },
892
+
893
+ snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined {
894
+ const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
895
+ if (!state) return undefined;
896
+ return {
897
+ settled: state.settled,
898
+ reserved: state.reserved,
899
+ unresolved: state.unresolved,
900
+ exhausted: isExhausted(scope, state),
901
+ };
902
+ },
903
+
904
+ exhausted(scope: SpendScope, scopeId: string): boolean {
905
+ const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
906
+ return state !== undefined && isExhausted(scope, state);
907
+ },
908
+
909
+ prune(at: number = now()): void {
910
+ // Removal requires BOTH inactive and not exhausted inside the window. An
911
+ // exhausted-but-idle scope that was dropped would be recreated fresh under the
912
+ // same id -- the exact laundering the ceiling exists to stop.
913
+ evictSends(at, false);
914
+ evictScopes(at, false);
915
+ },
916
+ };
917
+ }
918
+
919
+ let sharedLedger: SpendReservationLedger | undefined;
920
+
921
+ /**
922
+ * Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
923
+ * importing the module -- or running a request path that never reserves -- touches no
924
+ * disk.
925
+ */
926
+ export function sharedSpendLedger(): SpendReservationLedger {
927
+ if (!sharedLedger) {
928
+ const home = getConfigDir();
929
+ sharedLedger = createSpendReservationLedger({
930
+ journal: createFileSpendJournal(join(home, SPEND_LEDGER_JOURNAL_FILENAME)),
931
+ salt: loadOrCreateSpendLedgerSalt(join(home, SPEND_LEDGER_SALT_FILENAME)),
932
+ });
933
+ }
934
+ return sharedLedger;
935
+ }
936
+
937
+ /** Test seam. Production never discards the ledger: that would reset a spent budget. */
938
+ export function resetSharedSpendLedgerForTest(): void {
939
+ sharedLedger = undefined;
940
+ }