segreant 0.1.0 → 0.2.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 (142) hide show
  1. package/LICENSING.md +5 -3
  2. package/README.md +57 -19
  3. package/bin/segreant-statusline.mjs +20 -0
  4. package/bin/segreant.mjs +19 -8
  5. package/dist/alerts/detect.js +13 -0
  6. package/dist/billing/epistemic.js +4 -0
  7. package/dist/billing/importer.js +29 -10
  8. package/dist/billing/reconcile.js +122 -32
  9. package/dist/billing/scope.js +7 -0
  10. package/dist/budget/guard.js +33 -4
  11. package/dist/cli/allocCmd.js +73 -4
  12. package/dist/cli/askModelPrices.js +59 -0
  13. package/dist/cli/backupCmd.js +2 -1
  14. package/dist/cli/basisLines.js +45 -0
  15. package/dist/cli/billingCmd.js +68 -9
  16. package/dist/cli/capitalCmd.js +2 -1
  17. package/dist/cli/connectCmd.js +50 -9
  18. package/dist/cli/egressCmd.js +15 -3
  19. package/dist/cli/evidenceCmd.js +6 -3
  20. package/dist/cli/flags.js +68 -0
  21. package/dist/cli/homeCmd.js +117 -0
  22. package/dist/cli/importCmd.js +210 -54
  23. package/dist/cli/launchCmd.js +2 -1
  24. package/dist/cli/opsCmd.js +49 -2
  25. package/dist/cli/planCmd.js +181 -0
  26. package/dist/cli/pricingDeclareCmd.js +178 -0
  27. package/dist/cli/quotaCmd.js +190 -0
  28. package/dist/cli/runCmd.js +203 -51
  29. package/dist/cli/showCmd.js +37 -11
  30. package/dist/cli/teamCmd.js +5 -5
  31. package/dist/cli/ui.js +24 -8
  32. package/dist/cli/valueCmd.js +189 -14
  33. package/dist/cli.js +102 -21
  34. package/dist/config.js +95 -6
  35. package/dist/connect/antigravity.js +310 -0
  36. package/dist/connect/claudeCode.js +43 -3
  37. package/dist/connect/codex.js +38 -1
  38. package/dist/connect/importShared.js +58 -5
  39. package/dist/connect/opencode.js +37 -9
  40. package/dist/connect/protobuf.js +126 -0
  41. package/dist/connect/registry.js +46 -0
  42. package/dist/connect/repoRoot.js +112 -0
  43. package/dist/cost/basis.js +168 -0
  44. package/dist/cost/declarations.js +37 -0
  45. package/dist/cost/exactPricing.js +4 -1
  46. package/dist/cost/firstSight.js +98 -0
  47. package/dist/cost/overrides.js +230 -0
  48. package/dist/cost/pricing.js +228 -15
  49. package/dist/cost/reprice.js +130 -0
  50. package/dist/dashboard/claim-support.js +11 -8
  51. package/dist/dashboard/contracts.js +23 -3
  52. package/dist/dashboard/routes.js +980 -112
  53. package/dist/dashboard/sessions.js +345 -0
  54. package/dist/dashboard/settings.js +2 -1
  55. package/dist/dashboard/value-cache.js +105 -0
  56. package/dist/dashboard/web/app/components/claimInspector.js +6 -5
  57. package/dist/dashboard/web/app/components/drawer.js +105 -35
  58. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  59. package/dist/dashboard/web/app/components/spine.js +54 -24
  60. package/dist/dashboard/web/app/components/workflow.js +82 -0
  61. package/dist/dashboard/web/app/core/actions.js +734 -23
  62. package/dist/dashboard/web/app/core/api.js +37 -1
  63. package/dist/dashboard/web/app/core/chain.js +11 -4
  64. package/dist/dashboard/web/app/core/claimLayers.js +179 -51
  65. package/dist/dashboard/web/app/core/claimTypes.js +30 -0
  66. package/dist/dashboard/web/app/core/fmt.js +19 -2
  67. package/dist/dashboard/web/app/core/generated-contract.js +23 -3
  68. package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
  69. package/dist/dashboard/web/app/core/generated-types.js +1 -1
  70. package/dist/dashboard/web/app/core/griffin.js +10 -0
  71. package/dist/dashboard/web/app/core/jobs.js +12 -0
  72. package/dist/dashboard/web/app/core/period.js +35 -0
  73. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  74. package/dist/dashboard/web/app/core/periods.js +32 -0
  75. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  76. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  77. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  78. package/dist/dashboard/web/app/core/registry.js +79 -46
  79. package/dist/dashboard/web/app/core/slug.js +9 -0
  80. package/dist/dashboard/web/app/core/theme.js +48 -0
  81. package/dist/dashboard/web/app/core/view.js +69 -0
  82. package/dist/dashboard/web/app/main.js +197 -15
  83. package/dist/dashboard/web/app/views/allocation.js +249 -61
  84. package/dist/dashboard/web/app/views/control.js +57 -16
  85. package/dist/dashboard/web/app/views/data.js +4 -4
  86. package/dist/dashboard/web/app/views/evidence.js +336 -87
  87. package/dist/dashboard/web/app/views/privacy.js +13 -0
  88. package/dist/dashboard/web/app/views/spend.js +30 -15
  89. package/dist/dashboard/web/app/views/statement.js +189 -0
  90. package/dist/dashboard/web/app/views/system.js +44 -4
  91. package/dist/dashboard/web/app/views/value.js +105 -59
  92. package/dist/dashboard/web/app/views/workbench.js +312 -0
  93. package/dist/dashboard/web/classic.html +16 -4
  94. package/dist/dashboard/web/index.html +1 -1
  95. package/dist/dashboard/web/styles/app.css +536 -23
  96. package/dist/demo/seed.js +153 -14
  97. package/dist/economics/ledger.js +1 -1
  98. package/dist/egress/policy.js +129 -5
  99. package/dist/export/allocationCsv.js +70 -0
  100. package/dist/export/billingCsv.js +6 -2
  101. package/dist/export/csv.js +10 -2
  102. package/dist/export/economic.js +6 -2
  103. package/dist/export/statementCsv.js +104 -0
  104. package/dist/git/correlate.js +86 -9
  105. package/dist/git/quality.js +107 -24
  106. package/dist/git/repoScope.js +207 -0
  107. package/dist/git/survivalStore.js +186 -0
  108. package/dist/guide.js +9 -4
  109. package/dist/plans/detect.js +147 -0
  110. package/dist/proxy/server.js +134 -14
  111. package/dist/proxy/usage.js +9 -4
  112. package/dist/quota/limits.js +138 -0
  113. package/dist/quota/statusline.js +68 -0
  114. package/dist/quota/view.js +122 -0
  115. package/dist/scan/knownApps.js +6 -2
  116. package/dist/scan/scan.js +9 -0
  117. package/dist/store/allocation.js +14 -0
  118. package/dist/store/db.js +259 -34
  119. package/dist/store/realization.js +78 -14
  120. package/dist/store/schema.js +59 -1
  121. package/dist/store/scopeEvidence.js +67 -0
  122. package/dist/util/pool.js +19 -0
  123. package/dist/util/time.js +7 -0
  124. package/dist/value/realization.js +321 -30
  125. package/dist/value/report.js +28 -8
  126. package/dist/value/selfReported.js +10 -9
  127. package/docs/ARCHITECTURE.md +8 -4
  128. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
  129. package/docs/CLI-JSON-CONTRACT.md +38 -0
  130. package/docs/DATA-BOUNDARIES.md +66 -6
  131. package/docs/FAQ.md +43 -9
  132. package/docs/GETTING-STARTED.md +19 -12
  133. package/docs/GUIDE.md +81 -18
  134. package/docs/INTEGRATIONS.md +9 -9
  135. package/docs/METHODOLOGY.md +3 -2
  136. package/docs/NEUTRALITY.md +1 -1
  137. package/docs/PRIVACY.md +51 -0
  138. package/docs/README.md +1 -0
  139. package/docs/RELEASE-GATE.md +52 -7
  140. package/docs/THREAT-MODEL.md +2 -1
  141. package/package.json +3 -2
  142. package/pricing/models.json +1366 -60
