@bitkyc08/opencodex 2.79.0 → 2.81.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 (245) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/App-7yhoys8a.js +54 -0
  3. package/gui/dist/assets/Tray-EwAOsP0S.js +1 -0
  4. package/gui/dist/assets/index-RsoaAxXY.js +94 -0
  5. package/gui/dist/assets/quota-summary-D2lN8tDm.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/gui/dist/provider-icons/claude-green.svg +1 -0
  8. package/package.json +3 -2
  9. package/src/adapters/anthropic-model-contract.ts +8 -2
  10. package/src/adapters/anthropic.ts +7 -2
  11. package/src/adapters/codebuddy/mcp-server.ts +2 -25
  12. package/src/adapters/codebuddy/tool-bridge.ts +1 -7
  13. package/src/adapters/coding-agent/mcp-server.ts +33 -0
  14. package/src/adapters/coding-agent/protocol.ts +122 -17
  15. package/src/adapters/coding-agent/tool-catalog.ts +75 -0
  16. package/src/adapters/coding-agent/turn.ts +101 -44
  17. package/src/adapters/cursor/catalog.ts +8 -0
  18. package/src/adapters/cursor/effort-map.ts +2 -0
  19. package/src/adapters/cursor/live-transport.ts +5 -3
  20. package/src/adapters/cursor/native-exec-fs.ts +12 -9
  21. package/src/adapters/cursor/native-exec.ts +13 -5
  22. package/src/adapters/cursor/protobuf-request.ts +13 -7
  23. package/src/adapters/cursor/request-builder.ts +23 -10
  24. package/src/adapters/cursor/tool-definitions.ts +9 -6
  25. package/src/adapters/cursor/tool-guidance.ts +29 -8
  26. package/src/adapters/cursor/tool-naming.ts +40 -7
  27. package/src/adapters/cursor/tool-wording.ts +46 -0
  28. package/src/adapters/cursor.ts +3 -0
  29. package/src/adapters/devin/live-models.ts +2 -0
  30. package/src/adapters/devin.ts +25 -11
  31. package/src/adapters/kiro-retry.ts +9 -4
  32. package/src/adapters/openai-chat/messages.ts +7 -4
  33. package/src/adapters/physical-send.ts +8 -5
  34. package/src/adapters/qoder/adapter.ts +41 -6
  35. package/src/adapters/qoder/scaffold-guard.ts +2 -3
  36. package/src/adapters/zed.ts +16 -1
  37. package/src/claude/inbound-cache-stabilize.ts +4 -9
  38. package/src/claude/inbound.ts +10 -0
  39. package/src/claude/intercept/model-bindings.ts +1 -0
  40. package/src/claude/intercept/picker-listener.ts +71 -4
  41. package/src/cli/account-anthropic-threshold.ts +14 -5
  42. package/src/cli/account-api.ts +3 -1
  43. package/src/cli/account-extended.ts +13 -9
  44. package/src/cli/account-policy-dto.ts +1 -1
  45. package/src/cli/account-policy.ts +13 -4
  46. package/src/cli/account.ts +3 -3
  47. package/src/cli/capabilities-accounts.ts +2 -2
  48. package/src/cli/capabilities-agents-routing.ts +24 -0
  49. package/src/cli/capabilities-integrations.ts +16 -0
  50. package/src/cli/codex-shim-autorestore.ts +3 -0
  51. package/src/cli/dispatch.ts +10 -1
  52. package/src/cli/help.ts +4 -1
  53. package/src/cli/index.ts +11 -5
  54. package/src/cli/integration-plan-dto.ts +1 -0
  55. package/src/cli/integration-preview.ts +8 -1
  56. package/src/cli/integrations.ts +32 -3
  57. package/src/cli/message-args.ts +34 -0
  58. package/src/cli/message-command.ts +43 -0
  59. package/src/cli/message-runtime.ts +6 -0
  60. package/src/cli/observe.ts +3 -1
  61. package/src/cli/provider.ts +29 -6
  62. package/src/cli/registry.ts +24 -2
  63. package/src/clients/config-export/commandcode.ts +131 -0
  64. package/src/clients/config-export/contracts.ts +12 -0
  65. package/src/clients/config-export.ts +64 -1
  66. package/src/codex/auth-api/account-list.ts +11 -3
  67. package/src/codex/auth-api/main-account-probe.ts +25 -8
  68. package/src/codex/auth-api/pool-mode-gate.ts +33 -9
  69. package/src/codex/auth-api/pool-quota-probe.ts +15 -1
  70. package/src/codex/auth-api/routes.ts +1 -0
  71. package/src/codex/autostart-health.ts +3 -3
  72. package/src/codex/catalog/auto-review.ts +13 -6
  73. package/src/codex/catalog/build-entries.ts +15 -7
  74. package/src/codex/catalog/control-plane.ts +45 -0
  75. package/src/codex/catalog/effort.ts +11 -1
  76. package/src/codex/catalog/gather-capture.ts +5 -3
  77. package/src/codex/catalog/parsing.ts +2 -1
  78. package/src/codex/catalog/provider-models.ts +15 -9
  79. package/src/codex/control-plane-models.ts +6 -0
  80. package/src/codex/journal.ts +36 -5
  81. package/src/codex/shim-overlay.ts +9 -1
  82. package/src/combos/jev-decision-contract.ts +25 -2
  83. package/src/combos/jev.ts +6 -3
  84. package/src/combos/types.ts +1 -1
  85. package/src/config/diagnostics.ts +27 -16
  86. package/src/config/load-degrade.ts +34 -0
  87. package/src/config/schema/anthropic-account-pool.ts +143 -0
  88. package/src/config/schema/config-schema.ts +7 -1
  89. package/src/config/schema/leaf-validators.ts +4 -0
  90. package/src/config.ts +2 -0
  91. package/src/generated/compatibility-version.json +347 -199
  92. package/src/generated/model-metadata.ts +5 -4
  93. package/src/images/loop.ts +2 -1
  94. package/src/integrations/catalog-refresh.ts +1 -1
  95. package/src/integrations/mutation-plan.ts +7 -0
  96. package/src/integrations/registry.ts +8 -0
  97. package/src/integrations/state.ts +9 -2
  98. package/src/integrations/writer.ts +8 -2
  99. package/src/lib/lab-live-route-production.ts +9 -1
  100. package/src/lib/request-execution-budget.ts +236 -58
  101. package/src/lib/spend-pool-alias-validation.ts +79 -0
  102. package/src/lib/spend-pool-continuity.ts +34 -0
  103. package/src/lib/spend-reservation-ledger.ts +412 -50
  104. package/src/lib/state-store-registrations.ts +20 -3
  105. package/src/lib/upstream-retry.ts +17 -5
  106. package/src/lib/workflow-budget.ts +53 -11
  107. package/src/messaging/budget.ts +45 -0
  108. package/src/messaging/discovery.ts +57 -0
  109. package/src/messaging/envelope.ts +49 -0
  110. package/src/messaging/input.ts +37 -0
  111. package/src/messaging/rpc.ts +196 -0
  112. package/src/messaging/send.ts +58 -0
  113. package/src/messaging/socket.ts +72 -0
  114. package/src/messaging/types.ts +29 -0
  115. package/src/oauth/anthropic-account-refusal.ts +51 -17
  116. package/src/oauth/anthropic-account-threshold.ts +8 -2
  117. package/src/oauth/anthropic-continuity.ts +15 -1
  118. package/src/oauth/anthropic-model-quota.ts +120 -48
  119. package/src/oauth/anthropic-model-routes.ts +11 -3
  120. package/src/oauth/anthropic-oauth-definitions.ts +98 -0
  121. package/src/oauth/anthropic-pool-config.ts +30 -0
  122. package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
  123. package/src/oauth/anthropic-routing.ts +1202 -866
  124. package/src/oauth/anthropic-send-ownership.ts +41 -0
  125. package/src/oauth/anthropic.ts +4 -1
  126. package/src/oauth/generic-account-failover.ts +3 -1
  127. package/src/oauth/health.ts +8 -6
  128. package/src/oauth/index.ts +90 -173
  129. package/src/oauth/kiro.ts +22 -9
  130. package/src/oauth/login-cli.ts +2 -0
  131. package/src/oauth/model-discovery-auth.ts +43 -0
  132. package/src/oauth/pool-kernel.ts +11 -5
  133. package/src/oauth/pool-settings-capability.ts +5 -3
  134. package/src/oauth/store-anthropic-instance.ts +87 -0
  135. package/src/oauth/store.ts +67 -9
  136. package/src/oauth/token-guardian.ts +2 -0
  137. package/src/oauth/types.ts +2 -0
  138. package/src/protocols/plan-snapshot.ts +5 -0
  139. package/src/protocols/settings.ts +20 -5
  140. package/src/providers/anthropic-instance-id.ts +47 -0
  141. package/src/providers/anthropic-instance.ts +48 -0
  142. package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
  143. package/src/providers/derive.ts +2 -0
  144. package/src/providers/kiro-models.ts +3 -0
  145. package/src/providers/label.ts +1 -1
  146. package/src/providers/model-rename-fields.ts +2 -0
  147. package/src/providers/quota/account-cache.ts +37 -11
  148. package/src/providers/quota/anthropic-account-quota.ts +93 -0
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
  150. package/src/providers/quota/report-cache.ts +24 -1
  151. package/src/providers/quota/vendor-probes-oauth.ts +46 -14
  152. package/src/providers/quota.ts +22 -32
  153. package/src/providers/registry/entries-core.ts +35 -30
  154. package/src/providers/registry/model-ids.ts +1 -0
  155. package/src/providers/registry/model-seeds.ts +7 -4
  156. package/src/providers/registry/types.ts +3 -0
  157. package/src/providers/registry.ts +3 -3
  158. package/src/responses/spill-store.ts +5 -1
  159. package/src/responses/state/metrics.ts +87 -0
  160. package/src/responses/state/snapshot-codec.ts +6 -0
  161. package/src/responses/state/spill-queue.ts +1 -0
  162. package/src/responses/state/unforced-store-false.ts +55 -0
  163. package/src/responses/state.ts +50 -66
  164. package/src/router.ts +28 -4
  165. package/src/routing/compatibility/assemble.ts +1 -1
  166. package/src/routing/quota.ts +7 -3
  167. package/src/server/auth-cors.ts +5 -0
  168. package/src/server/chat-completions.ts +6 -1
  169. package/src/server/chat-native.ts +45 -25
  170. package/src/server/claude-messages.ts +16 -2
  171. package/src/server/gui-static.ts +38 -4
  172. package/src/server/index/spend-ledger-lifecycle.ts +10 -4
  173. package/src/server/index.ts +16 -16
  174. package/src/server/inference/context.ts +1 -1
  175. package/src/server/lifecycle.ts +1 -1
  176. package/src/server/management/agent-settings-routes.ts +30 -6
  177. package/src/server/management/anthropic-account-threshold.ts +12 -9
  178. package/src/server/management/anthropic-pool-settings.ts +72 -0
  179. package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
  180. package/src/server/management/config-routes.ts +27 -7
  181. package/src/server/management/decision-routes.ts +2 -1
  182. package/src/server/management/integration-routes.ts +25 -2
  183. package/src/server/management/oauth-account-routes.ts +79 -98
  184. package/src/server/management/provider-instance-ownership.ts +44 -0
  185. package/src/server/management/provider-routes.ts +21 -28
  186. package/src/server/management/route-registry.ts +3 -1
  187. package/src/server/management/routing-profile-routes.ts +4 -4
  188. package/src/server/management/system-restart.ts +44 -14
  189. package/src/server/management/system-routes.ts +24 -2
  190. package/src/server/management/vision-sidecar-options.ts +9 -6
  191. package/src/server/management/web-search-sidecar-options.ts +51 -2
  192. package/src/server/management-auth.ts +6 -0
  193. package/src/server/messages-native-eligibility.ts +3 -2
  194. package/src/server/messages-native-oauth.ts +86 -41
  195. package/src/server/messages-native-scope.ts +4 -1
  196. package/src/server/messages-native-selector.ts +39 -0
  197. package/src/server/messages-native.ts +84 -25
  198. package/src/server/request-log.ts +5 -0
  199. package/src/server/responses/adapter-continuation.ts +29 -17
  200. package/src/server/responses/adapter-dispatch.ts +47 -17
  201. package/src/server/responses/antigravity-validation-refusal.ts +20 -6
  202. package/src/server/responses/combo-requested-effort.ts +14 -0
  203. package/src/server/responses/compact.ts +25 -8
  204. package/src/server/responses/compaction-recovery.ts +1 -1
  205. package/src/server/responses/core-combo-native.ts +4 -1
  206. package/src/server/responses/core-combo.ts +21 -18
  207. package/src/server/responses/core-normalize.ts +1 -0
  208. package/src/server/responses/core-options.ts +3 -1
  209. package/src/server/responses/core-replay.ts +5 -0
  210. package/src/server/responses/passthrough-delivery.ts +7 -3
  211. package/src/server/responses/passthrough-dispatch.ts +21 -8
  212. package/src/server/responses/request-prepare.ts +37 -5
  213. package/src/server/responses/request-send-budget.ts +74 -37
  214. package/src/server/responses/request-spend.ts +325 -33
  215. package/src/server/responses/request-transport.ts +132 -52
  216. package/src/server/responses/run-turn-execution.ts +11 -3
  217. package/src/server/responses/sidecar-execution.ts +96 -14
  218. package/src/server/responses-undeclared-tool-guard.ts +5 -1
  219. package/src/server/workflow-refusal.ts +30 -0
  220. package/src/service/desktop-startup.ts +100 -3
  221. package/src/service/ownership-mutation-lease.d.mts +1 -1
  222. package/src/service/ownership-mutation-lease.mjs +5 -7
  223. package/src/sidecar/anthropic-binding.ts +79 -0
  224. package/src/sidecar/auth.ts +76 -2
  225. package/src/types/anthropic-account-pool.ts +33 -0
  226. package/src/types/config.ts +12 -28
  227. package/src/types/provider.ts +5 -0
  228. package/src/types/tools.ts +7 -0
  229. package/src/types.ts +4 -0
  230. package/src/usage/cost.ts +3 -2
  231. package/src/usage/expected-prices.ts +66 -14
  232. package/src/vision/anthropic-describe.ts +16 -3
  233. package/src/vision/index.ts +3 -0
  234. package/src/vision/plan.ts +19 -6
  235. package/src/web-search/alpha-search.ts +7 -1
  236. package/src/web-search/anthropic-executor.ts +16 -4
  237. package/src/web-search/header-deadline.ts +67 -0
  238. package/src/web-search/index.ts +7 -3
  239. package/src/web-search/loop.ts +32 -10
  240. package/src/web-search/passthrough-bridge.ts +4 -1
  241. package/src/web-search/sidecar-providers.ts +4 -3
  242. package/gui/dist/assets/App-BOUa-ek3.js +0 -54
  243. package/gui/dist/assets/Tray-DUtzB4zF.js +0 -1
  244. package/gui/dist/assets/index-DwHNDF4s.js +0 -94
  245. package/gui/dist/assets/quota-summary-BuL4WIBo.js +0 -1
