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
@@ -17,37 +17,51 @@
17
17
  import { reconciliationReadiness } from '../billing/readiness.js';
18
18
  import { CLAIM_USES } from '../epistemic/claim-uses.js';
19
19
  import { EXACT_ALLOCATION_EXCLUDED_FLOOR } from '../alloc/exact.js';
20
+ import { ALLOCATION_METHODS, MATCH_FIELDS } from '../alloc/rules.js';
20
21
  import { existsSync, statSync } from 'node:fs';
22
+ import { posix, win32 } from 'node:path';
21
23
  import { allocatedClaimSupport, billedClaimSupport, meteredClaimSupport, realizedClaimSupport, } from './claim-support.js';
22
- import { isDemo } from '../config.js';
24
+ import { isDemo, loadConfig, DemoReadOnlyError, DemoWriteBlockedError } from '../config.js';
25
+ import { MAX_BILLING_IMPORT_BYTES, parseBillingImportText } from '../billing/importer.js';
26
+ import { newOpenAiScopeDeclaration } from '../billing/scope.js';
27
+ import { reconciliationCountermodels } from '../billing/countermodels.js';
28
+ import { assessAssumptionFragility } from '../epistemic/countermodel.js';
29
+ import { moneyToJson } from '../economics/money.js';
23
30
  import { probeProxyState } from '../egress/proxyHealth.js';
24
31
  import { buildSettingsSnapshot, applySettingsPatch, SettingsValidationError } from './settings.js';
25
32
  import { serveHtml } from './static.js';
26
- import { resolveEnforcedSpend, startOfLocalDay } from '../budget/guard.js';
33
+ import { capBasisSpend, resolveEnforcedSpend, startOfLocalDay } from '../budget/guard.js';
27
34
  import { driftDetectability } from '../value/drift.js';
28
- import { loadRealization, realizeDiscoveredProjects } from '../value/realization.js';
35
+ import { loadRealization, realizeDiscoveredProjects, realizationFromStore, keptSummary } from '../value/realization.js';
29
36
  // The one composition of the value primitives, shared with the CLI — see the
30
37
  // '/api/value' handler below and src/value/report.ts for why it is not inline.
31
38
  import { valueReport } from '../value/report.js';
39
+ import { buildSessions } from './sessions.js';
32
40
  import { recordReportedOutcome } from '../value/selfReported.js';
33
41
  import { buildMarketReport, loadMarket } from '../market/market.js';
34
- import { projectName } from '../git/correlate.js';
42
+ import { projectName, resolveCommit } from '../git/correlate.js';
43
+ import { ValueReadCache, valueCacheKey } from './value-cache.js';
35
44
  import { scanWithDiff, saveScan } from '../scan/scan.js';
36
45
  import { describeSourceDepth } from '../value/sourceDepth.js';
37
46
  import { buildGuide } from '../guide.js';
38
47
  import { computeAlertCoverage, computeAlerts } from '../alerts/detect.js';
39
48
  import { requestsToCsv } from '../export/csv.js';
49
+ import { statementToCsv } from '../export/statementCsv.js';
50
+ import { allocationRunToCsv } from '../export/allocationCsv.js';
40
51
  import { economicRequestsToCsv } from '../export/economic.js';
41
52
  import { instant } from '../epistemic/time.js';
42
53
  import { DIMENSIONS } from '../value/characterization.js';
43
- import { IMPORTERS, emptyImportSummary } from '../connect/importShared.js';
44
- import { importClaudeCode, defaultClaudeCodeRoot } from '../connect/claudeCode.js';
45
- import { importOpencode, defaultOpencodeDbPath } from '../connect/opencode.js';
46
- import { importCodex, defaultCodexRoot } from '../connect/codex.js';
54
+ import { emptyImportSummary } from '../connect/importShared.js';
55
+ import { IMPORT_REGISTRY } from '../connect/registry.js';
47
56
  import { judgeSessionFromStore } from '../judge/orchestrate.js';
48
57
  import { resolveJudgeTier, hasHostedJudgeApiKey } from '../judge/tier.js';
49
58
  import { pricingStatus } from '../cost/pricing.js';
59
+ import { declarePrice, unknownModels } from '../cost/declarations.js';
60
+ import { previewReprice, applyReprice } from '../cost/reprice.js';
61
+ import { modelNameError, removePriceOverride } from '../cost/overrides.js';
62
+ import { summarizeBasis } from '../cost/basis.js';
50
63
  import { pricingCoverage } from '../cost/coverage.js';
64
+ import { activePriceOverrides } from '../cost/overrides.js';
51
65
  import { RESOURCE_LIMITS } from '../util/resource-limits.js';
52
66
  import { buildEconomicReport } from '../cli/economicCmd.js';
53
67
  import { verifyBlockedAssignmentPlan } from '../causal/assignment.js';
@@ -116,6 +130,7 @@ export function buildOverview(store, config, range) {
116
130
  : todaySpend;
117
131
  const summary = store.summary(startMs, endMs);
118
132
  const pricingWindow = store.healthStats(startMs, endMs);
133
+ const provenance = store.pricingEvidenceByModel(startMs, endMs);
119
134
  // What retention deleted from inside this window. Read from the ledger's own
120
135
  // record, because an absence of rows is exactly what cannot distinguish a
121
136
  // quiet window from a pruned one (D-175).
@@ -128,7 +143,8 @@ export function buildOverview(store, config, range) {
128
143
  // in the browser, which answered `complete` for a window with no spend in it.
129
144
  claimSupport: meteredClaimSupport({
130
145
  totalCostUsd: pricingWindow.totalCostUsd,
131
- estimatedCostUsd: pricingWindow.estimatedCostUsd,
146
+ // Prices you set are not list prices, so they never count as list support.
147
+ estimatedCostUsd: pricingWindow.estimatedCostUsd + pricingWindow.declaredCostUsd,
132
148
  retention,
133
149
  }),
134
150
  retention,
@@ -156,7 +172,8 @@ export function buildOverview(store, config, range) {
156
172
  // Per-row provenance, grouped without mixing cards or match paths. This
157
173
  // is deliberately separate from the active card above: a refresh never
158
174
  // rewrites the evidence captured for historical requests.
159
- provenance: store.pricingEvidenceByModel(startMs, endMs),
175
+ provenance,
176
+ basis: summarizeBasis(provenance),
160
177
  },
161
178
  byModel: store.byModel(startMs, endMs),
162
179
  byProject: store.byProject(startMs, endMs),
@@ -185,23 +202,11 @@ export function buildOverview(store, config, range) {
185
202
  alertCoverage: computeAlertCoverage(store, config, { now }),
186
203
  };
187
204
  }
188
- const DASH_IMPORTERS = [
189
- {
190
- ...IMPORTERS.find((i) => i.id === 'claude-code'),
191
- locate: () => (existsSync(defaultClaudeCodeRoot()) ? defaultClaudeCodeRoot() : null),
192
- run: (store, opts) => importClaudeCode(store, opts),
193
- },
194
- {
195
- ...IMPORTERS.find((i) => i.id === 'opencode'),
196
- locate: () => defaultOpencodeDbPath(),
197
- run: (store, opts) => importOpencode(store, opts),
198
- },
199
- {
200
- ...IMPORTERS.find((i) => i.id === 'codex'),
201
- locate: () => defaultCodexRoot(),
202
- run: (store, opts) => importCodex(store, opts),
203
- },
204
- ];
205
+ // The same registry the CLI and `scan` use (src/connect/registry.ts), so the
206
+ // dashboard can never again skip a tool the terminal reads.
207
+ const DASH_IMPORTERS = IMPORT_REGISTRY.map((e) => ({
208
+ id: e.id, label: e.label, blurb: e.blurb, locate: e.locate, run: e.run,
209
+ }));
205
210
  // ---------------------------------------------------------------------------
206
211
  // Handlers
207
212
  // ---------------------------------------------------------------------------
@@ -220,6 +225,14 @@ export function handleImporters({ res }) {
220
225
  }),
221
226
  });
222
227
  }
228
+ /** Demo mode writes nothing real: refuse with a conflict and drain the body. */
229
+ function refuseDemoImport(req, res, what) {
230
+ if (!isDemo())
231
+ return false;
232
+ req.resume();
233
+ json(res, 409, { error: new DemoWriteBlockedError(what).message });
234
+ return true;
235
+ }
223
236
  /**
224
237
  * Trigger a native import from the dashboard. POST-only + a custom header the
225
238
  * browser only sets same-origin (declared in the route table): a cross-site form
@@ -227,6 +240,8 @@ export function handleImporters({ res }) {
227
240
  * mutating the local DB.
228
241
  */