package/LICENSING.md CHANGED
@@ -20,9 +20,9 @@ No account, key, payment or permission is needed for any of that.
20
20
  1. **Offer Segreant as a service.** You may not run it, or a modified version
21
21
  of it, as a hosted or managed service for other people or other companies.
22
22
  Running it for your own organization is fine.
23
- 2. **Sell Segreant itself.** You may not sell, rent or charge a licence fee for
24
- Segreant or a modified version of it, including a copy relabelled under
25
- another name.
23
+ 2. **Sell Segreant itself.** You may not sell, rent, sublicense or otherwise
24
+ charge a fee for Segreant or a modified version of it, including a copy
25
+ relabelled under another name.
26
26
 
27
27
  ## Every version becomes fully open source
28
28
 
@@ -56,5 +56,7 @@ terms, open a
56
56
  under the PolyForm Noncommercial License 1.0.0 (from 2026-10-08 with an
57
57
  individual use permission). Those copies keep the terms they were published
58
58
  with; every later version is under the terms above.
59
+ - The npm package `segreant` 0.1.0 (2026-10-08) is under this license
60
+ (`BUSL-1.1` in its package metadata and LICENSE file).
59
61
 
60
62
  *This page explains the license; it is not legal advice.*
package/README.md CHANGED
@@ -7,11 +7,11 @@
7
7
  **Govern the spend. Not the developer.**
8
8
 
9
9
  The financial control layer for AI coding agents, running on your own machine.
10
- Every dollar your agents spend is metered, capped, checked against the
11
- provider's bill, assigned to the budget it belongs to, and traced to the work
12
- it produced. And every number tells you exactly how much it can be trusted.
10
+ Your agents' spend is metered, capped before a loop runs away with it, checked
11
+ against the provider's bill, assigned to the budget it belongs to, and traced to
12
+ the work it produced. And every number tells you exactly how much it can be trusted.
13
13
 
14
- `local-first` · `zero runtime dependencies` · `2,300+ tests` · `Node 24+` · `free for personal use`
14
+ `local-first` · `zero runtime dependencies` · `3,000+ tests` · `Node 24+` · `free to use, including at work (BUSL-1.1)`
15
15
 