@@ -54,7 +54,7 @@
54
54
  */
55
55
 
56
56
  import { appendFileSync, chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
57
- import { createHash, randomBytes } from "node:crypto";
57
+ import { randomBytes } from "node:crypto";
58
58
  import { dirname, join } from "node:path";
59
59
  // Definition-site import, not the ../config barrel -- same reasoning as
60
60
  // src/quota/reset-seen-store.ts: the barrel pulls ~154 modules into a hot path.
@@ -62,6 +62,8 @@ import { getConfigDir } from "../config/paths";
62
62
  // Type-only, so it is erased before this module has a runtime import graph at all. The
63
63
  // config SHAPE is what this file needs; the config loader is what the note above keeps out.
64
64
  import type { OcxSpendConfig, OcxSpendScopeConfig } from "../types/config";
65
+ import { resolvePoolAliases } from "./spend-pool-continuity";
66
+ import { hashSpendAlias, validatePoolAliasOwners } from "./spend-pool-alias-validation";
65
67
  import { assertNotRealHomeUnderTest } from "./test-home-guard";
66
68
  // Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
67
69
  import { hardenSecretPath } from "./windows-secret-acl";
@@ -93,6 +95,9 @@ export interface SpendScopeLimit {
93
95
  }
94
96
 
95
97
  export interface SpendReservationPolicy {
98
+ /** Exact historical salted pool alias -> canonical provider. Malformed input fails closed. */
99
+ readonly poolAliases?: unknown;
100
+ readonly canonicalProviderIds?: readonly string[];
96
101
  readonly root: SpendScopeLimit;
97
102
  readonly identity: SpendScopeLimit;
98
103
  readonly pool: SpendScopeLimit;
@@ -147,7 +152,11 @@ export interface SpendUsage {
147
152
  readonly outputTokens: number;
148
153
  }
149
154
 
155
+ /** Immutable numeric ceilings captured by one request; totals and capacity remain shared. */
156
+ export type SpendAdmissionPolicy = Readonly<Pick<SpendReservationPolicy, "root" | "identity" | "pool">>;
157
+
150
158
  export interface SpendReservationRequest {
159
+ readonly admissionPolicy?: SpendAdmissionPolicy;
151
160
  /** Stable id of the physical send. Settlement is idempotent on this key. */
152
161
  readonly sendId: string;
153
162
  readonly scopes: SpendScopes;
@@ -181,12 +190,15 @@ export interface SpendReservationRequest {
181
190
  * to buy an unlimited number of physical sends while the scope totals never moved.
182
191
  */
183
192
  export type SpendDenial =
193
+ | { readonly reason: "pool-history-unresolved" }
184
194
  | {
185
195
  readonly reason: "spend-limit-exceeded";
186
196
  readonly scope: SpendScope;
187
197
  readonly scopeId: string;
188
198
  readonly limit: number;
189
199
  readonly projected: number;
200
+ /** Pool checks include all unbound positive history until an operator verifies its owner. */
201
+ readonly includesUnboundPoolHistory?: boolean;
190
202
  }
191
203
  /** This send id is already known -- open, settled, lost or abandoned. */
192
204
  | { readonly reason: "duplicate-send-id"; readonly sendId: string }
@@ -606,8 +618,30 @@ export interface ScopeSpendSnapshot {
606
618
  readonly exhausted: boolean;
607
619
  }
608
620
 
621
+ /** In-memory proof of one booked send; never journaled or exposed in request logs. */
622
+ export interface SpendReservationProof {
623
+ readonly ledger: SpendReservationLedger;
624
+ readonly sendId: string;
625
+ }
626
+
627
+ export interface SpendSeed { readonly sendId: string }
628
+ export type SpendSeedDecision = { reserved: true; seed: SpendSeed } | { reserved: false; denial: SpendDenial };
629
+
609
630
  export interface SpendReservationLedger {
631
+ /** Tracker owners isolate cleanup barriers; omitted owners retain the installation barrier. */
632
+ reserveSeed(request: SpendReservationRequest, owner?: object): SpendSeedDecision;
633
+ reserveReportedFromSeed(seed: SpendSeed, request: { sendId: string; inputTokens: number; outputCeilingTokens: number }): boolean;
634
+ forgetResolved(sendIds: readonly string[], owner?: object): boolean;
635
+ finishSeed(seed: SpendSeed, owner?: object): boolean;
636
+ registerReporter(owner?: object): { close(): void };
637
+ /** Retry completed tracker cleanup on later admission or maintenance after storage recovers. */
638
+ deferCleanup(owner: object, cleanup: () => boolean): void;
639
+ waitForReporterDrain(): Promise<void>;
610
640
  reserve(request: SpendReservationRequest): SpendReservationDecision;
641
+ /** Pre-dispatch guard, including transports which report their sends after dispatch. */
642
+ checkPoolContinuity(): SpendDenial | undefined;
643
+ /** Whether any positive pool balance is still unbound and therefore charged to each candidate pool. */
644
+ hasUnboundPositivePoolHistory(): boolean;
611
645
  /**
612
646
  * The send left for upstream. Until this is called the reservation may be abandoned for
613
647
  * free; after it, a missing usage frame becomes unresolved spend. Returns false when the
@@ -624,6 +658,7 @@ export interface SpendReservationLedger {
624
658
  /**
625
659
  * Settle with real usage. Returns false when the send is unknown or already resolved --
626
660
  * double settlement is as wrong as none, so a repeat call changes nothing.
661
+ * Seeded terminal debt applies immediately; failed writes queue before durable forgetting.
627
662
  */
628
663
  settle(sendId: string, usage: SpendUsage): boolean;
629
664
  /**
@@ -632,7 +667,8 @@ export interface SpendReservationLedger {
632
667
  */
633
668
  markLost(sendId: string): boolean;
634
669
  snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined;
635
- exhausted(scope: SpendScope, scopeId: string): boolean;
670
+ /** Exclude only a proven current dispatch's still-open reservation in this scope. */
671
+ exhausted(scope: SpendScope, scopeId: string, excludingSendId?: string, admissionPolicy?: SpendAdmissionPolicy): boolean;
636
672
  /**
637
673
  * Drop dormant scopes per the retention rule in SpendReservationPolicy. Cleanup also runs
638
674
  * automatically on every reservation, so nothing depends on a caller remembering this.
@@ -701,6 +737,31 @@ export function createSpendReservationLedger(options: {
701
737
  const compactAfterRecords = (): number => policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
702
738
  const scopes = new Map<string, ScopeState>();
703
739
  const reservations = new Map<string, Reservation>();
740
+ // Object identity is the capability: a copied sendId or another ledger's seed is not proof.
741
+ type SeedState = { targets: readonly ScopeRef[]; sends: Set<string>; abandoned: boolean; owner?: object };
742
+ const seeds = new Map<SpendSeed, SeedState>();
743
+ const seedBySend = new Map<string, SeedState>();
744
+ const pinnedKeys = new Map<string, number>();
745
+ const seedForSend = (send: string) => seedBySend.get(send);
746
+ const pinnedScope = (key: string): boolean => pinnedKeys.has(key);
747
+ const pendingRecords: JournalRecord[] = [];
748
+ // Bounded by live ordinary reservations; never persisted as new metadata.
749
+ const nondurableReserves = new Map<string, JournalRecord>();
750
+ const retainedOrdinary = new Set<string>();
751
+ let reporters = 0;
752
+ const ownerReporters = new WeakMap<object, number>();
753
+ const activeReporters = (owner?: object): number => owner === undefined ? reporters : ownerReporters.get(owner) ?? 0;
754
+ const drainWaiters = new Set<() => void>();
755
+ const deferredCleanups = new Map<object, () => boolean>();
756
+ let retryingCleanup = false;
757
+ const retryCleanup = (): void => {
758
+ if (retryingCleanup) return;
759
+ retryingCleanup = true;
760
+ try {
761
+ for (const [owner, cleanup] of deferredCleanups) if (cleanup()) deferredCleanups.delete(owner);
762
+ } finally { retryingCleanup = false; }
763
+ };
764
+ let poolContinuity = resolvePoolAliases(policy.poolAliases, salt, policy.canonicalProviderIds);
704
765
  let persistFailures = 0;
705
766
  let corruptRecords = 0;
706
767
  let recordsOnDisk = 0;
@@ -710,9 +771,8 @@ export function createSpendReservationLedger(options: {
710
771
  * credential id, a pool name -- never leaves this function, so nothing identifying is
711
772
  * written to disk or held in a map key.
712
773
  */
713
- const aliasFor = (kind: SpendScope | "send", id: string): string =>
714
- createHash("sha256").update(salt).update("\u0000").update(kind).update("\u0000").update(id)
715
- .digest("hex").slice(0, 32);
774
+ const aliasFor = (kind: SpendScope | "send", id: string): string => hashSpendAlias(salt, kind, id);
775
+ const poolAliasFor = (provider: string): string => aliasFor("pool", provider);
716
776
 
717
777
  const scopeState = (scope: SpendScope, alias: string): ScopeState => {
718
778
  const key = scopeKey(scope, alias);
@@ -724,6 +784,37 @@ export function createSpendReservationLedger(options: {
724
784
  return state;
725
785
  };
726
786
 
787
+ const historicalPools = (): Set<string> => new Set([...scopes].filter(([key, state]) =>
788
+ key.startsWith("pool\0") && state.settled + state.reserved + state.unresolved > 0,
789
+ ).map(([key]) => key.slice(5)));
790
+ const hasUnboundPositivePoolHistory = (): boolean => [...historicalPools()].some(alias => !poolContinuity.known(alias));
791
+ const poolState = (alias: string): { totals: ScopeState; includesUnboundHistory: boolean } | undefined => {
792
+ const group = poolContinuity.resolve(alias);
793
+ let total: ScopeState | undefined;
794
+ let includesUnboundHistory = false;
795
+ for (const [key, state] of scopes) {
796
+ if (!key.startsWith("pool\0")) continue;
797
+ const member = key.slice(5);
798
+ const unbound = !poolContinuity.known(member);
799
+ if (unbound) {
800
+ // Configured provider buckets bind at read time. Only unmapped historical labels
801
+ // absent from that roster remain unbound and overlay every candidate provider.
802
+ if (state.settled + state.reserved + state.unresolved === 0) continue;
803
+ includesUnboundHistory = true;
804
+ } else if (poolContinuity.resolve(member) !== group) {
805
+ continue;
806
+ }
807
+ total ??= { settled: 0, reserved: 0, unresolved: 0, lastSeenAt: 0 };
808
+ total.settled += state.settled;
809
+ total.reserved += state.reserved;
810
+ total.unresolved += state.unresolved;
811
+ total.lastSeenAt = Math.max(total.lastSeenAt, state.lastSeenAt);
812
+ }
813
+ return total ? { totals: total, includesUnboundHistory } : undefined;
814
+ };
815
+ const stateFor = (scope: SpendScope, alias: string): ScopeState | undefined =>
816
+ scope === "pool" ? poolState(alias)?.totals : scopes.get(scopeKey(scope, alias));
817
+
727
818
  const limitFor = (scope: SpendScope): number | undefined => policy[scope].maxTokens;
728
819
 
729
820
  const isExhausted = (scope: SpendScope, state: ScopeState): boolean => {
@@ -736,7 +827,7 @@ export function createSpendReservationLedger(options: {
736
827
  const refs: ScopeRef[] = [];
737
828
  if (targets.rootId !== undefined) refs.push({ scope: "root", alias: aliasFor("root", targets.rootId) });
738
829
  if (targets.identityId !== undefined) refs.push({ scope: "identity", alias: aliasFor("identity", targets.identityId) });
739
- if (targets.poolId !== undefined) refs.push({ scope: "pool", alias: aliasFor("pool", targets.poolId) });
830
+ if (targets.poolId !== undefined) refs.push({ scope: "pool", alias: poolAliasFor(targets.poolId) });
740
831
  return refs;
741
832
  };
742
833
 
@@ -759,6 +850,29 @@ export function createSpendReservationLedger(options: {
759
850
  }
760
851
  };
761
852
 
853
+ // Already-sent debt owns its record before I/O, including an owner error that propagates.
854
+ const appendReported = (record: JournalRecord): void => {
855
+ pendingRecords.push(record);
856
+ if (pendingRecords.length === 1 && append(record)) pendingRecords.shift();
857
+ };
858
+
859
+ const flushPending = (): boolean => {
860
+ while (pendingRecords.length > 0) {
861
+ if (!append(pendingRecords[0]!)) return false;
862
+ pendingRecords.shift();
863
+ }
864
+ return true;
865
+ };
866
+
867
+ const repairReservePrefix = (send: string, reservation: Reservation): void => {
868
+ const reserve = nondurableReserves.get(send);
869
+ if (!reserve) return;
870
+ pendingRecords.push(reserve);
871
+ if (reservation.status === "dispatched") pendingRecords.push({ v: 1, kind: "dispatch", send, at: reservation.resolvedAt });
872
+ nondurableReserves.delete(send);
873
+ retainedOrdinary.add(send);
874
+ };
875
+
762
876
  const applyReserve = (send: string, targets: readonly ScopeRef[], tokens: number, at: number): void => {
763
877
  if (reservations.has(send)) return;
764
878
  reservations.set(send, { targets, tokens, status: "open", at, resolvedAt: at });
@@ -779,6 +893,8 @@ export function createSpendReservationLedger(options: {
779
893
  const applyResolve = (send: string, outcome: "settled" | "lost" | "abandoned", tokens: number, at: number): void => {
780
894
  const reservation = reservations.get(send);
781
895
  if (!reservation || !isLive(reservation.status)) return;
896
+ nondurableReserves.delete(send);
897
+ retainedOrdinary.delete(send);
782
898
  reservation.status = outcome;
783
899
  reservation.resolvedAt = at;
784
900
  for (const ref of reservation.targets) {
@@ -802,6 +918,8 @@ export function createSpendReservationLedger(options: {
802
918
  const reservation = reservations.get(send);
803
919
  if (!reservation || isLive(reservation.status)) return;
804
920
  reservations.delete(send);
921
+ nondurableReserves.delete(send);
922
+ retainedOrdinary.delete(send);
805
923
  };
806
924
 
807
925
  const applyDrop = (scope: SpendScope, alias: string): void => {
@@ -848,12 +966,13 @@ export function createSpendReservationLedger(options: {
848
966
  const line = lines[index] as string;
849
967
  const record = parseSpendJournalRecord(line);
850
968
  if (!record) {
851
- // A rejected FINAL line is a torn tail write -- the process died between the write
852
- // and its newline -- and is dropped quietly, because that record never completed and
853
- // therefore never authorised anything. A rejected line ANYWHERE ELSE is different:
854
- // the records after it did complete, so skipping it silently undercounts a scope and
855
- // hands back budget. It is counted, and a configured limit refuses on it below.
856
- if (index < lines.length - 1) corruptRecords += 1;
969
+ // A malformed final JSON line can be an incomplete tail write. Rejected records
970
+ // elsewhere cannot be skipped: later complete writes may have authorized spend.
971
+ // Only malformed JSON can be a torn tail. A complete unknown/invalid final
972
+ // record is evidence of an unsupported format, not permission to forget accounting.
973
+ let completeJson = false;
974
+ try { JSON.parse(line); completeJson = true; } catch { /* torn tail */ }
975
+ if (completeJson || index < lines.length - 1) corruptRecords += 1;
857
976
  continue;
858
977
  }
859
978
  switch (record.kind) {
@@ -880,10 +999,16 @@ export function createSpendReservationLedger(options: {
880
999
  // had already fired. An exhausted scope staying exhausted across a restart is the whole
881
1000
  // reason this store is on disk.
882
1001
  const reconciledAt = now();
1002
+ const enforcedReplay = spendCeilingsConfigured(policy);
883
1003
  for (const [send, reservation] of reservations) {
884
- if (!isLive(reservation.status)) continue;
885
- applyResolve(send, "lost", 0, reconciledAt);
886
- append({ v: 1, kind: "lost", send, at: reconciledAt });
1004
+ if (isLive(reservation.status)) {
1005
+ const record: JournalRecord = { v: 1, kind: "lost", send, at: reconciledAt };
1006
+ const durable = append(record);
1007
+ applyResolve(send, "lost", 0, reconciledAt);
1008
+ if (!durable && enforcedReplay) pendingRecords.push(record);
1009
+ }
1010
+ // No live producer or capability survives restart. Preserve totals, reclaim only IDs.
1011
+ if (enforcedReplay && flushPending() && append({ v: 1, kind: "forget", send, at: reconciledAt })) applyForget(send);
887
1012
  }
888
1013
  }
889
1014
 
@@ -900,24 +1025,76 @@ export function createSpendReservationLedger(options: {
900
1025
  */
901
1026
  const evictScopes = (at: number, force: boolean): number => {
902
1027
  const cutoff = at - policy.retentionMs;
1028
+ const activeKeys = new Set<string>();
1029
+ for (const reservation of reservations.values()) if (isLive(reservation.status)) {
1030
+ for (const ref of reservation.targets) activeKeys.add(scopeKey(ref.scope, ref.alias));
1031
+ }
1032
+ const idle = (key: string, state: ScopeState): boolean => state.reserved === 0
1033
+ && !activeKeys.has(key) && !pinnedScope(key);
1034
+ let removed = 0;
1035
+ // Unknown original buckets expire at their own cutoff, even when the overlay is exhausted.
1036
+ // This pass precedes group selection so a removed overlay cannot keep unrelated groups alive.
1037
+ for (const [key, state] of scopes) {
1038
+ if (!key.startsWith("pool\0") || poolContinuity.known(key.slice(5))
1039
+ || !idle(key, state) || state.lastSeenAt >= cutoff) continue;
1040
+ if (!flushPending() || !append({ v: 1, kind: "drop", scope: "pool", alias: key.slice(5), at })) continue;
1041
+ scopes.delete(key);
1042
+ removed += 1;
1043
+ }
1044
+ // Aggregate once per pass; resolving each candidate by scanning every scope is quadratic.
1045
+ const pinnedPoolGroups = new Set<string>();
1046
+ for (const key of activeKeys) if (key.startsWith("pool\0")) pinnedPoolGroups.add(poolContinuity.resolve(key.slice(5)));
1047
+ for (const seed of seeds.values()) for (const ref of seed.targets) if (ref.scope === "pool") {
1048
+ pinnedPoolGroups.add(poolContinuity.resolve(ref.alias));
1049
+ }
1050
+ const groups = new Map<string, ScopeState>();
1051
+ const overlay: ScopeState = { settled: 0, reserved: 0, unresolved: 0, lastSeenAt: 0 };
1052
+ for (const [key, state] of scopes) {
1053
+ if (!key.startsWith("pool\0")) continue;
1054
+ const alias = key.slice(5);
1055
+ const known = poolContinuity.known(alias);
1056
+ let total = overlay;
1057
+ if (known) {
1058
+ const group = poolContinuity.resolve(alias);
1059
+ total = groups.get(group) ?? { settled: 0, reserved: 0, unresolved: 0, lastSeenAt: 0 };
1060
+ groups.set(group, total);
1061
+ }
1062
+ total.settled += state.settled;
1063
+ total.reserved += state.reserved;
1064
+ total.unresolved += state.unresolved;
1065
+ total.lastSeenAt = Math.max(total.lastSeenAt, state.lastSeenAt);
1066
+ }
903
1067
  const candidates: { key: string; scope: SpendScope; alias: string; seenAt: number }[] = [];
904
1068
  for (const [key, state] of scopes) {
1069
+ if (!idle(key, state)) continue;
905
1070
  const separator = key.indexOf("\0");
906
1071
  const scope = key.slice(0, separator) as SpendScope;
907
- if (state.reserved > 0) continue;
908
- if (isExhausted(scope, state)) continue;
909
- if (!force && state.lastSeenAt >= cutoff) continue;
910
- candidates.push({ key, scope, alias: key.slice(separator + 1), seenAt: state.lastSeenAt });
1072
+ const alias = key.slice(separator + 1);
1073
+ const unbound = scope === "pool" && !poolContinuity.known(alias);
1074
+ // Pressure cannot shorten retention for unknown history, including zero-token keys.
1075
+ if (unbound && state.settled + state.reserved + state.unresolved > 0) continue;
1076
+ const group = scope === "pool" ? groups.get(poolContinuity.resolve(alias)) : undefined;
1077
+ const effective = scope === "pool" && !unbound ? {
1078
+ settled: (group?.settled ?? 0) + overlay.settled,
1079
+ reserved: (group?.reserved ?? 0) + overlay.reserved,
1080
+ unresolved: (group?.unresolved ?? 0) + overlay.unresolved,
1081
+ lastSeenAt: group?.lastSeenAt ?? state.lastSeenAt,
1082
+ } : state;
1083
+ if (effective.reserved > 0 || (scope === "pool" && pinnedPoolGroups.has(poolContinuity.resolve(alias)))
1084
+ || isExhausted(scope, effective)) continue;
1085
+ if (!force && effective.lastSeenAt >= cutoff) continue;
1086
+ candidates.push({ key, scope, alias, seenAt: state.lastSeenAt });
911
1087
  }
912
1088
  if (force) {
913
1089
  candidates.sort((a, b) => a.seenAt - b.seenAt);
914
1090
  candidates.length = Math.min(candidates.length, 1);
915
1091
  }
916
1092
  for (const candidate of candidates) {
1093
+ if (!flushPending() || !append({ v: 1, kind: "drop", scope: candidate.scope, alias: candidate.alias, at })) continue;
917
1094
  scopes.delete(candidate.key);
918
- append({ v: 1, kind: "drop", scope: candidate.scope, alias: candidate.alias, at });
1095
+ removed += 1;
919
1096
  }
920
- return candidates.length;
1097
+ return removed;
921
1098
  };
922
1099
 
923
1100
  /**
@@ -929,7 +1106,7 @@ export function createSpendReservationLedger(options: {
929
1106
  const cutoff = at - policy.retentionMs;
930
1107
  const candidates: { send: string; resolvedAt: number }[] = [];
931
1108
  for (const [send, reservation] of reservations) {
932
- if (isLive(reservation.status)) continue;
1109
+ if (isLive(reservation.status) || seedForSend(send)) continue;
933
1110
  if (!force && reservation.resolvedAt >= cutoff) continue;
934
1111
  candidates.push({ send, resolvedAt: reservation.resolvedAt });
935
1112
  }
@@ -937,11 +1114,15 @@ export function createSpendReservationLedger(options: {
937
1114
  candidates.sort((a, b) => a.resolvedAt - b.resolvedAt);
938
1115
  candidates.length = Math.min(candidates.length, 1);
939
1116
  }
1117
+ let removed = 0;
940
1118
  for (const candidate of candidates) {
1119
+ if (!flushPending() || !append({ v: 1, kind: "forget", send: candidate.send, at })) continue;
941
1120
  reservations.delete(candidate.send);
942
- append({ v: 1, kind: "forget", send: candidate.send, at });
1121
+ nondurableReserves.delete(candidate.send);
1122
+ retainedOrdinary.delete(candidate.send);
1123
+ removed += 1;
943
1124
  }
944
- return candidates.length;
1125
+ return removed;
945
1126
  };
946
1127
 
947
1128
  /**
@@ -949,10 +1130,7 @@ export function createSpendReservationLedger(options: {
949
1130
  * Bounded maps are not enough on their own: the file behind them is what replay reads, and
950
1131
  * an uncompacted file grows forever on unique root and send ids.
951
1132
  */
952
- const compact = (at: number): void => {
953
- const rewrite = journal?.rewrite;
954
- if (!journal || !rewrite || recordsOnDisk < compactAfterRecords()) return;
955
- const checkpoint: JournalRecord = {
1133
+ const checkpointRecord = (at: number): Extract<JournalRecord, { kind: "checkpoint" }> => ({
956
1134
  v: 1,
957
1135
  kind: "checkpoint",
958
1136
  at,
@@ -974,9 +1152,14 @@ export function createSpendReservationLedger(options: {
974
1152
  at: reservation.at,
975
1153
  resolvedAt: reservation.resolvedAt,
976
1154
  })),
977
- };
1155
+ });
1156
+
1157
+ const compact = (at: number): void => {
1158
+ const rewrite = journal?.rewrite;
1159
+ // Restore shipped compaction semantics; corruption refusal persists until clean replay.
1160
+ if (!journal || !rewrite || pendingRecords.length > 0 || recordsOnDisk < compactAfterRecords()) return;
978
1161
  try {
979
- rewrite.call(journal, [JSON.stringify(checkpoint)]);
1162
+ rewrite.call(journal, [JSON.stringify(checkpointRecord(at))]);
980
1163
  recordsOnDisk = 1;
981
1164
  } catch (error) {
982
1165
  if (error instanceof SpendLedgerOwnerError) throw error;
@@ -986,6 +1169,14 @@ export function createSpendReservationLedger(options: {
986
1169
  }
987
1170
  };
988
1171
 
1172
+ const preparePoolContinuity = (requestedProvider?: string): SpendDenial | undefined => {
1173
+ const providers = [...(policy.canonicalProviderIds ?? []), ...(requestedProvider === undefined ? [] : [requestedProvider])];
1174
+ if (!poolContinuity.valid || validatePoolAliasOwners(policy.poolAliases, salt, providers)) {
1175
+ return { reason: "pool-history-unresolved" };
1176
+ }
1177
+ return undefined;
1178
+ };
1179
+
989
1180
  /** The denial when tracking cannot fit this request, or undefined when it can. */
990
1181
  const makeRoom = (refs: readonly ScopeRef[], at: number): SpendDenial | undefined => {
991
1182
  evictSends(at, false);
@@ -993,9 +1184,8 @@ export function createSpendReservationLedger(options: {
993
1184
  while (reservations.size >= maxTrackedSends()) {
994
1185
  if (evictSends(at, true) === 0) return { reason: "tracking-capacity-exhausted" };
995
1186
  }
996
- let fresh = 0;
997
- for (const ref of refs) if (!scopes.has(scopeKey(ref.scope, ref.alias))) fresh += 1;
998
- while (scopes.size + fresh > maxTrackedScopes()) {
1187
+ const freshCount = (): number => refs.filter(ref => !scopes.has(scopeKey(ref.scope, ref.alias))).length;
1188
+ while (scopes.size + freshCount() > maxTrackedScopes()) {
999
1189
  if (evictScopes(at, true) === 0) {
1000
1190
  return { reason: "tracking-capacity-exhausted", scope: refs[0]?.scope };
1001
1191
  }
@@ -1003,7 +1193,106 @@ export function createSpendReservationLedger(options: {
1003
1193
  return undefined;
1004
1194
  };
1005
1195
 
1006
- return {
1196
+ const ledger: SpendReservationLedger = {
1197
+ reserveSeed(request, owner): SpendSeedDecision {
1198
+ assertOwnedAccounting?.();
1199
+ // Initial anchors never borrow already-sent capacity or bypass token/durability checks.
1200
+ const decision = ledger.reserve({ ...request, alreadySent: false });
1201
+ if (!decision.reserved) return decision;
1202
+ const seed: SpendSeed = Object.freeze({ sendId: request.sendId });
1203
+ const send = aliasFor("send", seed.sendId);
1204
+ const state: SeedState = { targets: reservations.get(send)!.targets, sends: new Set([send]), abandoned: false, owner };
1205
+ seeds.set(seed, state);
1206
+ seedBySend.set(send, state);
1207
+ for (const ref of state.targets) {
1208
+ const key = scopeKey(ref.scope, ref.alias);
1209
+ pinnedKeys.set(key, (pinnedKeys.get(key) ?? 0) + 1);
1210
+ }
1211
+ return { reserved: true, seed };
1212
+ },
1213
+ reserveReportedFromSeed(seed, request): boolean {
1214
+ assertOwnedAccounting?.();
1215
+ const owned = seeds.get(seed);
1216
+ const send = aliasFor("send", request.sendId);
1217
+ if (!owned || owned.abandoned || owned.targets.some(ref => !scopes.has(scopeKey(ref.scope, ref.alias)))) return false;
1218
+ const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
1219
+ const existing = reservations.get(send);
1220
+ if (existing || seedBySend.has(send)) {
1221
+ return seedBySend.get(send) === owned && existing !== undefined && isLive(existing.status) && existing.tokens === tokens;
1222
+ }
1223
+ const at = now();
1224
+ const record: JournalRecord = { v: 1, kind: "reserve", send, targets: [...owned.targets], tokens, at };
1225
+ // Enrollment is stable before an owner error can escape the journal write.
1226
+ applyReserve(send, owned.targets, tokens, at);
1227
+ owned.sends.add(send);
1228
+ seedBySend.set(send, owned);
1229
+ appendReported(record);
1230
+ compact(at);
1231
+ return true;
1232
+ },
1233
+ forgetResolved(sendIds, owner): boolean {
1234
+ assertOwnedAccounting?.();
1235
+ if (activeReporters(owner) > 0) return false;
1236
+ const sends = [...new Set(sendIds.map(id => aliasFor("send", id)))];
1237
+ if (sends.some(send => { const seed = seedForSend(send); return seed && seed.owner !== owner; }) || !flushPending()) return false;
1238
+ if (sends.some(send => { const entry = reservations.get(send); return entry && isLive(entry.status); })) return false;
1239
+ for (const send of sends) {
1240
+ if (!reservations.has(send)) continue;
1241
+ if (!append({ v: 1, kind: "forget", send, at: now() })) return false;
1242
+ applyForget(send);
1243
+ }
1244
+ return true;
1245
+ },
1246
+ finishSeed(seed, owner): boolean {
1247
+ assertOwnedAccounting?.();
1248
+ const owned = seeds.get(seed);
1249
+ if (!owned || owned.owner !== owner || !flushPending()) return false;
1250
+ if (owned.abandoned) {
1251
+ // A never-sent provisional anchor cannot have delayed physical reports. Rebinding
1252
+ // during another live producer is safe only for this explicitly abandoned capability.
1253
+ const send = aliasFor("send", seed.sendId);
1254
+ if (reservations.has(send)) {
1255
+ if (!append({ v: 1, kind: "forget", send, at: now() })) return false;
1256
+ applyForget(send);
1257
+ }
1258
+ } else if (activeReporters(owner) > 0) return false;
1259
+ if ([...owned.sends].some(send => reservations.has(send))) return false;
1260
+ seeds.delete(seed);
1261
+ for (const send of owned.sends) seedBySend.delete(send);
1262
+ for (const ref of owned.targets) {
1263
+ const key = scopeKey(ref.scope, ref.alias);
1264
+ const count = pinnedKeys.get(key)! - 1;
1265
+ if (count === 0) pinnedKeys.delete(key); else pinnedKeys.set(key, count);
1266
+ }
1267
+ return true;
1268
+ },
1269
+ registerReporter(owner) {
1270
+ assertOwnedAccounting?.();
1271
+ reporters += 1;
1272
+ if (owner) ownerReporters.set(owner, activeReporters(owner) + 1);
1273
+ let closed = false;
1274
+ return { close() {
1275
+ if (closed) return;
1276
+ closed = true;
1277
+ reporters -= 1;
1278
+ if (owner) {
1279
+ const remaining = activeReporters(owner) - 1;
1280
+ if (remaining === 0) ownerReporters.delete(owner); else ownerReporters.set(owner, remaining);
1281
+ }
1282
+ try { assertOwnedAccounting?.(); retryCleanup(); }
1283
+ finally {
1284
+ if (reporters === 0) { for (const resolve of drainWaiters) resolve(); drainWaiters.clear(); }
1285
+ }
1286
+ } };
1287
+ },
1288
+ deferCleanup(owner, cleanup) {
1289
+ assertOwnedAccounting?.();
1290
+ deferredCleanups.set(owner, cleanup);
1291
+ },
1292
+ waitForReporterDrain() {
1293
+ assertOwnedAccounting?.();
1294
+ return reporters === 0 ? Promise.resolve() : new Promise<void>(resolve => { drainWaiters.add(resolve); });
1295
+ },
1007
1296
  // Every figure this ledger reports describes a journal it must still own. Reporting one
1008
1297
  // after ownership ended is the same error as writing then, with a quieter symptom.
1009
1298
  get persistFailures() { assertOwnedAccounting?.(); return persistFailures; },
@@ -1011,18 +1300,33 @@ export function createSpendReservationLedger(options: {
1011
1300
  get degraded() { assertOwnedAccounting?.(); return persistFailures > 0 || corruptRecords > 0; },
1012
1301
  get policy() { assertOwnedAccounting?.(); return policy; },
1013
1302
 
1303
+ checkPoolContinuity(): SpendDenial | undefined {
1304
+ assertOwnedAccounting?.();
1305
+ if (policy.pool.maxTokens === undefined) return undefined;
1306
+ if (corruptRecords > 0) return { reason: "journal-corrupt", corruptRecords };
1307
+ return preparePoolContinuity();
1308
+ },
1309
+
1310
+ hasUnboundPositivePoolHistory(): boolean {
1311
+ assertOwnedAccounting?.();
1312
+ return hasUnboundPositivePoolHistory();
1313
+ },
1314
+
1014
1315
  reserve(request: SpendReservationRequest): SpendReservationDecision {
1015
1316
  assertOwnedAccounting?.();
1317
+ retryCleanup();
1318
+ const pendingDurable = flushPending(); // Observe-only admissions also retry ordinary repair debt.
1016
1319
  const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
1017
1320
  const at = request.at ?? now();
1018
1321
  const send = aliasFor("send", request.sendId);
1019
1322
  const refs = refsFor(request.scopes);
1020
- const enforced = refs.some((ref) => limitFor(ref.scope) !== undefined);
1323
+ const admissionLimit = (scope: SpendScope) => (request.admissionPolicy ?? policy)[scope].maxTokens;
1324
+ const enforced = refs.some((ref) => admissionLimit(ref.scope) !== undefined);
1021
1325
 
1022
1326
  // A send id this ledger already knows is REFUSED. Returning success while booking
1023
1327
  // nothing -- the old behaviour -- let one id authorise an unlimited number of physical
1024
1328
  // sends with the scope totals never moving.
1025
- if (reservations.has(send)) {
1329
+ if (reservations.has(send) || seedBySend.has(send)) {
1026
1330
  return { reserved: false, denial: { reason: "duplicate-send-id", sendId: request.sendId } };
1027
1331
  }
1028
1332
  // Replay could not prove these totals are complete, so a configured ceiling cannot be
@@ -1030,6 +1334,13 @@ export function createSpendReservationLedger(options: {
1030
1334
  if (enforced && corruptRecords > 0) {
1031
1335
  return { reserved: false, denial: { reason: "journal-corrupt", corruptRecords } };
1032
1336
  }
1337
+ if (enforced && request.alreadySent !== true && !pendingDurable) {
1338
+ return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
1339
+ }
1340
+ const continuityDenial = preparePoolContinuity(request.scopes.poolId);
1341
+ if (continuityDenial && enforced && request.alreadySent !== true) {
1342
+ return { reserved: false, denial: continuityDenial };
1343
+ }
1033
1344
  const capacity = makeRoom(refs, at);
1034
1345
  if (capacity) return { reserved: false, denial: capacity };
1035
1346
 
@@ -1039,9 +1350,10 @@ export function createSpendReservationLedger(options: {
1039
1350
  for (const ref of refs) {
1040
1351
  // A recorded send has no limit to fail: it already happened, and the point of booking
1041
1352
  // it is to let the total go OVER the ceiling so the next request can be refused.
1042
- const limit = request.alreadySent === true ? undefined : limitFor(ref.scope);
1353
+ const limit = request.alreadySent === true ? undefined : admissionLimit(ref.scope);
1043
1354
  if (limit === undefined) continue;
1044
- const state = scopes.get(scopeKey(ref.scope, ref.alias));
1355
+ const poolView = ref.scope === "pool" ? poolState(ref.alias) : undefined;
1356
+ const state = ref.scope === "pool" ? poolView?.totals : scopes.get(scopeKey(ref.scope, ref.alias));
1045
1357
  const projected = (state ? state.settled + state.reserved + state.unresolved : 0) + tokens;
1046
1358
  if (projected > limit) {
1047
1359
  const scopeId = ref.scope === "root"
@@ -1049,7 +1361,10 @@ export function createSpendReservationLedger(options: {
1049
1361
  : ref.scope === "identity" ? request.scopes.identityId : request.scopes.poolId;
1050
1362
  return {
1051
1363
  reserved: false,
1052
- denial: { reason: "spend-limit-exceeded", scope: ref.scope, scopeId: scopeId ?? "", limit, projected },
1364
+ denial: {
1365
+ reason: "spend-limit-exceeded", scope: ref.scope, scopeId: scopeId ?? "", limit, projected,
1366
+ ...(poolView?.includesUnboundHistory ? { includesUnboundPoolHistory: true } : {}),
1367
+ },
1053
1368
  };
1054
1369
  }
1055
1370
  }
@@ -1057,11 +1372,13 @@ export function createSpendReservationLedger(options: {
1057
1372
  // Durability BEFORE admission. The record goes to disk first, and under a configured
1058
1373
  // limit a failed write refuses the request rather than admitting one that a restart
1059
1374
  // would forget -- which is exactly the disk-full and permission case durability is for.
1060
- const durable = append({ v: 1, kind: "reserve", send, targets: refs, tokens, at });
1375
+ const record: JournalRecord = { v: 1, kind: "reserve", send, targets: refs, tokens, at };
1376
+ const durable = append(record);
1061
1377
  if (!durable && enforced && request.alreadySent !== true) {
1062
1378
  return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
1063
1379
  }
1064
1380
  applyReserve(send, refs, tokens, at);
1381
+ if (!durable) nondurableReserves.set(send, record);
1065
1382
  compact(at);
1066
1383
  return { reserved: true, sendId: request.sendId, tokens, durable };
1067
1384
  },
@@ -1070,10 +1387,15 @@ export function createSpendReservationLedger(options: {
1070
1387
  assertOwnedAccounting?.();
1071
1388
  const send = aliasFor("send", sendId);
1072
1389
  const reservation = reservations.get(send);
1390
+ if (reservation?.status === "dispatched" && seedForSend(send)) return true;
1073
1391
  if (!reservation || reservation.status !== "open") return false;
1392
+ repairReservePrefix(send, reservation);
1074
1393
  const at = now();
1075
1394
  applyDispatch(send, at);
1076
- append({ v: 1, kind: "dispatch", send, at });
1395
+ const record: JournalRecord = { v: 1, kind: "dispatch", send, at };
1396
+ const retained = seedForSend(send) || retainedOrdinary.has(send);
1397
+ if (retained) appendReported(record);
1398
+ else append(record);
1077
1399
  return true;
1078
1400
  },
1079
1401
 
@@ -1085,8 +1407,12 @@ export function createSpendReservationLedger(options: {
1085
1407
  // upstream the tokens may already be billed, so the caller owes settle or markLost.
1086
1408
  if (!reservation || reservation.status !== "open") return false;
1087
1409
  const at = now();
1410
+ const record: JournalRecord = { v: 1, kind: "abandon", send, at };
1411
+ const seeded = seedForSend(send);
1412
+ if (seeded && (!flushPending() || !append(record))) return false;
1088
1413
  applyResolve(send, "abandoned", 0, at);
1089
- append({ v: 1, kind: "abandon", send, at });
1414
+ if (seeded) seeded.abandoned = true;
1415
+ else append(record);
1090
1416
  return true;
1091
1417
  },
1092
1418
 
@@ -1095,10 +1421,16 @@ export function createSpendReservationLedger(options: {
1095
1421
  const send = aliasFor("send", sendId);
1096
1422
  const reservation = reservations.get(send);
1097
1423
  if (!reservation || !isLive(reservation.status)) return false;
1424
+ repairReservePrefix(send, reservation);
1098
1425
  const tokens = sanitizeTokens(usage.inputTokens) + sanitizeTokens(usage.outputTokens);
1099
1426
  const at = now();
1427
+ const record: JournalRecord = { v: 1, kind: "settle", send, tokens, at };
1428
+ const seeded = seedForSend(send) || retainedOrdinary.has(send);
1429
+ // Terminal usage cannot be refused: the tracker may never report it again, and
1430
+ // the estimate can be smaller than the actual bill. Preserve ordered retryable debt.
1431
+ if (seeded && (pendingRecords.length > 0 || !append(record))) pendingRecords.push(record);
1100
1432
  applyResolve(send, "settled", tokens, at);
1101
- append({ v: 1, kind: "settle", send, tokens, at });
1433
+ if (!seeded) append(record);
1102
1434
  return true;
1103
1435
  },
1104
1436
 
@@ -1107,22 +1439,28 @@ export function createSpendReservationLedger(options: {
1107
1439
  const send = aliasFor("send", sendId);
1108
1440
  const reservation = reservations.get(send);
1109
1441
  if (!reservation || !isLive(reservation.status)) return false;
1442
+ repairReservePrefix(send, reservation);
1110
1443
  const at = now();
1444
+ const record: JournalRecord = { v: 1, kind: "lost", send, at };
1445
+ const seeded = seedForSend(send) || retainedOrdinary.has(send);
1446
+ if (seeded && (pendingRecords.length > 0 || !append(record))) pendingRecords.push(record);
1111
1447
  applyResolve(send, "lost", 0, at);
1112
- append({ v: 1, kind: "lost", send, at });
1448
+ if (!seeded) append(record);
1113
1449
  return true;
1114
1450
  },
1115
1451
 
1116
1452
  knows(sendId: string): boolean {
1117
1453
  assertOwnedAccounting?.();
1118
- return reservations.has(aliasFor("send", sendId));
1454
+ const send = aliasFor("send", sendId);
1455
+ return reservations.has(send) || seedBySend.has(send);
1119
1456
  },
1120
1457
 
1121
1458
  snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined {
1122
1459
  // Reading accounting from a handle whose ownership has ended is as wrong as writing it:
1123
1460
  // the figures describe a journal this process no longer owns.
1124
1461
  assertOwnedAccounting?.();
1125
- const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
1462
+ const alias = scope === "pool" ? poolAliasFor(scopeId) : aliasFor(scope, scopeId);
1463
+ const state = stateFor(scope, alias);
1126
1464
  if (!state) return undefined;
1127
1465
  return {
1128
1466
  settled: state.settled,
@@ -1132,14 +1470,23 @@ export function createSpendReservationLedger(options: {
1132
1470
  };
1133
1471
  },
1134
1472
 
1135
- exhausted(scope: SpendScope, scopeId: string): boolean {
1473
+ exhausted(scope: SpendScope, scopeId: string, excludingSendId?: string, admissionPolicy?: SpendAdmissionPolicy): boolean {
1136
1474
  assertOwnedAccounting?.();
1137
- const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
1138
- return state !== undefined && isExhausted(scope, state);
1475
+ const alias = scope === "pool" ? poolAliasFor(scopeId) : aliasFor(scope, scopeId);
1476
+ const state = stateFor(scope, alias);
1477
+ if (!state) return false;
1478
+ const own = excludingSendId === undefined ? undefined : reservations.get(aliasFor("send", excludingSendId));
1479
+ const matches = own?.status === "open" && own.targets.some(target => target.scope === scope
1480
+ && (scope === "pool" ? (!poolContinuity.known(target.alias) || poolContinuity.resolve(target.alias) === poolContinuity.resolve(alias)) : target.alias === alias));
1481
+ const totals = matches ? { ...state, reserved: Math.max(0, state.reserved - own.tokens) } : state;
1482
+ const limit = (admissionPolicy ?? policy)[scope].maxTokens;
1483
+ return limit !== undefined && totals.settled + totals.reserved + totals.unresolved >= limit;
1139
1484
  },
1140
1485
 
1141
1486
  prune(at: number = now()): void {
1142
1487
  assertOwnedAccounting?.();
1488
+ flushPending();
1489
+ retryCleanup();
1143
1490
  // Removal requires BOTH inactive and not exhausted inside the window. An
1144
1491
  // exhausted-but-idle scope that was dropped would be recreated fresh under the
1145
1492
  // same id -- the exact laundering the ceiling exists to stop.
@@ -1149,9 +1496,22 @@ export function createSpendReservationLedger(options: {
1149
1496
 
1150
1497
  reconfigure(next: SpendReservationPolicy): void {
1151
1498
  assertOwnedAccounting?.();
1499
+ retryCleanup();
1152
1500
  policy = next;
1501
+ poolContinuity = resolvePoolAliases(policy.poolAliases, salt, policy.canonicalProviderIds);
1153
1502
  },
1154
1503
  };
1504
+ return ledger;
1505
+ }
1506
+
1507
+ /** Drain only an already-constructed ledger; shutdown must not open an unused journal. */
1508
+ export function waitForSharedSpendReporterDrain(): Promise<void> {
1509
+ return sharedLedger?.waitForReporterDrain() ?? Promise.resolve();
1510
+ }
1511
+
1512
+ /** Does no I/O for installations without a pool ceiling. */
1513
+ export function sharedPoolContinuityDenial(): SpendDenial | undefined {
1514
+ return sharedPolicy.pool.maxTokens === undefined ? undefined : sharedSpendLedger().checkPoolContinuity();
1155
1515
  }
1156
1516
 
1157
1517
  let sharedLedger: SpendReservationLedger | undefined;
@@ -1189,9 +1549,11 @@ const spendScopeLimitFromConfig = (scope: OcxSpendScopeConfig | undefined): Spen
1189
1549
  * ceiling would start refusing real traffic on the first upgrade that ran this code, against
1190
1550
  * a number nobody chose. There is deliberately no default figure here at all.
1191
1551
  */
1192
- export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined): SpendReservationPolicy {
1552
+ export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined, poolAliases?: unknown, canonicalProviderIds?: readonly string[]): SpendReservationPolicy {
1193
1553
  const retentionDays = spend?.retentionDays;
1194
1554
  return {
1555
+ ...(poolAliases !== undefined ? { poolAliases } : {}),
1556
+ ...(canonicalProviderIds !== undefined ? { canonicalProviderIds } : {}),
1195
1557
  root: spendScopeLimitFromConfig(spend?.root),
1196
1558
  identity: spendScopeLimitFromConfig(spend?.identity),
1197
1559
  pool: spendScopeLimitFromConfig(spend?.pool),