229
242
  export function handleImport({ req, res, url, store }) {
243
+ if (refuseDemoImport(req, res, 'the import'))
244
+ return;
230
245
  const tool = url.searchParams.get('tool') ?? 'all';
231
246
  const targets = tool === 'all' ? DASH_IMPORTERS : DASH_IMPORTERS.filter((i) => i.id === tool);
232
247
  if (targets.length === 0)
@@ -286,6 +301,8 @@ export function handleDiscover({ req, res, url, store }) {
286
301
  export function handleScan({ req, res, url, store }) {
287
302
  const path = url.searchParams.get('path') || undefined;
288
303
  if (req.method === 'POST') {
304
+ if (refuseDemoImport(req, res, 'the import and setup'))
305
+ return;
289
306
  req.resume();
290
307
  void (async () => {
291
308
  try {
@@ -345,6 +362,219 @@ export function handleOverview({ res, url, store, config }) {
345
362
  return json(res, 500, { error: String(err) });
346
363
  }
347
364
  }
365
+ export function handleSessions({ res, url, store }) {
366
+ const range = url.searchParams.get('range') ?? '7d';
367
+ const valid = ['today', '7d', '30d', 'all'];
368
+ const safe = valid.includes(range) ? range : '7d';
369
+ try {
370
+ const now = Date.now();
371
+ return json(res, 200, buildSessions(store, safe, resolveRange(safe, now), { now }));
372
+ }
373
+ catch (err) {
374
+ return json(res, 500, { error: String(err) });
375
+ }
376
+ }
377
+ const STATEMENT_MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
378
+ const STATEMENT_TOP = 10;
379
+ const STATEMENT_RUN_LIMIT = 50;
380
+ /**
381
+ * Local-time calendar month, half-open: local midnight on the 1st to local
382
+ * midnight on the 1st of the next month. Local time matches startOfLocalDay,
383
+ * which the day figures already use. Only the rolling `segreant month` window
384
+ * exists in the CLI, so there is no calendar helper to reuse.
385
+ */
386
+ export function calendarMonthBounds(year, month) {
387
+ return { startMs: new Date(year, month - 1, 1).getTime(), endMs: new Date(year, month, 1).getTime() };
388
+ }
389
+ /**
390
+ * How far a month's overlapping runs reach. Never a sum: a run that covers only
391
+ * part of the month says so, and no run is prorated into a month figure.
392
+ */
393
+ function monthCoverageOf(runs) {
394
+ if (runs.length === 0)
395
+ return 'not_established';
396
+ return runs.some((r) => r.coversWholeMonth) ? 'whole' : 'partial';
397
+ }
398
+ export function handleStatement({ res, url, store }) {
399
+ const r = buildStatement(store, url.searchParams.get('month'), STATEMENT_TOP);
400
+ return r.status === 200 ? json(res, 200, r.payload) : json(res, r.status, { error: r.error });
401
+ }
402
+ /**
403
+ * The same month as a spreadsheet: every project and model rather than the
404
+ * page's top ten, and the four claims kept as four sections with no total.
405
+ */
406
+ export function handleStatementCsv({ res, url, store }) {
407
+ const r = buildStatement(store, url.searchParams.get('month'), null);
408
+ if (r.status !== 200)
409
+ return json(res, r.status, { error: r.error });
410
+ res.writeHead(200, {
411
+ 'content-type': 'text/csv; charset=utf-8',
412
+ 'content-disposition': `attachment; filename="segreant-statement-${r.payload.month}${r.payload.demo ? '-sample' : ''}.csv"`,
413
+ 'cache-control': 'no-store',
414
+ });
415
+ res.end(statementToCsv(r.payload));
416
+ }
417
+ /**
418
+ * F5. A unit is counted in the month its survival check falls (commit time plus
419
+ * the window), as last measured. Read from stored snapshots, so opening a month
420
+ * never runs git. Dollars are the list cost behind the work, not its value.
421
+ */
422
+ export function realizedForMonth(store, startMs, endMs) {
423
+ const report = realizationFromStore(store);
424
+ // Each unit is placed by the window it was measured with. Units persisted
425
+ // before that was recorded fall back to the default, and the reason says so.
426
+ const DAY_MS = 24 * 60 * 60 * 1000;
427
+ const due = (u) => u.tsEpochMs + (u.checkWindowDays ?? report.windowDays) * DAY_MS;
428
+ const judged = report.units.filter((u) => !u.maturing && due(u) >= startMs && due(u) < endMs);
429
+ const waiting = report.units.filter((u) => u.maturing && u.tsEpochMs < endMs && due(u) >= startMs);
430
+ const k = keptSummary({ units: judged, windowDays: report.windowDays });
431
+ const cost = (us) => us.reduce((s, u) => s + (Number.isFinite(u.attributedCostUsd) ? u.attributedCostUsd : 0), 0);
432
+ const measuredAt = report.units.length > 0 ? report.generatedAt : null;
433
+ const assumed = [...judged, ...waiting].filter((u) => u.checkWindowDays === undefined).length;
434
+ return {
435
+ standing: judged.length > 0 ? 'judged_in_month' : 'none_judged',
436
+ windowDays: report.windowDays,
437
+ assumedWindowUnits: assumed,
438
+ measuredAt,
439
+ kept: k.kept,
440
+ notKept: k.notKept,
441
+ contested: k.contested,
442
+ unknown: k.unknown,
443
+ tooEarly: { units: waiting.length, costUsd: cost(waiting), nextCheckMs: waiting.length > 0 ? Math.min(...waiting.map(due)) : null },
444
+ reason: measuredAt === null
445
+ ? 'No outcomes have been measured on this machine yet. Run a value read from the Value page.'
446
+ : `Work is counted in the month its survival check falls, as last measured. The dollars are the list cost of that work, not its value; a value needs an hourly rate and is on the Value page.`
447
+ + (assumed > 0 ? ` ${assumed} ${assumed === 1 ? 'piece was' : 'pieces were'} measured before Segreant recorded each check's window, so ${assumed === 1 ? 'it is' : 'they are'} placed by the ${report.windowDays}-day default.` : ''),
448
+ };
449
+ }
450
+ function buildStatement(store, raw, top) {
451
+ try {
452
+ const now = Date.now();
453
+ let year;
454
+ let month;
455
+ if (raw === null) {
456
+ const current = new Date(now);
457
+ year = current.getFullYear();
458
+ month = current.getMonth() + 1;
459
+ }
460
+ else {
461
+ const match = /^(\d{4})-(\d{2})$/.exec(raw);
462
+ if (!match)
463
+ return { status: 400, error: 'month must be YYYY-MM, for example 2026-07' };
464
+ year = Number(match[1]);
465
+ month = Number(match[2]);
466
+ if (year < 1970 || month < 1 || month > 12)
467
+ return { status: 400, error: 'month must be a calendar month from 1970 onward' };
468
+ }
469
+ const { startMs, endMs } = calendarMonthBounds(year, month);
470
+ if (startMs > now)
471
+ return { status: 400, error: `month ${year}-${String(month).padStart(2, '0')} is entirely in the future` };
472
+ const overlaps = (periodStartMs, periodEndMs) => periodStartMs < endMs && periodEndMs > startMs;
473
+ const covers = (periodStartMs, periodEndMs) => periodStartMs <= startMs && periodEndMs >= endMs;
474
+ // Metered: the same reads buildOverview makes, for this month's range.
475
+ const summary = store.summary(startMs, endMs);
476
+ const pricingWindow = store.healthStats(startMs, endMs);
477
+ const retention = store.windowCoverage(startMs);
478
+ const basis = summarizeBasis(store.pricingEvidenceByModel(startMs, endMs));
479
+ // Provider-billed: runs whose period overlaps the month. A partial run is
480
+ // reported as itself, with its own figures, never prorated into the month.
481
+ const reconciliation = store.reconciliationRuns(STATEMENT_RUN_LIMIT);
482
+ const billedRuns = reconciliation
483
+ .filter((r) => overlaps(r.result.periodStartMs, r.result.periodEndMs))
484
+ .map((r) => ({
485
+ reconciliationRunId: r.reconciliationRunId,
486
+ computedAtMs: r.computedAtMs,
487
+ periodStartMs: r.result.periodStartMs,
488
+ periodEndMs: r.result.periodEndMs,
489
+ providerReportedMicros: r.result.providerReportedMicros,
490
+ localCapturedMicros: r.result.localCapturedMicros,
491
+ unexplainedVarianceMicros: r.result.unexplainedVarianceMicros,
492
+ providerSourceKind: r.result.providerSourceKind,
493
+ offPathBound: r.result.offPathBound ?? null,
494
+ snapshotStability: r.result.snapshotStability ?? null,
495
+ localQuantization: r.result.localQuantization ?? null,
496
+ providerProjectRef: r.result.providerProjectRef ?? null,
497
+ coversWholeMonth: covers(r.result.periodStartMs, r.result.periodEndMs),
498
+ }));
499
+ // Allocated: the same rule over allocation runs.
500
+ const allocations = store.allocationRuns(STATEMENT_RUN_LIMIT);
501
+ const allocatedRuns = allocations
502
+ .filter((r) => overlaps(r.result.periodStartMs, r.result.periodEndMs))
503
+ .map((r) => ({
504
+ allocationRunId: r.allocationRunId,
505
+ computedAtMs: r.computedAtMs,
506
+ periodStartMs: r.result.periodStartMs,
507
+ periodEndMs: r.result.periodEndMs,
508
+ totalMicros: r.result.totalMicros,
509
+ allocatedMicros: r.result.allocatedMicros,
510
+ unallocatedMicros: r.result.unallocatedMicros,
511
+ conserves: r.result.conserves,
512
+ byCostCentre: r.result.byCostCentre.map((c) => ({
513
+ costCentreId: c.costCentreId,
514
+ allocatedMicros: c.allocatedMicros,
515
+ requestCount: c.requestCount,
516
+ sourceBasis: c.sourceBasis,
517
+ })),
518
+ coversWholeMonth: covers(r.result.periodStartMs, r.result.periodEndMs),
519
+ }));
520
+ const closeStatus = store.economicPeriodCloseStatus(startMs, endMs);
521
+ const byProject = store.byProject(startMs, endMs)
522
+ .slice(0, top ?? undefined)
523
+ .map((p) => ({ label: p.label, costUsd: p.costUsd, requests: p.requests }));
524
+ const byModel = store.byModel(startMs, endMs)
525
+ .slice(0, top ?? undefined)
526
+ .map((m) => ({ label: m.label, provider: m.provider, costUsd: m.costUsd, requests: m.requests }));
527
+ return { status: 200, payload: {
528
+ demo: isDemo(),
529
+ generatedAt: new Date(now).toISOString(),
530
+ month: `${year}-${String(month).padStart(2, '0')}`,
531
+ period: {
532
+ startMs,
533
+ endMs,
534
+ label: `${STATEMENT_MONTH_NAMES[month - 1]} ${year}`,
535
+ complete: endMs <= now,
536
+ },
537
+ metered: {
538
+ costUsd: summary.costUsd,
539
+ requests: summary.requests,
540
+ claimSupport: meteredClaimSupport({
541
+ totalCostUsd: pricingWindow.totalCostUsd,
542
+ estimatedCostUsd: pricingWindow.estimatedCostUsd + pricingWindow.declaredCostUsd,
543
+ retention,
544
+ }),
545
+ basis,
546
+ retention,
547
+ byProject,
548
+ byModel,
549
+ },
550
+ billed: {
551
+ monthCoverage: monthCoverageOf(billedRuns),
552
+ overlappingRuns: billedRuns.length,
553
+ // False when the store returned as many runs as it was asked for, so an
554
+ // older overlapping run may exist beyond the read. A "not established"
555
+ // with this false is a limit of the read, not a finding.
556
+ scanComplete: reconciliation.length < STATEMENT_RUN_LIMIT,
557
+ runs: billedRuns,
558
+ },
559
+ allocated: {
560
+ monthCoverage: monthCoverageOf(allocatedRuns),
561
+ overlappingRuns: allocatedRuns.length,
562
+ scanComplete: allocations.length < STATEMENT_RUN_LIMIT,
563
+ runs: allocatedRuns,
564
+ costCentres: store.costCentres().map((c) => ({ costCentreId: c.costCentreId, name: c.name })),
565
+ },
566
+ realized: realizedForMonth(store, startMs, endMs),
567
+ close: {
568
+ status: closeStatus.status,
569
+ activeFinalizationId: closeStatus.activeFinalizationId,
570
+ eventCount: closeStatus.eventCount,
571
+ },
572
+ } };
573
+ }
574
+ catch (error) {
575
+ return { status: 500, error: error instanceof Error ? error.message : String(error) };
576
+ }
577
+ }
348
578
  /**
349
579
  * Provider billing evidence has a different truth contract from the local
350
580
  * request ledger: an operator supplied it, Segreant has not verified it with
@@ -373,6 +603,16 @@ export function handleBilling({ res, url, store }) {
373
603
  // disagree the moment a run landed between the calls.
374
604
  const runs = store.reconciliationRuns(10);
375
605
  const summary = store.billingSummary();
606
+ // An adopted export is recorded as an observation whose one "page" digest
607
+ // is the file's SHA-256 (adoptOpenAiCostsFromImport). Only an adoption under
608
+ // the ACTIVE declaration puts a file in use: one adopted under an earlier
609
+ // declaration says nothing about the project declared now.
610
+ const scope = store.activeOpenAiScope();
611
+ const adoptions = scope === null ? [] : store.openAiCostsObservationRuns(200).filter((o) => o.sourceKind === 'operator_supplied_export' && o.resultState === 'succeeded' && o.declaredScopeId === scope.declarationId);
612
+ const imports = store.billingImportRuns(25).map((run) => {
613
+ const adopted = adoptions.find((o) => o.pageDigestChainSha256 === run.fileSha256);
614
+ return { ...run, inUse: adopted ? { observationRunId: adopted.observationRunId, adoptedAtMs: adopted.fetchedAtMs } : null };
615
+ });
376
616
  return json(res, 200, {
377
617
  demo: isDemo(),
378
618
  /** The boundary the kernel claims answer at; `null` is a live read. */
@@ -404,7 +644,17 @@ export function handleBilling({ res, url, store }) {
404
644
  ],
405
645
  },
406
646
  summary,
407
- imports: store.billingImportRuns(25),
647
+ // The declaration itself, so the page can name what is declared and what
648
+ // clearing it would end. An operator statement, never provider-verified.
649
+ activeScope: scope === null ? null : {
650
+ declarationId: scope.declarationId,
651
+ billingAccountRef: scope.billingAccountRef,
652
+ providerProjectRef: scope.providerProjectRef,
653
+ upstreamDisplay: scope.upstreamDisplay,
654
+ declaredAtMs: scope.declaredAtMs,
655
+ trust: scope.trust,
656
+ },
657
+ imports,
408
658
  kernel: {
409
659
  kind: 'trusted_epistemic_kernel_billing',
410
660
  claims: store.billingKernelClaims(25, asOf),
@@ -473,6 +723,35 @@ export function handleBilling({ res, url, store }) {
473
723
  * rather than derived money — the page needs them to name a centre and to
474
724
  * show which rule version placed each line.
475
725
  */
726
+ /**
727
+ * One recorded allocation run as CSV, by id. Read from the stored run, never
728
+ * recomputed, so the file is the split someone reviewed. An id is required:
729
+ * "the latest run" would silently change meaning when a new one is recorded.
730
+ */
731
+ export function handleAllocationRunCsv({ res, url, store }) {
732
+ const id = url.searchParams.get('id');
733
+ if (id === null || id.trim() === '')
734
+ return json(res, 400, { error: 'id is required: the allocation run to export' });
735
+ if (id.length > 200)
736
+ return json(res, 400, { error: 'id is too long to be an allocation run id' });
737
+ try {
738
+ const run = store.allocationRunById(id);
739
+ if (run === null)
740
+ return json(res, 404, { error: 'no recorded allocation run has that id' });
741
+ const names = new Map(store.costCentres().map((c) => [c.costCentreId, c.name]));
742
+ const demo = isDemo();
743
+ const day = new Date(run.result.periodStartMs).toISOString().slice(0, 10);
744
+ res.writeHead(200, {
745
+ 'content-type': 'text/csv; charset=utf-8',
746
+ 'content-disposition': `attachment; filename="segreant-allocation-${day}${demo ? '-sample' : ''}.csv"`,
747
+ 'cache-control': 'no-store',
748
+ });
749
+ res.end(allocationRunToCsv(run, (costCentreId) => names.get(costCentreId) ?? costCentreId, demo));
750
+ }
751
+ catch (err) {
752
+ return json(res, 500, { error: String(err) });
753
+ }
754
+ }
476
755
  export function handleAllocation({ res, store }) {
477
756
  try {
478
757
  const reconciliationRuns = store.reconciliationRuns(1);
@@ -606,6 +885,104 @@ export function handlePricing({ res, url, store, config }) {
606
885
  return json(res, 500, { error: String(err) });
607
886
  }
608
887
  }
888
+ export function handlePricingUnknown({ res, store }) {
889
+ try {
890
+ const payload = {
891
+ models: unknownModels(store), demo: isDemo(),
892
+ // Sample data shows no real declarations (activePriceOverrides is empty in demo mode).
893
+ declared: Object.entries(activePriceOverrides()).sort(([a], [b]) => a.localeCompare(b)).map(([model, price]) => ({
894
+ model, kind: price.kind, declaredAt: price.declaredAt,
895
+ summary: price.kind === 'free' ? 'free (local model)'
896
+ : price.kind === 'like' ? `like ${price.like}`
897
+ : `input $${price.input}, output $${price.output}${price.cacheRead ? `, cache read $${price.cacheRead}` : ''}${price.cacheWrite ? `, cache write $${price.cacheWrite}` : ''}${price.cacheWrite1h ? `, 1-hour cache write $${price.cacheWrite1h}` : ''} per 1M tokens`,
898
+ ...(price.kind === 'rate' ? { rate: {
899
+ input: price.input, output: price.output,
900
+ ...(price.cacheRead === undefined ? {} : { cacheRead: price.cacheRead }),
901
+ ...(price.cacheWrite === undefined ? {} : { cacheWrite: price.cacheWrite }),
902
+ ...(price.cacheWrite1h === undefined ? {} : { cacheWrite1h: price.cacheWrite1h }),
903
+ } } : {}),
904
+ ...(price.kind === 'like' ? { like: price.like } : {}),
905
+ })),
906
+ boundary: 'Recorded cost (estimated). Declaring a price leaves recorded requests unchanged until you re-price them.',
907
+ };
908
+ json(res, 200, payload);
909
+ }
910
+ catch (error) {
911
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
912
+ }
913
+ }
914
+ /** Small local pricing operations share a strict 16 KiB JSON boundary. */
915
+ async function pricingBody(req, allowed) {
916
+ const chunks = [];
917
+ let bytes = 0;
918
+ for await (const chunk of req) {
919
+ bytes += chunk.byteLength;
920
+ if (bytes > 16_384) {
921
+ req.resume();
922
+ throw new PricingBodyTooLarge();
923
+ }
924
+ chunks.push(chunk);
925
+ }
926
+ let body;
927
+ try {
928
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
929
+ }
930
+ catch {
931
+ throw new Error('request must contain valid JSON');
932
+ }
933
+ if (!body || typeof body !== 'object' || Array.isArray(body))
934
+ throw new Error('request must be an object');
935
+ if (Object.keys(body).some((key) => !allowed.includes(key)))
936
+ throw new Error('request has an unknown field');
937
+ return body;
938
+ }
939
+ class PricingBodyTooLarge extends Error {
940
+ }
941
+ function pricingWrite(context, allowed, action, readOnly = () => false) {
942
+ void (async () => {
943
+ try {
944
+ const body = await pricingBody(context.req, allowed);
945
+ // A preview writes nothing, so the sample data can show one (independent
946
+ // review, 2026-10-18); only a write is refused there.
947
+ if (isDemo() && !readOnly(body))
948
+ return json(context.res, 409, { error: new DemoWriteBlockedError('the pricing change').message });
949
+ json(context.res, 200, action(body));
950
+ }
951
+ catch (error) {
952
+ json(context.res, error instanceof PricingBodyTooLarge ? 413 : 400, {
953
+ error: error instanceof PricingBodyTooLarge ? 'pricing request exceeds bounded dashboard limit' : error instanceof Error ? error.message : String(error),
954
+ });
955
+ }
956
+ })();
957
+ }
958
+ export function handlePricingDeclare(context) {
959
+ pricingWrite(context, ['model', 'kind', 'input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h', 'like'], (body) => {
960
+ const { model, ...input } = body;
961
+ const error = modelNameError(model);
962
+ if (error)
963
+ throw new Error(error);
964
+ // setPriceOverride validates kind-specific fields and decimal strings; no coercion.
965
+ const price = declarePrice(model, input);
966
+ return { ok: true, model: model, price };
967
+ });
968
+ }
969
+ export function handlePricingUnset(context) {
970
+ pricingWrite(context, ['model'], (body) => {
971
+ const error = modelNameError(body.model);
972
+ if (error)
973
+ throw new Error(error);
974
+ const model = body.model;
975
+ return { ok: true, model, removed: removePriceOverride(model) };
976
+ });
977
+ }
978
+ export function handleReprice(context) {
979
+ pricingWrite(context, ['apply'], (body) => {
980
+ if (typeof body.apply !== 'boolean')
981
+ throw new Error('apply must be true or false');
982
+ const { updates: _updates, meta: _meta, priceEvidence: _legacyCliEvidence, ...report } = body.apply ? applyReprice(context.store) : previewReprice(context.store);
983
+ return { ...report, demo: isDemo() };
984
+ }, (body) => body.apply === false);
985
+ }
609
986
  export function handleExportCsv({ res, url, store }) {
610
987
  const range = url.searchParams.get('range') ?? '30d';
611
988
  const valid = ['today', '7d', '30d', 'all'];
@@ -707,6 +1084,10 @@ export function handleGuide({ res, store, config }) {
707
1084
  requestsAllTime: store.summary(0, now + 1000).requests,
708
1085
  requestsRetention: store.retentionFloor(),
709
1086
  spend30dUsd: store.summary(now - 30 * day, now + 1000).costUsd,
1087
+ ...(() => {
1088
+ const capBasis = capBasisSpend(store, config.budget, now - 30 * day, now + 1000);
1089
+ return { capBasisSpend30dUsd: capBasis.usd, capBasis: capBasis.basis };
1090
+ })(),
710
1091
  dailyCapUsd: config.budget.dailyUsd,
711
1092
  outcomeSignals: store.countSignals(),
712
1093
  realizationUnits: store.countRealizationUnits(),
@@ -769,103 +1150,136 @@ export function handleJudge({ req, res, store, config }) {
769
1150
  }
770
1151
  })();
771
1152
  }
1153
+ const valueReadCaches = new WeakMap();
1154
+ /**
1155
+ * The value read cache of one Store, created on first use. Scoped per Store so
1156
+ * two dashboards (or two tests, each with a fresh Store) never share answers.
1157
+ * `opts` applies only when the cache is created; tests use it to inject a clock.
1158
+ */
1159
+ export function valueReadCacheFor(store, opts) {
1160
+ let cache = valueReadCaches.get(store);
1161
+ if (cache === undefined) {
1162
+ cache = new ValueReadCache(opts);
1163
+ valueReadCaches.set(store, cache);
1164
+ }
1165
+ return cache;
1166
+ }
772
1167
  export function handleValue({ res, url, store, config }) {
773
1168
  const repo = safeRepo(url.searchParams.get('repo'));
774
1169
  const windowDays = Number(url.searchParams.get('window') || '14') || 14;
1170
+ const explicitRepo = url.searchParams.has('repo');
775
1171
  void (async () => {
776
1172
  try {
777
- // The whole composition — realization, baseline, Lift, money, RoI,
778
- // drift, time reclaimed, frontier, per-project value, usage, cohort and
779
- // budget advice — lives in src/value/report.ts, which the CLI's `roi` /
780
- // `saved` commands call too. The two surfaces used to sequence these
781
- // primitives independently, held in step only by comments claiming they
782
- // matched. Now there is one sequence, so they cannot drift apart.
783
- //
784
- // `repo` is never empty — `safeRepo` falls back to the dashboard's
785
- // launch directory when no `?repo=` is given, the same
786
- // `flags.repo ?? process.cwd()` convention every CLI command uses — so
787
- // the baseline mines that directory's git history exactly as
788
- // `segreant roi` (no `--repo`) would.
789
- //
790
- // `discloseLiftSource: false` keeps this payload's strings exactly as
791
- // they have always been: the CLI names its Lift source in
792
- // `roi.lenses.lift.how` and labels the demo's synthetic TSF in
793
- // `roi.notes`; this endpoint never has. It is a disclosure difference
794
- // only — every value, interval and dollar is computed identically.
795
- const value = await valueReport(store, config, {
796
- repo,
797
- windowDays,
798
- limit: 40,
799
- persist: false,
800
- discloseLiftSource: false,
801
- });
802
- const spine = value.spine;
803
- const rep = spine?.loaded.report ?? null;
804
- // The payload's realization slice is deliberately narrower than the
805
- // full report: the fields the GUI declares, and no more.
806
- const realization = rep
807
- ? {
808
- matured: rep.matured,
809
- firstPassAcceptance: rep.firstPassAcceptance,
810
- proposalCoverage: rep.proposalCoverage,
811
- projectScoped: rep.projectScoped,
812
- costStaleUnits: rep.costStaleUnits,
813
- units: rep.units,
814
- }
815
- : null;
816
- // Raw frontier cells may be unlike tasks. The dashboard exposes only
817
- // the separately gated, within-task model trials from `frontier`.
818
- const allocation = null;
819
- // Cross-project allocation is not comparable/reliable enough to
820
- // recommend from raw RoI alone; per-project value stays descriptive.
821
- const projectAllocation = null;
822
- // The money claim is `returnRatio.manualEquivalentValueUsd`, and only when
823
- // the payload's own `basis` says it is priced. A dollar figure is never
824
- // invented from a bare ratio.
825
- const ratio = spine?.roi?.returnRatio ?? null;
826
- const roiCoverage = spine?.roi?.coverage;
827
- return json(res, 200, {
828
- demo: value.demo,
829
- // Contradicted gate evidence lands on COVERAGE here rather than on the
830
- // epistemic axis, because mature units are different propositions and a
831
- // population of contradictions is not a contradiction in the aggregate.
832
- // See `src/dashboard/claim-support.ts`.
833
- claimSupport: realizedClaimSupport({
834
- maturedUnits: rep?.matured?.units ?? 0,
835
- realizedUnits: rep?.matured?.realizedUnits ?? 0,
836
- gateConflicts: rep?.matured?.gateConflicts ?? null,
837
- roiCoverage: typeof roiCoverage === 'number' ? roiCoverage : null,
838
- valued: ratio?.basis === 'usd' && typeof ratio.manualEquivalentValueUsd === 'number',
839
- }),
840
- gitRepo: spine?.loaded.source === 'git',
841
- valueSource: spine?.loaded.source ?? null,
842
- // Whether the realized-value dollars came from spend SCOPED to this
843
- // project (imports / tagged proxy) vs the project-blind window sum —
844
- // disclosed so the number's basis is never silent.
845
- projectScoped: rep?.projectScoped ?? null,
1173
+ // Answered from memory when nothing the report reads has changed (see
1174
+ // src/dashboard/value-cache.ts for the key). Nothing here persists.
1175
+ const head = await resolveCommit(repo, 'HEAD');
1176
+ const key = valueCacheKey({
1177
+ params: url.searchParams,
846
1178
  repo,
847
- generatedAt: new Date(value.generatedAtMs).toISOString(),
848
- realization,
849
- roi: spine?.roi ?? null,
850
- frontier: spine?.frontier ?? null,
851
- budget: value.budget,
852
- allocation,
853
- projects: value.projects,
854
- projectAllocation,
855
- usage: value.usage,
856
- selfReported: value.selfReported,
857
- team: value.team,
858
- drift: spine?.drift === null || spine?.drift === undefined
859
- ? null
860
- : { ...spine.drift, ...driftDetectability(spine.drift) },
861
- reclaimed: spine?.reclaimed ?? null,
1179
+ head,
1180
+ config,
1181
+ marks: store.valueChangeMarks(),
862
1182
  });
1183
+ const payload = await valueReadCacheFor(store).get(key, () => computeValuePayload(store, config, repo, windowDays, explicitRepo));
1184
+ return json(res, 200, payload);
863
1185
  }
864
1186
  catch (err) {
865
1187
  return json(res, 500, { error: String(err) });
866
1188
  }
867
1189
  })();
868
1190
  }
1191
+ async function computeValuePayload(store, config, repo, windowDays, explicitRepo) {
1192
+ // The whole composition — realization, baseline, Lift, money, RoI,
1193
+ // drift, time reclaimed, frontier, per-project value, usage, cohort and
1194
+ // budget advice — lives in src/value/report.ts, which the CLI's `roi` /
1195
+ // `saved` commands call too. The two surfaces used to sequence these
1196
+ // primitives independently, held in step only by comments claiming they
1197
+ // matched. Now there is one sequence, so they cannot drift apart.
1198
+ //
1199
+ // `repo` is never empty — `safeRepo` falls back to the dashboard's
1200
+ // launch directory when no `?repo=` is given, the same
1201
+ // `flags.repo ?? process.cwd()` convention every CLI command uses — so
1202
+ // the baseline mines that directory's git history exactly as
1203
+ // `segreant roi` (no `--repo`) would.
1204
+ //
1205
+ // `discloseLiftSource: false` keeps this payload's strings exactly as
1206
+ // they have always been: the CLI names its Lift source in
1207
+ // `roi.lenses.lift.how` and labels the demo's synthetic TSF in
1208
+ // `roi.notes`; this endpoint never has. It is a disclosure difference
1209
+ // only — every value, interval and dollar is computed identically.
1210
+ const value = await store.withReadSnapshot(() => valueReport(store, config, {
1211
+ repo,
1212
+ windowDays,
1213
+ limit: 40,
1214
+ persist: false,
1215
+ discloseLiftSource: false,
1216
+ // Without an explicit ?repo=, answer from what `scan` measured and
1217
+ // stored (every project, as the CLI home view does) rather than
1218
+ // re-reading the launch folder's git on every load.
1219
+ preferStored: !explicitRepo,
1220
+ }));
1221
+ const spine = value.spine;
1222
+ const rep = spine?.loaded.report ?? null;
1223
+ // The payload's realization slice is deliberately narrower than the
1224
+ // full report: the fields the GUI declares, and no more.
1225
+ const realization = rep
1226
+ ? {
1227
+ matured: rep.matured,
1228
+ firstPassAcceptance: rep.firstPassAcceptance,
1229
+ proposalCoverage: rep.proposalCoverage,
1230
+ projectScoped: rep.projectScoped,
1231
+ costStaleUnits: rep.costStaleUnits,
1232
+ units: rep.units,
1233
+ }
1234
+ : null;
1235
+ // Raw frontier cells may be unlike tasks. The dashboard exposes only
1236
+ // the separately gated, within-task model trials from `frontier`.
1237
+ const allocation = null;
1238
+ // Cross-project allocation is not comparable/reliable enough to
1239
+ // recommend from raw RoI alone; per-project value stays descriptive.
1240
+ const projectAllocation = null;
1241
+ // The money claim is `returnRatio.manualEquivalentValueUsd`, and only when
1242
+ // the payload's own `basis` says it is priced. A dollar figure is never
1243
+ // invented from a bare ratio.
1244
+ const ratio = spine?.roi?.returnRatio ?? null;
1245
+ const roiCoverage = spine?.roi?.coverage;
1246
+ return {
1247
+ demo: value.demo,
1248
+ // Contradicted gate evidence lands on COVERAGE here rather than on the
1249
+ // epistemic axis, because mature units are different propositions and a
1250
+ // population of contradictions is not a contradiction in the aggregate.
1251
+ // See `src/dashboard/claim-support.ts`.
1252
+ claimSupport: realizedClaimSupport({
1253
+ maturedUnits: rep?.matured?.units ?? 0,
1254
+ realizedUnits: rep?.matured?.realizedUnits ?? 0,
1255
+ gateConflicts: rep?.matured?.gateConflicts ?? null,
1256
+ roiCoverage: typeof roiCoverage === 'number' ? roiCoverage : null,
1257
+ valued: ratio?.basis === 'usd' && typeof ratio.manualEquivalentValueUsd === 'number',
1258
+ }),
1259
+ gitRepo: spine?.loaded.source === 'git',
1260
+ valueSource: spine?.loaded.source ?? null,
1261
+ // Whether the realized-value dollars came from spend SCOPED to this
1262
+ // project (imports / tagged proxy) vs the project-blind window sum —
1263
+ // disclosed so the number's basis is never silent.
1264
+ projectScoped: rep?.projectScoped ?? null,
1265
+ repo,
1266
+ generatedAt: new Date(value.generatedAtMs).toISOString(),
1267
+ realization,
1268
+ roi: spine?.roi ?? null,
1269
+ frontier: spine?.frontier ?? null,
1270
+ budget: value.budget,
1271
+ allocation,
1272
+ projects: value.projects,
1273
+ projectAllocation,
1274
+ usage: value.usage,
1275
+ selfReported: value.selfReported,
1276
+ team: value.team,
1277
+ drift: spine?.drift === null || spine?.drift === undefined
1278
+ ? null
1279
+ : { ...spine.drift, ...driftDetectability(spine.drift) },
1280
+ reclaimed: spine?.reclaimed ?? null,
1281
+ };
1282
+ }
869
1283
  /** Public market, read-only: the bundled snapshot plus any refresh already on disk. Never fetches. */
870
1284
  export function handleMarket({ res, config }) {
871
1285
  try {
@@ -875,6 +1289,442 @@ export function handleMarket({ res, config }) {
875
1289
  return json(res, 500, { error: error instanceof Error ? error.message : String(error) });
876
1290
  }
877
1291
  }
1292
+ const ALLOCATION_WRITE_FIELDS = new Set(['op', 'apply', 'id', 'name', 'owner', 'method', 'targets', 'match', 'priority', 'from', 'to', 'until', 'note']);
1293
+ const ALLOCATION_WRITE_OPS = new Set(['centre', 'archive', 'rule', 'revoke', 'run']);
1294
+ function allocationId(value) {
1295
+ if (typeof value !== 'string' || value.length === 0 || value.length > 64)
1296
+ throw new Error('id must be a non-empty string of at most 64 characters');
1297
+ return value;
1298
+ }
1299
+ function allocationText(value, label, max) {
1300
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > max)
1301
+ throw new Error(`${label} must be a non-empty string of at most ${max} characters`);
1302
+ return value;
1303
+ }
1304
+ function allocationOptionalText(value, label) {
1305
+ if (value === undefined || value === null)
1306
+ return null;
1307
+ if (typeof value !== 'string')
1308
+ throw new Error(`${label} must be a string`);
1309
+ return value;
1310
+ }
1311
+ /** An ISO date or date-time, or null when absent. Mirrors parseTs in src/cli/allocCmd.ts. */
1312
+ function allocationTs(value, label) {
1313
+ if (value === undefined || value === null)
1314
+ return null;
1315
+ if (typeof value !== 'string')
1316
+ throw new Error(`${label} must be an ISO date or date-time`);
1317
+ if (!value.trim())
1318
+ return null;
1319
+ const ms = Date.parse(value.trim());
1320
+ if (!Number.isFinite(ms))
1321
+ throw new Error(`${label} must be an ISO date or date-time`);
1322
+ return ms;
1323
+ }
1324
+ /** Same rules as parseTargets in src/cli/allocCmd.ts; the store validates ids and ratio ranges. */
1325
+ function allocationTargets(value) {
1326
+ if (!Array.isArray(value) || value.length === 0)
1327
+ throw new Error('targets must name at least one cost centre');
1328
+ return value.map((entry) => {
1329
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
1330
+ throw new Error('each target must be { costCentreId, ratio }');
1331
+ const target = entry;
1332
+ const costCentreId = allocationId(target.costCentreId);
1333
+ if (typeof target.ratio !== 'number' || !Number.isFinite(target.ratio))
1334
+ throw new Error(`target ${costCentreId}: ratio must be a number`);
1335
+ return { costCentreId, ratio: target.ratio };
1336
+ });
1337
+ }
1338
+ /** Same rules as parseMatch in src/cli/allocCmd.ts: known fields only, empty values are a wildcard. */
1339
+ function allocationMatch(value) {
1340
+ const match = {};
1341
+ if (value === undefined || value === null)
1342
+ return match;
1343
+ if (typeof value !== 'object' || Array.isArray(value))
1344
+ throw new Error('match must be an object');
1345
+ for (const [key, field] of Object.entries(value)) {
1346
+ if (!MATCH_FIELDS.includes(key))
1347
+ throw new Error(`match.${key} is not a match field`);
1348
+ if (typeof field !== 'string')
1349
+ throw new Error(`match.${key} must be a string`);
1350
+ if (field.trim())
1351
+ match[key] = field.trim();
1352
+ }
1353
+ return match;
1354
+ }
1355
+ /**
1356
+ * Allocation changes from the GUI: the same Store calls, validation and
1357
+ * semantics as `segreant alloc`. Preview never writes; `run` with apply:true
1358
+ * records the run. Demo mode refuses every write, and allows only the preview.
1359
+ */
1360
+ export function handleAllocationWrite({ req, res, store }) {
1361
+ void (async () => {
1362
+ try {
1363
+ const chunks = [];
1364
+ let bytes = 0;
1365
+ for await (const part of req) {
1366
+ const chunk = part;
1367
+ bytes += chunk.byteLength;
1368
+ if (bytes > RESOURCE_LIMITS.dashboardRequestBytes) {
1369
+ req.resume();
1370
+ return json(res, 413, { error: 'allocation request exceeds bounded dashboard limit' });
1371
+ }
1372
+ chunks.push(chunk);
1373
+ }
1374
+ const raw = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
1375
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1376
+ throw new Error('allocation request must be an object');
1377
+ const body = raw;
1378
+ if (Object.keys(body).some((key) => !ALLOCATION_WRITE_FIELDS.has(key)))
1379
+ throw new Error('allocation request has an unknown field');
1380
+ if (typeof body.op !== 'string' || !ALLOCATION_WRITE_OPS.has(body.op))
1381
+ throw new Error('op must be one of centre, archive, rule, revoke, run');
1382
+ const op = body.op;
1383
+ if (body.apply !== undefined && typeof body.apply !== 'boolean')
1384
+ throw new Error('apply must be true or false');
1385
+ const apply = body.apply === true;
1386
+ // The one allocation request demo mode answers: a preview, which records nothing.
1387
+ if (isDemo() && !(op === 'run' && !apply)) {
1388
+ return json(res, 409, { error: new DemoWriteBlockedError('the allocation change').message });
1389
+ }
1390
+ if (op === 'centre') {
1391
+ const costCentre = store.upsertCostCentre({
1392
+ costCentreId: allocationId(body.id),
1393
+ name: allocationText(body.name, 'name', 120),
1394
+ owner: allocationOptionalText(body.owner, 'owner'),
1395
+ });
1396
+ return json(res, 200, { ok: true, op, applied: true, message: `Cost centre "${costCentre.costCentreId}" saved.`, costCentre });
1397
+ }
1398
+ if (op === 'archive') {
1399
+ const id = allocationId(body.id);
1400
+ if (!store.archiveCostCentre(id))
1401
+ return json(res, 404, { error: `No open cost centre "${id}".` });
1402
+ return json(res, 200, { ok: true, op, applied: true, message: `Archived "${id}". Past runs keep it; new spend will not reach it.` });
1403
+ }
1404
+ if (op === 'rule') {
1405
+ const method = body.method;
1406
+ if (typeof method !== 'string' || !ALLOCATION_METHODS.includes(method)) {
1407
+ throw new Error(`method must be one of ${ALLOCATION_METHODS.join(', ')}`);
1408
+ }
1409
+ const priority = body.priority === undefined ? 100 : body.priority;
1410
+ if (typeof priority !== 'number' || !Number.isFinite(priority))
1411
+ throw new Error('priority must be a number');
1412
+ const rule = store.saveAllocationRule({
1413
+ ruleId: allocationId(body.id),
1414
+ method: method,
1415
+ match: allocationMatch(body.match),
1416
+ targets: allocationTargets(body.targets),
1417
+ priority,
1418
+ // Default to the epoch so a first rule covers existing history, as the CLI does.
1419
+ effectiveFromMs: allocationTs(body.from, 'from') ?? 0,
1420
+ effectiveToMs: allocationTs(body.until, 'until'),
1421
+ revokedAtMs: null,
1422
+ owner: allocationOptionalText(body.owner, 'owner'),
1423
+ note: allocationOptionalText(body.note, 'note'),
1424
+ });
1425
+ const superseded = rule.version > 1
1426
+ ? ` v${rule.version - 1} closed at ${new Date(rule.effectiveFromMs).toISOString()} and retained — recorded runs are unchanged; new previews use effective dates.`
1427
+ : '';
1428
+ return json(res, 200, { ok: true, op, applied: true, rule, message: `Rule "${rule.ruleId}" v${rule.version} (${rule.method}) saved.${superseded}` });
1429
+ }
1430
+ if (op === 'revoke') {
1431
+ const id = allocationId(body.id);
1432
+ const revoked = store.revokeAllocationRule(id);
1433
+ if (revoked === 0)
1434
+ return json(res, 404, { error: `No open rule "${id}".` });
1435
+ return json(res, 200, { ok: true, op, applied: true, revoked, message: `Revoked ${revoked} open version(s) of "${id}". Rows retained.` });
1436
+ }
1437
+ const from = allocationTs(body.from, 'from');
1438
+ const to = allocationTs(body.to, 'to');
1439
+ if (from === null || to === null)
1440
+ throw new Error('from and to are required ISO dates or date-times');
1441
+ if (to <= from)
1442
+ throw new Error('to must be after from');
1443
+ const result = store.allocatePeriod(from, to);
1444
+ if (!result.conserves)
1445
+ return json(res, 500, { error: 'Allocation did not conserve its input. Refusing to show or record it.' });
1446
+ const allocationRunId = apply ? store.saveAllocationRun(result) : null;
1447
+ return json(res, 200, {
1448
+ ok: true,
1449
+ op,
1450
+ applied: apply,
1451
+ allocationRunId,
1452
+ message: apply ? 'Recorded as an immutable derived run.' : 'Not recorded. Apply to record this run.',
1453
+ result,
1454
+ });
1455
+ }
1456
+ catch (error) {
1457
+ return json(res, 400, { error: error instanceof SyntaxError ? 'allocation request must contain valid JSON' : error instanceof Error ? error.message : String(error) });
1458
+ }
1459
+ })();
1460
+ }
1461
+ const BILLING_WRITE_FIELDS = new Set(['op', 'apply', 'fileName', 'contentBase64', 'materialityUsd', 'importId', 'accountRef', 'projectRef']);
1462
+ const BILLING_WRITE_OPS = new Set(['import', 'reconcile', 'adopt', 'scope', 'scope-clear']);
1463
+ /**
1464
+ * Import content travels as the file's raw bytes in base64, never as decoded
1465
+ * text. The CLI hashes the bytes on disk; a browser that sent `file.text()`
1466
+ * would have the server hash a UTF-8 re-encoding instead, so the same export
1467
+ * (a BOM, a non-UTF-8 byte) could get two digests and import twice, once per
1468
+ * surface. Base64 makes the digest identical and bounds the body exactly: the
1469
+ * import limit at 4/3, plus the envelope. The shared 16 KiB dashboard cap would
1470
+ * refuse every realistic export.
1471
+ */
1472
+ const BILLING_WRITE_BODY_BYTES = Math.ceil(MAX_BILLING_IMPORT_BYTES / 3) * 4 + 64 * 1024;
1473
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
1474
+ /** Serialize before the status line is written, so a payload that cannot be JSON-encoded fails as a 400 rather than a broken response. */
1475
+ function billingJson(res, status, payload) {
1476
+ const text = stringifyJson(payload, 0);
1477
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1478
+ res.end(text);
1479
+ }
1480
+ function billingText(value, label, max) {
1481
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > max)
1482
+ throw new Error(`${label} must be a non-empty string of at most ${max} characters`);
1483
+ return value;
1484
+ }
1485
+ function billingOptionalText(value, label) {
1486
+ if (value === undefined || value === null)
1487
+ return null;
1488
+ if (typeof value !== 'string')
1489
+ throw new Error(`${label} must be a string`);
1490
+ return value;
1491
+ }
1492
+ /** Same reconciliation mapping as cmdReconcile in src/cli/billingCmd.ts. */
1493
+ function billingReconcile(store, body, apply) {
1494
+ let materialityUsd;
1495
+ if (body.materialityUsd !== undefined) {
1496
+ if (typeof body.materialityUsd !== 'number' || body.materialityUsd < 0
1497
+ || !Number.isSafeInteger(Math.round(body.materialityUsd * 1_000_000))) {
1498
+ throw new Error('materialityUsd must be a non-negative dollar amount within the safe integer microdollar range');
1499
+ }
1500
+ materialityUsd = body.materialityUsd;
1501
+ }
1502
+ const readiness = reconciliationReadiness(store);
1503
+ const notReady = {
1504
+ status: 409,
1505
+ payload: {
1506
+ ok: false, op: 'reconcile', applied: false, status: 'not_ready', readiness,
1507
+ message: 'Reconciliation is not possible yet. Complete the steps listed in readiness first.',
1508
+ error: 'Reconciliation is not possible yet. Complete the steps listed in readiness first.',
1509
+ },
1510
+ };
1511
+ if (!readiness.ready)
1512
+ return notReady;
1513
+ const result = store.reconcileOpenAiCosts({ materialityUsd });
1514
+ if (!result)
1515
+ return notReady;
1516
+ if (result.status === 'refused') {
1517
+ return {
1518
+ status: 409,
1519
+ payload: { ok: false, op: 'reconcile', applied: false, status: 'refused', refusal: result.refusal, detail: result.detail, error: result.detail, message: result.detail },
1520
+ };
1521
+ }
1522
+ const reconciliationRunId = apply ? store.saveReconciliationRun(result) : null;
1523
+ // A provider-day comparison crosses the kernel only after the immutable run is
1524
+ // recorded. Local-only periods have no provider Evidence and stay a non-claim.
1525
+ const kernel = apply && reconciliationRunId !== null && result.coverage.providerDays > 0
1526
+ ? store.issueOpenAiReconciliationToKernel(reconciliationRunId)
1527
+ : null;
1528
+ return {
1529
+ status: 200,
1530
+ payload: {
1531
+ ok: true,
1532
+ op: 'reconcile',
1533
+ applied: apply,
1534
+ message: apply ? 'Recorded as an immutable derived run.' : 'Not recorded. Apply to record this run.',
1535
+ reconciliationRunId,
1536
+ result,
1537
+ kernel,
1538
+ fragility: assessAssumptionFragility([...result.conditions], reconciliationCountermodels(result)),
1539
+ },
1540
+ };
1541
+ }
1542
+ /** Same adoption preconditions and refusals as cmdAdoptCosts in src/cli/billingCmd.ts. */
1543
+ function billingAdopt(store, body, apply) {
1544
+ const scope = store.activeOpenAiScope();
1545
+ if (!scope || !scope.providerProjectRef) {
1546
+ const error = 'Declare the route scope first, so the adopted lines are bound to a project: use op "scope" with accountRef and projectRef, then retry.';
1547
+ return { status: 409, payload: { ok: false, op: 'adopt', applied: false, error, message: error } };
1548
+ }
1549
+ const imports = store.billingImportRuns(50).filter((r) => r.provider === 'openai');
1550
+ if (imports.length === 0) {
1551
+ const error = 'No OpenAI billing export has been imported. Import one first with op "import".';
1552
+ return { status: 409, payload: { ok: false, op: 'adopt', applied: false, error, message: error } };
1553
+ }
1554
+ const importId = billingText(body.importId, 'importId', 200);
1555
+ const plan = store.planOpenAiCostsAdoption({
1556
+ importId,
1557
+ declaredScopeId: scope.declarationId,
1558
+ providerProjectRef: scope.providerProjectRef,
1559
+ });
1560
+ if (!plan.adoptable) {
1561
+ const error = `Cannot adopt: ${plan.refusal.replaceAll('_', ' ')}. ${plan.detail}`;
1562
+ return { status: 409, payload: { ok: false, op: 'adopt', applied: false, plan, error, message: error } };
1563
+ }
1564
+ const run = apply ? store.adoptOpenAiCostsFromImport(plan) : null;
1565
+ return {
1566
+ status: 200,
1567
+ payload: {
1568
+ ok: true,
1569
+ op: 'adopt',
1570
+ applied: apply,
1571
+ observationRunId: run?.observationRunId ?? null,
1572
+ plan,
1573
+ networkAttempted: false,
1574
+ credentialRead: false,
1575
+ message: apply
1576
+ ? 'Recorded as an immutable observation. Stamped operator_supplied_export; reconciliations built on it carry a fifth condition.'
1577
+ : 'Not recorded. Apply to record this adoption.',
1578
+ },
1579
+ };
1580
+ }
1581
+ /**
1582
+ * Same preview and effect as `billing scope clear` in src/cli/billingCmd.ts.
1583
+ * Clearing ends the declaration for future proxy traffic only: requests
1584
+ * already recorded keep the scope they were captured under, and recorded
1585
+ * comparisons stay as they were.
1586
+ */
1587
+ function billingScopeClear(store, apply) {
1588
+ const active = store.activeOpenAiScope();
1589
+ if (!apply) {
1590
+ return {
1591
+ status: 200,
1592
+ payload: {
1593
+ ok: true, op: 'scope-clear', applied: false, active,
1594
+ message: active
1595
+ ? 'Not cleared. Clearing stops matching future OpenAI requests to this project. Requests already recorded and recorded comparisons are unchanged.'
1596
+ : 'No project is declared, so there is nothing to clear.',
1597
+ },
1598
+ };
1599
+ }
1600
+ const cleared = store.clearOpenAiScope();
1601
+ return {
1602
+ status: 200,
1603
+ payload: {
1604
+ ok: true, op: 'scope-clear', applied: true, cleared,
1605
+ message: cleared
1606
+ ? 'Cleared. Future OpenAI requests through Segreant are no longer matched to a project. Requests already recorded and recorded comparisons are unchanged.'
1607
+ : 'No project was declared, so nothing changed.',
1608
+ },
1609
+ };
1610
+ }
1611
+ /** Same declaration, preview and refusal as `billing scope set` in src/cli/billingCmd.ts. */
1612
+ function billingScope(store, body, apply) {
1613
+ const accountRef = billingText(body.accountRef, 'accountRef', 200);
1614
+ const projectRef = billingOptionalText(body.projectRef, 'projectRef');
1615
+ let upstreamBase;
1616
+ let preview;
1617
+ try {
1618
+ upstreamBase = loadConfig().upstreams.openai;
1619
+ preview = newOpenAiScopeDeclaration({ billingAccountRef: accountRef, providerProjectRef: projectRef, upstreamBase });
1620
+ }
1621
+ catch (error) {
1622
+ throw new Error(`Billing scope failed: ${error instanceof Error ? error.message : String(error)}`);
1623
+ }
1624
+ if (!apply) {
1625
+ return {
1626
+ status: 200,
1627
+ payload: {
1628
+ ok: true,
1629
+ op: 'scope',
1630
+ applied: false,
1631
+ message: 'No data written. Apply to declare this scope.',
1632
+ scopePreview: {
1633
+ ...preview,
1634
+ capture: 'future OpenAI proxy rows only when the resolved configured endpoint exactly matches',
1635
+ trust: 'operator_declared_unverified',
1636
+ reconciliationStatus: 'not_reconciled',
1637
+ },
1638
+ },
1639
+ };
1640
+ }
1641
+ const declaration = store.setOpenAiScope({ billingAccountRef: accountRef, providerProjectRef: projectRef, upstreamBase });
1642
+ return {
1643
+ status: 200,
1644
+ payload: {
1645
+ ok: true,
1646
+ op: 'scope',
1647
+ applied: true,
1648
+ declaration,
1649
+ reconciliationStatus: 'not_reconciled',
1650
+ message: `Active local OpenAI scope ${declaration.declarationId} declared. Future matching proxy rows are declared_unverified; historical and imported rows are unchanged.`,
1651
+ },
1652
+ };
1653
+ }
1654
+ /**
1655
+ * Billing evidence from the GUI: the same parsing, store calls and refusals as
1656
+ * `segreant billing import|reconcile|openai-costs adopt|scope set`. Preview
1657
+ * never writes; apply:true writes, and demo mode refuses every apply.
1658
+ */
1659
+ export function handleBillingWrite({ req, res, store }) {
1660
+ void (async () => {
1661
+ try {
1662
+ const chunks = [];
1663
+ let bytes = 0;
1664
+ for await (const part of req) {
1665
+ const chunk = part;
1666
+ bytes += chunk.byteLength;
1667
+ if (bytes > BILLING_WRITE_BODY_BYTES) {
1668
+ req.resume();
1669
+ return billingJson(res, 413, { error: 'billing request exceeds bounded dashboard limit' });
1670
+ }
1671
+ chunks.push(chunk);
1672
+ }
1673
+ const raw = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
1674
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1675
+ throw new Error('billing request must be an object');
1676
+ const body = raw;
1677
+ if (Object.keys(body).some((key) => !BILLING_WRITE_FIELDS.has(key)))
1678
+ throw new Error('billing request has an unknown field');
1679
+ if (typeof body.op !== 'string' || !BILLING_WRITE_OPS.has(body.op))
1680
+ throw new Error('op must be one of import, reconcile, adopt, scope, scope-clear');
1681
+ const op = body.op;
1682
+ if (body.apply !== undefined && typeof body.apply !== 'boolean')
1683
+ throw new Error('apply must be true or false');
1684
+ const apply = body.apply === true;
1685
+ if (apply && isDemo()) {
1686
+ return billingJson(res, 409, { error: new DemoWriteBlockedError('the billing change').message });
1687
+ }
1688
+ if (op === 'import') {
1689
+ const fileName = billingText(body.fileName, 'fileName', 200);
1690
+ if (fileName === '.' || fileName === '..' || posix.basename(fileName) !== fileName || win32.basename(fileName) !== fileName) {
1691
+ throw new Error('fileName must be an ordinary basename, without directories');
1692
+ }
1693
+ const encoded = billingText(body.contentBase64, 'contentBase64', BILLING_WRITE_BODY_BYTES);
1694
+ if (encoded.length % 4 !== 0 || !BASE64.test(encoded))
1695
+ throw new Error('contentBase64 must be the file\'s bytes in standard base64');
1696
+ // The file's own bytes are what is hashed, exactly as the CLI hashes the file on disk.
1697
+ const parsed = parseBillingImportText(Buffer.from(encoded, 'base64'), fileName);
1698
+ if (!apply) {
1699
+ return billingJson(res, 200, {
1700
+ ok: true, op, applied: false, preview: parsed.preview,
1701
+ message: 'Not recorded. Apply to import this file.',
1702
+ });
1703
+ }
1704
+ const result = store.applyBillingImport(parsed.input);
1705
+ // Canonical kernel issue after the legacy compatibility write, as in the CLI.
1706
+ const kernel = store.issueBillingImportToKernel(result.run.importId);
1707
+ // BigInt coefficient converted to its canonical string form for the envelope only.
1708
+ const kernelJson = { ...kernel, total: { ...moneyToJson(kernel.total) } };
1709
+ const message = result.duplicateFile
1710
+ ? `Identical billing evidence was already imported as ${result.run.importId}; no duplicate provider records written.`
1711
+ : `Imported ${result.run.recordsInserted} provider-declared charge line(s); ${result.run.recordsDuplicate} already existed. Status remains not_reconciled. Provider-reported totals are stored separately from local metered estimates.`;
1712
+ return billingJson(res, 200, {
1713
+ ok: true, op, applied: true, message, preview: parsed.preview,
1714
+ duplicateFile: result.duplicateFile, run: result.run, kernel: kernelJson,
1715
+ });
1716
+ }
1717
+ const outcome = op === 'reconcile' ? billingReconcile(store, body, apply)
1718
+ : op === 'adopt' ? billingAdopt(store, body, apply)
1719
+ : op === 'scope-clear' ? billingScopeClear(store, apply)
1720
+ : billingScope(store, body, apply);
1721
+ return billingJson(res, outcome.status, outcome.payload);
1722
+ }
1723
+ catch (error) {
1724
+ return billingJson(res, 400, { error: error instanceof SyntaxError ? 'billing request must contain valid JSON' : error instanceof Error ? error.message : String(error) });
1725
+ }
1726
+ })();
1727
+ }
878
1728
  /** Same local-only POST guard as other mutations. A POST with apply:false is
879
1729
  * the preview; only a second deliberate POST with apply:true appends evidence. */
880
1730
  export function handleOutcomeRecord({ req, res, store, config }) {
@@ -1071,6 +1921,14 @@ export function handleSettingsUpdate({ req, res, store, config, version, configP
1071
1921
  if (err instanceof SyntaxError) {
1072
1922
  return json(res, 400, { error: { code: 'SETTINGS_INVALID_JSON', message: 'settings request body must be valid JSON' } });
1073
1923
  }
1924
+ if (err instanceof DemoReadOnlyError || (err instanceof Error && err.name === 'DemoReadOnlyError')) {
1925
+ return json(res, 409, {
1926
+ error: {
1927
+ code: 'SETTINGS_DEMO_READ_ONLY',
1928
+ message: "This dashboard is showing sample data, so settings can't be changed here. Start Segreant without --demo to change your own settings.",
1929
+ },
1930
+ });
1931
+ }
1074
1932
  return json(res, 500, { error: { code: 'SETTINGS_UPDATE_FAILED', message: 'settings could not be persisted' } });
1075
1933
  }
1076
1934
  })();
@@ -1110,10 +1968,20 @@ export const ROUTES = [
1110
1968
  // POST carries the CSRF gate.
1111
1969
  apiRoute('scan', handleScan),
1112
1970
  apiRoute('overview', handleOverview),
1971
+ apiRoute('sessions', handleSessions),
1113
1972
  apiRoute('billing', handleBilling),
1973
+ apiRoute('statement', handleStatement),
1974
+ apiRoute('statement-csv', handleStatementCsv),
1114
1975
  apiRoute('allocation', handleAllocation),
1976
+ apiRoute('allocation-write', handleAllocationWrite),
1977
+ apiRoute('allocation-run-csv', handleAllocationRunCsv),
1978
+ apiRoute('billing-write', handleBillingWrite),
1115
1979
  apiRoute('economic', handleEconomic),
1116
1980
  apiRoute('pricing', handlePricing),
1981
+ apiRoute('pricing-unknown', handlePricingUnknown),
1982
+ apiRoute('pricing-declare', handlePricingDeclare),
1983
+ apiRoute('pricing-unset', handlePricingUnset),
1984
+ apiRoute('reprice', handleReprice),
1117
1985
  apiRoute('export-csv', handleExportCsv),
1118
1986
  apiRoute('realization', handleRealization),
1119
1987
  apiRoute('guide', handleGuide),