16
16
  [![CI](https://github.com/GamingDragonwastaken/Segreant/actions/workflows/ci.yml/badge.svg)](https://github.com/GamingDragonwastaken/Segreant/actions/workflows/ci.yml)
17
17
 
@@ -87,9 +87,11 @@ No API key, no account, no setup. With Node 24 or newer:
87
87
  npx segreant demo --serve # labelled synthetic data; opens the dashboard
88
88
  ```
89
89
 
90
- Open **http://localhost:8091**. You'll see spend by project and model, budget
91
- controls and governance alerts, the Return on Intelligence view, and a
92
- review-only cheaper-model trial. It all runs on an isolated `demo.db` and is
90
+ Open **http://localhost:8091**. You'll see a month's statement with all four
91
+ figures, spend by project and model with each session and what its commits
92
+ came to, caps and alerts, a comparison with a generated provider bill,
93
+ cost-centre splits, and what the spend produced, with a review-only
94
+ cheaper-model comparison. It all runs on an isolated `demo.db` and is
93
95
  labelled as synthetic. Clear it with `segreant demo --clear`.
94
96
 
95
97
  ## Use it on your real work
@@ -100,9 +102,16 @@ Install the `segreant` command (it has no runtime dependencies):
100
102
  npm install -g segreant
101
103
  ```
102
104
 
105
+ On Windows, a project-local install (`npm install segreant`) inside a folder
106
+ whose path contains `&` cannot start through `npx` or `node_modules\.bin`:
107
+ npm's generated `.cmd` shim does not quote that path. The global install above
108
+ and `npx segreant` from any folder are not affected. For a local install there,
109
+ run `node node_modules/segreant/bin/segreant.mjs` instead.
110
+
103
111
  **Option A: no wiring at all.** If you use Claude Code, Codex or opencode,
104
112
  Segreant reads the usage those tools already log on your machine, including
105
- subscription usage a proxy never sees:
113
+ subscription usage a proxy never sees. Antigravity is read from 0.2.0, which is
114
+ unreleased and not yet on npm:
106
115
 
107
116
  ```bash
108
117
  segreant scan # finds your AI tools and git repos; changes nothing
@@ -131,10 +140,12 @@ export OPENAI_BASE_URL="http://localhost:8090/v1"
131
140
 
132
141
  A tool pointed at the proxy this way cannot connect while Segreant is stopped.
133
142
 
134
- Segreant starts **locked**: it forwards nothing to a cloud provider until you
135
- grant that exact route with `segreant egress apply`; the two commands for OpenAI
136
- and Anthropic are in [GETTING-STARTED.md](docs/GETTING-STARTED.md). Then set a
137
- cap:
143
+ Requests to OpenAI's and Anthropic's official APIs go through as soon as Segreant
144
+ starts: that traffic was already going to your provider, and Segreant meters it
145
+ on the way. Everything Segreant would send on its own (webhooks, team rollups,
146
+ hosted judging) stays off until you allow it, and another provider or gateway
147
+ needs one rule, which a refused request prints
148
+ ([GETTING-STARTED.md](docs/GETTING-STARTED.md)). Then set a cap:
138
149
 
139
150
  ```bash
140
151
  segreant budget --daily 25 --soft 18 --runaway 2 # hard cap, warning, loop guard
@@ -166,11 +177,34 @@ Per-tool recipes (Cursor, aider, opencode, Antigravity, your own SDK scripts):
166
177
  - **Keep your data yours.** SQLite on your disk, verified backup and restore,
167
178
  and evidence packs you can sign and verify.
168
179
 
180
+ ### In the dashboard, without the terminal
181
+
182
+ `segreant start` serves a local dashboard organised by job:
183
+
184
+ - **Statement**: one calendar month, side by side. What was measured, what the
185
+ provider billed, how it was split, and what it produced. The four are never
186
+ added together, and the month downloads as a CSV.
187
+ - **Spend**: where the measured spend went, by project, model and source.
188
+ - **Protect**: caps and alerts that stop spend before it happens.
189
+ - **Close**: declare the OpenAI project, bring in the cost export, and record
190
+ the comparison with what Segreant measured.
191
+ - **Allocate**: cost centres, the rules that assign spend to them, and the
192
+ monthly split, previewed before it is recorded.
193
+ - **Value**: what the spend produced, with the limits stated.
194
+
195
+ - **Settings**: choose what you see, from density to which jobs sit in the top
196
+ bar, and a map of everything Segreant can do. Each capability the dashboard
197
+ runs opens from that map in one click.
198
+
199
+ Splits, bill comparisons, imports, adoptions and budget changes show a preview
200
+ before anything is saved. The dashboard follows your system's light or dark
201
+ setting, works at phone width, and loads nothing from the internet.
202
+
169
203
  Every command, flag and design detail is in the **[Segreant guide](docs/GUIDE.md)**.
170
204
 
171
205
  ## For teams and enterprises
172
206
 
173
- The same local tool scales up without sending prompts or code anywhere:
207
+ The same local tool scales up without sending prompts or code to any Segreant-run service:
174
208
 
175
209
  - **Team rollups.** Each machine signs and pushes per-project value and spend
176
210
  summaries to a team server *you* run. Per-developer views are opt-in,
@@ -211,13 +245,17 @@ Segreant has no hosted service and sends no telemetry by default. The ledger
211
245
  lives under `~/.segreant`, and the dashboard loads nothing from third parties:
212
246
  no CDNs, fonts or analytics. Requests you route through the proxy still go to
213
247
  the AI provider you configured, and your API keys pass through without being
214
- stored. Every other outbound path (price-card refresh, alert webhooks, team
215
- rollups) is opt-in and listed in [DATA-BOUNDARIES.md](docs/DATA-BOUNDARIES.md).
248
+ stored. Segreant also downloads the public price list when it meets a model
249
+ its rate card does not know; that download sends nothing about you, and
250
+ `segreant pricing --auto off` stops it. Every other outbound path (alert
251
+ webhooks, team rollups) is opt-in. The privacy statement is [PRIVACY.md](docs/PRIVACY.md), also
252
+ one click from every dashboard page; every path in detail is in
253
+ [DATA-BOUNDARIES.md](docs/DATA-BOUNDARIES.md).
216
254
 
217
255
  ## Status
218
256
 
219
257
  Segreant is **pre-release (0.1.0)**, published on npm as `segreant`. CI covers Linux, macOS and Windows, including a packaged-install
220
- smoke test, a browser accessibility pass and supply-chain checks.
258
+ smoke test, real-browser tests and supply-chain checks.
221
259
  Reconciliation and outcome measurement are implemented and tested but have not
222
260
  yet been validated against real provider accounts or with outside users. That
223
261
  is what the first users will prove.
@@ -240,10 +278,10 @@ The full index is in [docs/README.md](docs/README.md).
240
278
 
241
279
  | | |
242
280
  |---|---|
243
- | Product code | ~88,000 lines of strict TypeScript |
244
- | Tests | ~70,000 lines, 2,300+ test cases across the CLI, dashboard and team server |
281
+ | Product code | ~105,000 lines of strict TypeScript |
282
+ | Tests | ~81,000 lines, 3,000+ test cases across the CLI, dashboard, team server and a real browser |
245
283
  | Runtime dependencies | 0 |
246
- | CI | 11 jobs on every pull request: three operating systems, the team server against real PostgreSQL, a packaged install, supply chain, accessibility, and exact-head verification |
284
+ | CI | 14 checks on every pull request: three operating systems, install paths with spaces and `&`, the team server against real PostgreSQL, a packaged install, supply chain, real-browser tests, and exact-head verification |
247
285
 
248
286
  ## Development
249
287
 
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code status line: `"statusLine": { "type": "command", "command": "segreant-statusline" }`.
3
+ // Deliberately bypasses the full launcher (re-exec, runtime snapshot, CLI load):
4
+ // a status line runs on every refresh and must answer in about a tenth of a
5
+ // second. Prints one line on stdout and always exits 0, so a failure here can
6
+ // never break the person's status bar.
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ let line = 'segreant';
10
+ try {
11
+ let raw = '';
12
+ try { raw = readFileSync(0, 'utf8').slice(0, 1_000_000); } catch { raw = ''; }
13
+ const { dbPath } = await import('../dist/config.js');
14
+ const { statuslineLine } = await import('../dist/quota/statusline.js');
15
+ line = statuslineLine(dbPath(), raw, Date.now());
16
+ } catch {
17
+ line = 'segreant';
18
+ }
19
+ process.stdout.write(line + '\n');
20
+ process.exit(0);
package/bin/segreant.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // path that a userland process.emitWarning override can't intercept, so we
7
7
  // re-exec ourselves once with --disable-warning to keep output clean. This is
8
8
  // the only knob that reliably suppresses it for `npx segreant`.
9
- import { spawnSync } from 'node:child_process';
9
+ import { spawn } from 'node:child_process';
10
10
  import { acquirePublicationLock } from './publication-lock.mjs';
11
11
  import { createRuntimeSnapshot, reapOrphanRuntimeSnapshots } from './runtime-snapshot.mjs';
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -27,18 +27,29 @@ if (major < 24) {
27
27
  const self = fileURLToPath(import.meta.url);
28
28
 
29
29
  if (!process.env.__SEGREANT_CHILD) {
30
- const result = spawnSync(
30
+ // Asynchronous, so the launcher can forward signals. With spawnSync it could
31
+ // not: a SIGTERM to `segreant` (a supervisor, `kill <pid>`) ended the
32
+ // launcher and left a running `start` or dashboard behind as an orphan.
33
+ // Ctrl+C in a terminal worked only because it signals the whole group.
34
+ const child = spawn(
31
35
  process.execPath,
32
36
  ['--disable-warning=ExperimentalWarning', self, ...process.argv.slice(2)],
33
37
  { stdio: 'inherit', env: { ...process.env, __SEGREANT_CHILD: '1' } },
34
38
  );
35
- if (result.error) {
36
- console.error(`Segreant could not start its runtime: ${result.error.message}`);
37
- process.exit(1);
39
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
40
+ process.on(signal, () => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); });
38
41
  }
39
- if (typeof result.status === 'number') process.exit(result.status);
40
- console.error(`Segreant runtime terminated by signal ${result.signal ?? 'unknown'}`);
41
- process.exit(1);
42
+ child.on('error', (error) => {
43
+ console.error(`Segreant could not start its runtime: ${error.message}`);
44
+ process.exit(1);
45
+ });
46
+ child.on('exit', (code, signal) => {
47
+ if (typeof code === 'number') process.exit(code);
48
+ // A child ended by a forwarded SIGINT/SIGTERM is the requested stop.
49
+ if (signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP') process.exit(signal === 'SIGINT' ? 130 : 143);
50
+ console.error(`Segreant runtime terminated by signal ${signal ?? 'unknown'}`);
51
+ process.exit(1);
52
+ });
42
53
  } else {
43
54
  const here = dirname(self);
44
55
  // Collect snapshots whose owning process is gone before adding another one,
@@ -171,6 +171,17 @@ export function detectAlerts(inp) {
171
171
  metric: `${Math.round(inp.estimatedShare * 100)}% of last-7d spend estimated`,
172
172
  });
173
173
  }
174
+ // Prices the user set are not estimates of a list price, and not checked
175
+ // against one either; said apart from "approximate" so neither is mislabelled.
176
+ if ((inp.declaredShare ?? 0) > 0.2) {
177
+ out.push({
178
+ id: 'declared-pricing',
179
+ severity: 'info',
180
+ title: 'Some costs use prices you set',
181
+ detail: 'Part of recent spend is priced at prices set with segreant pricing set; they are not checked against a price list or your bill.',
182
+ metric: `${Math.round((inp.declaredShare ?? 0) * 100)}% of last-7d spend at prices you set`,
183
+ });
184
+ }
174
185
  return out.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
175
186
  }
176
187
  /** Gather the inputs once, so alerts and their coverage are read from the same observation. */
@@ -194,6 +205,7 @@ function gatherAlertInputs(store, config, opts) {
194
205
  const blocked24h = store.healthStats(now - day, now + 1000).blocked;
195
206
  const week = store.healthStats(now - 7 * day, now + 1000);
196
207
  const estimatedShare = week.totalCostUsd > 0 ? week.estimatedCostUsd / week.totalCostUsd : 0;
208
+ const declaredShare = week.totalCostUsd > 0 ? week.declaredCostUsd / week.totalCostUsd : 0;
197
209
  // Carried alongside the share so coverage can tell an unestimated window from
198
210
  // an empty one; the share alone cannot (D-141).
199
211
  const pricedWindowSpendUsd = week.totalCostUsd;
@@ -217,6 +229,7 @@ function gatherAlertInputs(store, config, opts) {
217
229
  baselinePrunedBeforeMs,
218
230
  blocked24h,
219
231
  estimatedShare,
232
+ declaredShare,
220
233
  pricedWindowSpendUsd,
221
234
  runaway,
222
235
  realizedSpendShare: opts.realizedSpendShare ?? null,
@@ -503,6 +503,9 @@ function buildLocalCaptureEvidence(reconciliation, reconciliationRunId, issuedAt
503
503
  providerProjectRef: reconciliation.providerProjectRef,
504
504
  observationRunId: reconciliation.observationRunId,
505
505
  reconciliationRunId,
506
+ // How the exact local amounts became this microdollar total, when a rule
507
+ // applied; absent on runs recorded before it, which rounded nothing.
508
+ ...(reconciliation.localQuantization ? { localQuantization: { ...reconciliation.localQuantization } } : {}),
506
509
  },
507
510
  scope: scope({ provider: 'openai', declaredScopeId: reconciliation.declaredScopeId, providerProjectRef: reconciliation.providerProjectRef }),
508
511
  grain: grain(['provider_project_period']),
@@ -590,6 +593,7 @@ export function billingReconciliationClaim(input) {
590
593
  // reader would get a residual with no statement of whether it bounds
591
594
  // off-path spend at all.
592
595
  offPathBound: input.run.offPathBound,
596
+ ...(input.run.localQuantization ? { localQuantization: { ...input.run.localQuantization } } : {}),
593
597
  snapshotStability: input.run.snapshotStability,
594
598
  providerSourceKind: input.run.providerSourceKind,
595
599
  ...(input.reconciliationRunId === undefined ? {} : { reconciliationRunId: nonEmpty(input.reconciliationRunId, 'reconciliation run id') }),
@@ -32,25 +32,44 @@ function preview(document, fileName, fileSha256, fileSizeBytes) {
32
32
  reconciliationStatus: 'not_reconciled',
33
33
  };
34
34
  }
35
+ function sizeLimitError() {
36
+ return new Error(`billing evidence exceeds the ${MAX_BILLING_IMPORT_BYTES / (1024 * 1024)} MiB v1 import limit`);
37
+ }
35
38
  /** Read, size-gate, hash, and strictly validate a Segreant billing-evidence v1 JSON file. */
36
39
  export function readBillingImportFile(path) {
37
40
  const stat = statSync(path);
38
41
  if (!stat.isFile())
39
42
  throw new Error('billing import --file must point to a regular local file');
40
- if (stat.size > MAX_BILLING_IMPORT_BYTES) {
41
- throw new Error(`billing evidence exceeds the ${MAX_BILLING_IMPORT_BYTES / (1024 * 1024)} MiB v1 import limit`);
42
- }
43
- const raw = readFileSync(path);
43
+ // Refuse before reading: a multi-gigabyte file must never be pulled into memory.
44
+ if (stat.size > MAX_BILLING_IMPORT_BYTES)
45
+ throw sizeLimitError();
46
+ return parseBillingImportText(readFileSync(path), basename(path));
47
+ }
48
+ /**
49
+ * The validation readBillingImportFile applies, over bytes already in hand. The
50
+ * dashboard import route calls this directly; the CLI reaches it through
51
+ * readBillingImportFile, so the two surfaces give the same preview for the same bytes.
52
+ */
53
+ export function parseBillingImportText(raw, fileName) {
54
+ if (raw.byteLength > MAX_BILLING_IMPORT_BYTES)
55
+ throw sizeLimitError();
44
56
  let value;
45
57
  try {
46
- value = JSON.parse(raw.toString('utf8'));
58
+ // A UTF-8 byte-order mark is not JSON, and Windows tools write one by
59
+ // default, so an export saved from Notepad was refused as "not valid JSON".
60
+ // It is skipped for parsing only: the digest below is over the file's own
61
+ // bytes, mark included, so the same file still has one identity.
62
+ const text = raw.toString('utf8');
63
+ value = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
47
64
  }
48
- catch (error) {
49
- throw new Error(`billing evidence is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
65
+ catch {
66
+ // Parser diagnostics can quote the file contents, which belong only in the
67
+ // operator's export, never in HTTP responses or CLI error output.
68
+ throw new Error('billing evidence is not valid JSON');
50
69
  }
51
70
  const document = parseBillingImportDocument(value);
52
- const fileName = basename(path);
53
71
  const fileSha256 = createHash('sha256').update(raw).digest('hex');
54
- const input = { document, fileName, fileSha256, fileSizeBytes: stat.size, format: 'json' };
55
- return { input, preview: preview(document, fileName, fileSha256, stat.size) };
72
+ const fileSizeBytes = raw.byteLength;
73
+ const input = { document, fileName, fileSha256, fileSizeBytes, format: 'json' };
74
+ return { input, preview: preview(document, fileName, fileSha256, fileSizeBytes) };
56
75
  }
@@ -107,6 +107,122 @@ export function describeOffPathBound(bound) {
107
107
  function refuse(refusal, detail) {
108
108
  return { status: 'refused', refusal, detail };
109
109
  }
110
+ /**
111
+ * The rounding a comparison applied to the local side, in words, or null when
112
+ * it changed nothing. A run recorded before the rule existed rounded nothing
113
+ * either: it refused any exact amount finer than a microdollar.
114
+ */
115
+ export function describeLocalQuantization(q) {
116
+ if (!q || q.daysRounded === 0)
117
+ return null;
118
+ const days = `${q.daysRounded} day total${q.daysRounded === 1 ? '' : 's'}`;
119
+ return `Local amounts finer than a microdollar were summed exactly for each day; ${days} then rounded to the microdollar, half to even, so the local figure is within $${(q.daysRounded * 0.0000005).toFixed(7)} of the exact sum.`;
120
+ }
121
+ /** An exact decimal (coefficient x 10^-scale) as bigint microdollars, rounded half to even. */
122
+ function halfEvenMicrosBig(coefficient, scale) {
123
+ const shift = scale - 6;
124
+ if (shift <= 0)
125
+ return { micros: coefficient * 10n ** BigInt(-shift), rounded: false };
126
+ const divisor = 10n ** BigInt(shift);
127
+ const negative = coefficient < 0n;
128
+ const abs = negative ? -coefficient : coefficient;
129
+ let q = abs / divisor;
130
+ const r = abs % divisor;
131
+ const twice = r * 2n;
132
+ if (twice > divisor || (twice === divisor && q % 2n === 1n))
133
+ q += 1n;
134
+ return { micros: negative ? -q : q, rounded: r !== 0n };
135
+ }
136
+ /**
137
+ * An exact amount as an integer coefficient and a decimal scale, read through
138
+ * `formatMoneyAmount` so this stays a visible monetary read site (the FX
139
+ * consumer sweep classifies every file that reads Money).
140
+ */
141
+ function exactParts(value) {
142
+ const text = formatMoneyAmount(value);
143
+ const negative = text.startsWith('-');
144
+ const [whole, fraction = ''] = (negative ? text.slice(1) : text).split('.');
145
+ const coefficient = BigInt(`${whole}${fraction}`);
146
+ return { coefficient: negative ? -coefficient : coefficient, scale: fraction.length };
147
+ }
148
+ /** bigint microdollars as a number, or null when a number cannot hold them exactly. */
149
+ function safeMicros(micros) {
150
+ const max = BigInt(Number.MAX_SAFE_INTEGER);
151
+ return micros > max || micros < -max ? null : Number(micros);
152
+ }
153
+ /**
154
+ * An exact USD amount in microdollars, rounded half to even; says whether
155
+ * rounding changed it. Throws rather than return a number that lost digits.
156
+ */
157
+ export function halfEvenMicros(value) {
158
+ const { coefficient, scale } = exactParts(value);
159
+ const { micros, rounded } = halfEvenMicrosBig(coefficient, scale);
160
+ const n = safeMicros(micros);
161
+ if (n === null)
162
+ throw new Error('amount is outside the supported fixed-point range');
163
+ return { micros: n, rounded };
164
+ }
165
+ function addExact(day, coefficient, scale) {
166
+ if (scale > day.scale) {
167
+ day.coefficient *= 10n ** BigInt(scale - day.scale);
168
+ day.scale = scale;
169
+ }
170
+ day.coefficient += coefficient * 10n ** BigInt(day.scale - scale);
171
+ }
172
+ /**
173
+ * Local capture per UTC day under the reconciliation's one rule: each row's
174
+ * exact amount (or, for a legacy row, its rate-card projection) is summed
175
+ * exactly per day, and only the day total is rounded to the microdollar, half
176
+ * to even. Anything that compares itself with a reconciliation's local figure
177
+ * uses this, so the two cannot disagree by a rounding: a period total rounded
178
+ * once from floats could come out below the per-day sum of a subset of it.
179
+ */
180
+ export function quantizeLocalDays(rows, accept) {
181
+ const exactByDay = new Map();
182
+ for (const row of rows) {
183
+ if (!accept(row))
184
+ continue;
185
+ const key = utcDayStart(row.tsEpochMs);
186
+ const bucket = exactByDay.get(key) ?? { coefficient: 0n, scale: 6, requests: 0 };
187
+ if (row.economicAmount !== undefined) {
188
+ if (row.economicAmount.currency !== 'USD') {
189
+ return { refusal: {
190
+ code: 'local_exact_amount_is_not_usd',
191
+ message: `request ${row.requestId} has an exact local amount in ${row.economicAmount.currency}; this reconciliation has no FX policy`,
192
+ } };
193
+ }
194
+ // Kept exact until the day is summed. Per-token prices give amounts like
195
+ // 0.0014008, finer than the provider's microdollars; refusing them here
196
+ // refused almost every real OpenAI route (found seeding the demo bill,
197
+ // 2026-10-16). The rule is stated in the result, not invented silently.
198
+ const parts = exactParts(row.economicAmount);
199
+ addExact(bucket, parts.coefficient, parts.scale);
200
+ }
201
+ else {
202
+ // Legacy rows have no exact economic event. Retain their compatibility
203
+ // projection explicitly as the rate-card estimate documented by the
204
+ // result, and add it into the same exact sum: rounding the exact part
205
+ // alone and adding the legacy part after could round a tie the wrong way.
206
+ addExact(bucket, BigInt(Math.round(row.costUsd * 1_000_000)), 6);
207
+ }
208
+ bucket.requests += 1;
209
+ exactByDay.set(key, bucket);
210
+ }
211
+ // Each day's exact sum becomes microdollars once, rounded half to even, and
212
+ // is checked against the number range before it leaves bigint.
213
+ let daysRounded = 0;
214
+ const localByDay = new Map();
215
+ for (const [key, day] of exactByDay) {
216
+ const { micros, rounded } = halfEvenMicrosBig(day.coefficient, day.scale);
217
+ const n = safeMicros(micros);
218
+ if (n === null)
219
+ return { refusal: { code: 'local_exact_amount_requires_explicit_quantization', message: 'a day total is outside the supported fixed-point range' } };
220
+ if (rounded)
221
+ daysRounded += 1;
222
+ localByDay.set(key, { micros: n, requests: day.requests });
223
+ }
224
+ return { localByDay, daysRounded };
225
+ }
110
226
  /** UTC day key for a local ledger row, matching the provider's bucket boundary. */
111
227
  function utcDayStart(tsEpochMs) {
112
228
  return Math.floor(tsEpochMs / DAY_MS) * DAY_MS;
@@ -182,38 +298,11 @@ export function reconcileOpenAiCosts(input) {
182
298
  else
183
299
  providerByDay.set(line.bucketStartMs, [line]);
184
300
  }
185
- const localByDay = new Map();
186
- for (const row of input.requests) {
187
- if (!isOnDeclaredRoute(row, input.run.declaredScopeId))
188
- continue;
189
- if (row.tsEpochMs < input.run.periodStartMs || row.tsEpochMs >= input.run.periodEndMs)
190
- continue;
191
- const key = utcDayStart(row.tsEpochMs);
192
- const bucket = localByDay.get(key) ?? { micros: 0, requests: 0 };
193
- let capturedMicros;
194
- if (row.economicAmount !== undefined) {
195
- if (row.economicAmount.currency !== 'USD') {
196
- return refuse('local_exact_amount_is_not_usd', `request ${row.requestId} has an exact local amount in ${row.economicAmount.currency}; this reconciliation has no FX policy`);
197
- }
198
- try {
199
- // Preserve the canonical exact amount until the fixed-point boundary.
200
- // If it cannot be represented in provider microdollars, refusing is safer
201
- // than inventing a rounding mode for a consequential comparison.
202
- capturedMicros = usdMicros(formatMoneyAmount(row.economicAmount), `request ${row.requestId} exact amount`);
203
- }
204
- catch (error) {
205
- return refuse('local_exact_amount_requires_explicit_quantization', `request ${row.requestId} exact local amount cannot be represented in microdollars without an explicit quantization policy: ${error instanceof Error ? error.message : String(error)}`);
206
- }
207
- }
208
- else {
209
- // Legacy rows have no exact economic event. Retain their compatibility
210
- // projection explicitly as the rate-card estimate documented by the result.
211
- capturedMicros = Math.round(row.costUsd * 1_000_000);
212
- }
213
- bucket.micros += capturedMicros;
214
- bucket.requests += 1;
215
- localByDay.set(key, bucket);
216
- }
301
+ const quantized = quantizeLocalDays(input.requests, (row) => isOnDeclaredRoute(row, input.run.declaredScopeId)
302
+ && row.tsEpochMs >= input.run.periodStartMs && row.tsEpochMs < input.run.periodEndMs);
303
+ if ('refusal' in quantized)
304
+ return refuse(quantized.refusal.code, quantized.refusal.message);
305
+ const { localByDay, daysRounded } = quantized;
217
306
  const days = [];
218
307
  const allDays = [...new Set([...providerByDay.keys(), ...localByDay.keys()])].sort((a, b) => a - b);
219
308
  let providerTotal = 0;
@@ -290,6 +379,7 @@ export function reconcileOpenAiCosts(input) {
290
379
  },
291
380
  days,
292
381
  snapshotStability,
382
+ localQuantization: { rule: 'day_total_half_even_micros', daysRounded },
293
383
  unstableDayStartMs,
294
384
  providerSourceKind: sourceKind,
295
385
  // The fifth condition appears only when it is true. A reader who sees four
@@ -18,6 +18,13 @@ function safeRef(value, label, required) {
18
18
  throw new Error(`${label} is required`);
19
19
  if (trimmed.length > MAX_REF || CONTROL.test(trimmed))
20
20
  throw new Error(`${label} must be a short, single-line reference`);
21
+ // A reference is stored for good and issued to the kernel, so a key typed into
22
+ // this field would become durable ledger content. OpenAI keys start `sk-`
23
+ // (sk-proj-, sk-admin-, sk-svcacct-); restricted keys `rk-`. Refuse the shape
24
+ // outright rather than store it and hope nobody exports the ledger.
25
+ if (/^(sk|rk)-/i.test(trimmed) || /^bearer\s/i.test(trimmed)) {
26
+ throw new Error(`${label} looks like an API key. Use the organisation id (org_…) or project id (proj_…) from your OpenAI settings; never a key.`);
27
+ }
21
28
  return trimmed;
22
29
  }
23
30
  /** Canonical, credential-free identity of the configured upstream endpoint. */
@@ -125,18 +125,47 @@ export function resolveEnforcedSpend(exact, floatUsd) {
125
125
  };
126
126
  }
127
127
  /** One clause naming the basis, appended to whatever refusal or warning states the number. */
128
+ /**
129
+ * Spend on the basis a cap governs, over [startMs, endMs), projected exactly
130
+ * as the guard projects it (resolveEnforcedSpend), with the number of requests
131
+ * behind it. The guide and the budget advisor read this rather than a series
132
+ * of their own: a separate series could say "no proxied spend" while the guard
133
+ * was blocking on it (review 4, F3).
134
+ */
135
+ export function capBasisSpend(store, budget, startMs, endMs) {
136
+ const liveOnly = !budget.capIncludesImported;
137
+ const exact = typeof store.exactSpendBetween === 'function' ? store.exactSpendBetween(startMs, endMs, liveOnly) : null;
138
+ const resolved = resolveEnforcedSpend(exact, store.spendBetween(startMs, endMs, liveOnly));
139
+ return { usd: resolved.usd, requests: exact?.requestCount ?? null, basis: liveOnly ? 'live_proxy' : 'all_observed' };
140
+ }
128
141
  export function describeSpendBasis(basis) {
129
142
  if (basis.enforcedAgainst === 'rate_card_float' && basis.exactResolvedUsd === null) {
130
- return 'Basis: local rate-card estimates, with no exact economic projection available.';
143
+ return 'Basis: local estimates (list prices, and any prices you set), with no exact economic projection available.';
131
144
  }
132
145
  const bases = basis.sourceBases.length > 0 ? basis.sourceBases.join(', ') : 'none declared';
133
146
  if (basis.complete)
134
147
  return `Basis: effective economic charges (${bases}), complete for this window.`;
135
148
  const which = basis.enforcedAgainst === 'exact_effective'
136
149
  ? `effective economic charges (${bases})`
137
- : 'local rate-card estimates';
150
+ : 'local estimates (list prices, and any prices you set)';
138
151
  return `Basis: ${which}, a lower bound — ${basis.unresolvedRequests} of ${basis.requestCount} requests in this window are unpriced.`;
139
152
  }
153
+ /**
154
+ * Spend beside the cap it crossed. Two decimals hid the overshoot: $5.004
155
+ * against a $5 cap read "$5.00 of $5.00". When the two would round alike but
156
+ * differ, add digits (to the microdollar) until they do not. A difference
157
+ * below the microdollar is said in words, so two different amounts never
158
+ * print as the same figure (PR #142 review).
159
+ */
160
+ export function spendBesideCap(spendUsd, capUsd) {
161
+ let digits = 2;
162
+ while (digits < 6 && spendUsd !== capUsd && spendUsd.toFixed(digits) === capUsd.toFixed(digits))
163
+ digits += 1;
164
+ const shown = '$' + spendUsd.toFixed(digits);
165
+ if (spendUsd === capUsd || spendUsd.toFixed(digits) !== capUsd.toFixed(digits))
166
+ return shown;
167
+ return `${spendUsd > capUsd ? 'just over' : 'just under'} ${shown}`;
168
+ }
140
169
  export function startOfLocalDay(now = Date.now()) {
141
170
  const d = new Date(now);
142
171
  d.setHours(0, 0, 0, 0);
@@ -250,7 +279,7 @@ export class BudgetGuard {
250
279
  return {
251
280
  ...base,
252
281
  action: 'block',
253
- reason: `Daily budget reached: $${daySpend.toFixed(2)} of $${dailyLimit.toFixed(2)} cap` +
282
+ reason: `Daily budget reached: ${spendBesideCap(daySpend, dailyLimit)} of $${dailyLimit.toFixed(2)} cap` +
254
283
  (liveOnly ? ' (live proxy spend; imported spend excluded). ' : ' (includes imported spend). ') +
255
284
  describeSpendBasis(day.basis),
256
285
  };
@@ -259,7 +288,7 @@ export class BudgetGuard {
259
288
  return {
260
289
  ...base,
261
290
  action: 'block',
262
- reason: `Session budget reached: $${session.usd.toFixed(2)} of $${cfg.sessionUsd.toFixed(2)} cap. `
291
+ reason: `Session budget reached: ${spendBesideCap(session.usd, cfg.sessionUsd)} of $${cfg.sessionUsd.toFixed(2)} cap. `
263
292
  + describeSpendBasis(session.basis),
264
293
  };
265
294
  }