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
@@ -14,9 +14,12 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkS
14
14
  import { createHash } from 'node:crypto';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { egressFetch, EgressError } from '../egress/transport.js';
17
+ import { DEFAULT_PRICE_LIST_URL } from '../egress/policy.js';
17
18
  import { dirname, join } from 'node:path';
18
19
  import { segreantHome } from '../config.js';
19
20
  import { computeExactCost, validateExactModelRate } from './exactPricing.js';
21
+ import { activePriceOverrides, priceOverrideFor } from './overrides.js';
22
+ import { money } from '../economics/money.js';
20
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
24
  /** The rate card shipped inside the package — always present, works offline. */
22
25
  const BUNDLED_PRICING_PATH = join(__dirname, '..', '..', 'pricing', 'models.json');
@@ -52,12 +55,14 @@ export const COST_BASES = [
52
55
  'tool_reported_unverified',
53
56
  'synthetic_demo',
54
57
  'unpriced',
58
+ 'not_sent',
59
+ 'user_declared_price',
55
60
  'legacy_unknown',
56
61
  ];
57
62
  /** Source of an attached rate-card hash. `none` is deliberately explicit for
58
63
  * tool-reported and unpriced rows, so a null hash is never mistaken for a lost
59
64
  * local rate card. */
60
- export const RATE_CARD_SOURCE_KINDS = ['manual', 'native_manifest', 'litellm_transformed', 'bundled', 'legacy_unknown', 'none'];
65
+ export const RATE_CARD_SOURCE_KINDS = ['manual', 'native_manifest', 'litellm_transformed', 'bundled', 'legacy_unknown', 'none', 'user_declaration'];
61
66
  /** How the observed model was resolved against a local rate card, if any. */
62
67
  export const RATE_MATCH_KINDS = [
63
68
  'exact_provider',
@@ -67,6 +72,9 @@ export const RATE_MATCH_KINDS = [
67
72
  'fallback',
68
73
  'reported',
69
74
  'unpriced',
75
+ 'not_sent',
76
+ 'user_declared',
77
+ 'user_declared_free',
70
78
  'legacy_unknown',
71
79
  ];
72
80
  export const MAX_PRICING_MANIFEST_BYTES = 5 * 1024 * 1024;
@@ -75,8 +83,17 @@ let cachedSource = 'bundled';
75
83
  function sha256(text) {
76
84
  return createHash('sha256').update(text, 'utf8').digest('hex');
77
85
  }
86
+ // Every priced request carries its card's hash, and an import prices tens of
87
+ // thousands of them against one card. A loaded card is replaced, never mutated,
88
+ // when pricing is refreshed, so the hash is remembered per card object.
89
+ const cardHashes = new WeakMap();
78
90
  function pricingCardHash(file) {
79
- return sha256(JSON.stringify(file));
91
+ let hash = cardHashes.get(file);
92
+ if (hash === undefined) {
93
+ hash = sha256(JSON.stringify(file));
94
+ cardHashes.set(file, hash);
95
+ }
96
+ return hash;
80
97
  }
81
98
  function redactedUrl(raw) {
82
99
  try {
@@ -299,15 +316,33 @@ export function loadPricing(force = false) {
299
316
  * scraping provider pricing pages (brittle HTML that needs a parser which
300
317
  * itself rots). The GET fetches a public file and sends nothing about the user.
301
318
  */
302
- export const DEFAULT_MANIFEST_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
303
- /** LiteLLM providers we carry, mapped onto our table's provider keys. */
319
+ export const DEFAULT_MANIFEST_URL = DEFAULT_PRICE_LIST_URL;
320
+ /**
321
+ * First-party model makers, mapped onto our table's provider keys. Hosts and
322
+ * resellers are excluded: their rates price the host's route, which a bare
323
+ * model name cannot identify.
324
+ */
304
325
  const LITELLM_PROVIDER_MAP = {
305
326
  anthropic: 'anthropic',
306
327
  openai: 'openai',
307
328
  gemini: 'google',
329
+ cohere_chat: 'cohere',
330
+ dashscope: 'dashscope',
331
+ deepseek: 'deepseek',
332
+ mistral: 'mistral',
333
+ perplexity: 'perplexity',
334
+ xai: 'xai',
335
+ zai: 'zai',
308
336
  };
309
337
  /** Fewer matched models than this means the feed shape changed — refuse, keep the current table. */
310
338
  const MIN_TRANSFORMED_MODELS = 10;
339
+ /** A per-1M rate as a plain decimal string, never exponent notation (1e-7 is "0.0000001"). */
340
+ function plainDecimal(n) {
341
+ const s = String(n);
342
+ if (!/e/i.test(s))
343
+ return s;
344
+ return n.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
345
+ }
311
346
  /** Per-token → per-1M, trimmed of float noise (3e-6/token → 3 $/1M exactly). */
312
347
  function perMillion(perToken) {
313
348
  return Number((perToken * 1e6).toPrecision(10));
@@ -315,7 +350,7 @@ function perMillion(perToken) {
315
350
  /**
316
351
  * Convert a LiteLLM price file into our manifest schema. Pure and total: bad
317
352
  * input yields `{ ok: false }`, never a throw. Only chat-capable entries from
318
- * providers we meter are carried; `verified` is false because the rates are
353
+ * first-party makers are carried; `verified` is false because the rates are
319
354
  * community-maintained; the conservative `unknown` fallback comes from the
320
355
  * CURRENT table, so a feed can never weaken it.
321
356
  */
@@ -360,18 +395,39 @@ export function transformLiteLLMManifest(rawText) {
360
395
  if (typeof e.cache_read_input_token_cost === 'number' && e.cache_read_input_token_cost > 0) {
361
396
  rate.cache_read = perMillion(e.cache_read_input_token_cost);
362
397
  }
398
+ // The decimal companion, so a model learned from the feed reprices to an
399
+ // exact amount: without it `reprice --apply` refused a first-sight model
400
+ // that had been recorded against the bundled card's exact fallback (PR #146 review).
401
+ const exact = {
402
+ input: plainDecimal(rate.input),
403
+ output: plainDecimal(rate.output),
404
+ };
405
+ if (rate.cache_write_5m !== undefined)
406
+ exact.cache_write_5m = plainDecimal(rate.cache_write_5m);
407
+ if (rate.cache_read !== undefined)
408
+ exact.cache_read = plainDecimal(rate.cache_read);
409
+ rate.exact = exact;
363
410
  bucket.models[name] = rate;
364
411
  }
365
412
  const total = Object.values(providers).reduce((n, p) => n + Object.keys(p.models).length, 0);
366
413
  if (total < MIN_TRANSFORMED_MODELS) {
367
414
  return { ok: false, error: `feed shape not recognized (only ${total} usable models found) — keeping the current table` };
368
415
  }
416
+ // Cross-provider lookups use insertion order: keep the big three ahead of
417
+ // other makers on name collisions, regardless of the upstream key order.
418
+ const primaryProviders = ['anthropic', 'openai', 'google'];
419
+ const providerOrder = [
420
+ ...primaryProviders,
421
+ ...Object.keys(providers).filter((provider) => !primaryProviders.includes(provider)).sort(),
422
+ ];
369
423
  const file = {
370
424
  schema_version: 1,
371
425
  updated: new Date().toISOString().slice(0, 10),
372
426
  currency: 'USD',
373
427
  unit: 'per_million_tokens',
374
- providers,
428
+ providers: Object.fromEntries(providerOrder
429
+ .filter((provider) => providers[provider] !== undefined)
430
+ .map((provider) => [provider, providers[provider]])),
375
431
  fallbacks: loadPricing().fallbacks,
376
432
  };
377
433
  return { ok: true, file };
@@ -666,17 +722,29 @@ function heuristicMatch(models, model) {
666
722
  function activeRateCardSourceKind() {
667
723
  return cachedSource === 'bundled' ? 'bundled' : cachedProvenance?.sourceKind ?? 'legacy_unknown';
668
724
  }
669
- function resolved(rate, rateMatchKind, rateMatchProvider, rateMatchModel) {
725
+ function resolved(rate, rateMatchKind, rateMatchProvider, rateMatchModel, declarationSha256 = null) {
670
726
  // rateFor always loads the active card first, so the card identity and source
671
727
  // describe this exact local computation rather than a later refresh.
672
728
  const card = loadPricing();
729
+ const declared = rateMatchKind === 'user_declared' || rateMatchKind === 'user_declared_free';
730
+ // A rate or free declaration is priced by the declaration alone: stamping it
731
+ // with the active card's hash made a card refresh look like a price change
732
+ // for requests whose price had not moved (PR #146 review). Only `like`
733
+ // depends on the card, and it names the card model it follows.
734
+ const fromCard = !declared || rateMatchProvider !== null;
673
735
  return {
674
736
  rate,
675
- estimated: rateMatchKind !== 'exact_provider' && rateMatchKind !== 'exact_cross_provider',
737
+ // A declared price is resolved, not guessed: it is the price the user said
738
+ // this model costs. It keeps its own basis so it is never shown as list price.
739
+ estimated: rateMatchKind !== 'exact_provider' && rateMatchKind !== 'exact_cross_provider' && !declared,
676
740
  pricing: {
677
- costBasis: rateMatchKind === 'fallback' ? 'fallback_estimate' : 'local_list_price',
678
- rateCardSha256: pricingCardHash(card),
679
- rateCardSourceKind: activeRateCardSourceKind(),
741
+ costBasis: rateMatchKind === 'fallback' ? 'fallback_estimate' : declared ? 'user_declared_price' : 'local_list_price',
742
+ // A rate or free declaration carries a revision of the declaration itself,
743
+ // labelled as the user's, never the card's: a changed price is then a new
744
+ // revision that value comparisons see, and a card refresh is not
745
+ // (PR #146 review).
746
+ rateCardSha256: fromCard ? pricingCardHash(card) : declarationSha256,
747
+ rateCardSourceKind: fromCard ? activeRateCardSourceKind() : declarationSha256 ? 'user_declaration' : 'none',
680
748
  rateMatchKind,
681
749
  rateMatchProvider,
682
750
  rateMatchModel,
@@ -705,6 +773,22 @@ export function unpricedPricingEvidence() {
705
773
  rateMatchModel: null,
706
774
  };
707
775
  }
776
+ /**
777
+ * A request Segreant refused before it reached the provider (a budget block, an
778
+ * egress refusal). Its cost is not unknown: nothing was sent, so nothing was
779
+ * charged. Recording it as `unpriced` made every cap refusal read as a missing
780
+ * price ("1 of 835 requests in this window are unpriced").
781
+ */
782
+ export function notSentPricingEvidence() {
783
+ return {
784
+ costBasis: 'not_sent',
785
+ rateCardSha256: null,
786
+ rateCardSourceKind: 'none',
787
+ rateMatchKind: 'not_sent',
788
+ rateMatchProvider: null,
789
+ rateMatchModel: null,
790
+ };
791
+ }
708
792
  /** The only default for records created before per-request pricing lineage existed. */
709
793
  export function legacyPricingEvidence() {
710
794
  return {
@@ -720,9 +804,117 @@ export function legacyPricingEvidence() {
720
804
  export function syntheticPricingEvidence(cost) {
721
805
  return { ...cost.pricing, costBasis: 'synthetic_demo' };
722
806
  }
807
+ /** The rate-card rate a `like` declaration points at: exact matches only, never another guess. */
808
+ function exactCardRate(pricing, model) {
809
+ for (const p of Object.keys(pricing.providers)) {
810
+ const r = pricing.providers[p]?.models[model];
811
+ if (r)
812
+ return { provider: p, rate: r };
813
+ }
814
+ return null;
815
+ }
816
+ /** USD per 1M tokens as a decimal string, back to the card's number form. */
817
+ function declaredRate(o) {
818
+ const exact = {
819
+ input: o.input,
820
+ output: o.output,
821
+ ...(o.cacheRead === undefined ? {} : { cache_read: o.cacheRead }),
822
+ // cacheWrite prices both TTLs unless cacheWrite1h names the 1-hour price
823
+ // (the engine and the usage record already tell the two apart).
824
+ ...(o.cacheWrite === undefined ? {} : { cache_write_5m: o.cacheWrite, cache_write_1h: o.cacheWrite }),
825
+ ...(o.cacheWrite1h === undefined ? {} : { cache_write_1h: o.cacheWrite1h }),
826
+ };
827
+ return {
828
+ input: Number(o.input),
829
+ output: Number(o.output),
830
+ ...(o.cacheRead === undefined ? {} : { cache_read: Number(o.cacheRead) }),
831
+ ...(o.cacheWrite === undefined ? {} : { cache_write_5m: Number(o.cacheWrite), cache_write_1h: Number(o.cacheWrite) }),
832
+ ...(o.cacheWrite1h === undefined ? {} : { cache_write_1h: Number(o.cacheWrite1h) }),
833
+ exact,
834
+ };
835
+ }
836
+ /** A stable revision of what was declared (the prices, not when): the same price declared again is the same revision. */
837
+ function declarationRevision(o) {
838
+ const { declaredAt: _when, ...price } = o;
839
+ const canonical = JSON.stringify(Object.keys(price).sort().map((k) => [k, price[k]]));
840
+ return createHash('sha256').update('segreant-declaration:v1\n' + canonical).digest('hex');
841
+ }
842
+ /** The rate with decimal companions for any it lacks, written from its own numbers. */
843
+ function withExactCompanion(rate) {
844
+ if (rate.exact !== undefined)
845
+ return rate;
846
+ const exact = {
847
+ input: plainDecimal(rate.input),
848
+ output: plainDecimal(rate.output),
849
+ };
850
+ if (rate.cache_write_5m !== undefined)
851
+ exact.cache_write_5m = plainDecimal(rate.cache_write_5m);
852
+ if (rate.cache_write_1h !== undefined)
853
+ exact.cache_write_1h = plainDecimal(rate.cache_write_1h);
854
+ if (rate.cache_read !== undefined)
855
+ exact.cache_read = plainDecimal(rate.cache_read);
856
+ return { ...rate, exact };
857
+ }
858
+ /** A free (local) model: every rate is zero, cache rates included. */
859
+ /** Where rateFor reads declarations; null reads the file (re-read when it changes). */
860
+ let declarationSource = null;
861
+ function withDeclarationSource(source, fn) {
862
+ const previous = declarationSource;
863
+ declarationSource = source;
864
+ try {
865
+ return fn();
866
+ }
867
+ finally {
868
+ declarationSource = previous;
869
+ }
870
+ }
871
+ /**
872
+ * Run `fn` pricing from the card alone. The demo is fixed sample data: a real
873
+ * declaration (say, gpt-4o marked free) must not change its spend, evidence or
874
+ * comparisons (PR #146 review).
875
+ */
876
+ export function withoutDeclaredPrices(fn) {
877
+ return withDeclarationSource(() => null, fn);
878
+ }
879
+ /**
880
+ * Run `fn` against one read of the declarations. A plan priced row by row
881
+ * re-read the file at each row, so a `pricing set` from another process midway
882
+ * gave earlier and later rows of the same model different prices in one
883
+ * correction (PR #146 review). The card needs no snapshot: it is cached for
884
+ * the life of the process and changes only by a refresh in this process,
885
+ * which cannot run inside a synchronous plan.
886
+ */
887
+ /** Run `fn` against declarations read earlier (a proxied request keeps the ones it was checked against). */
888
+ export function withDeclarations(models, fn) {
889
+ return withDeclarationSource((model) => models[model] ?? null, fn);
890
+ }
891
+ export function withPricingSnapshot(fn) {
892
+ if (declarationSource !== null)
893
+ return fn();
894
+ const models = activePriceOverrides();
895
+ return withDeclarationSource((model) => models[model] ?? null, fn);
896
+ }
897
+ const FREE_RATE = Object.freeze({ input: 0, output: 0, cache_read: 0, cache_write_5m: 0, cache_write_1h: 0 });
723
898
  export function rateFor(provider, model) {
724
899
  const pricing = loadPricing();
725
900
  const here = pricing.providers[provider]?.models;
901
+ // 0) A price the user declared for this exact model name wins: it is the one
902
+ // place a private, fine-tuned or local model can get a real price. A `like`
903
+ // that no longer resolves exactly on the card is ignored, so the request
904
+ // falls to the ordinary ladder rather than to a guess built on a guess.
905
+ const declared = declarationSource ? declarationSource(model) : priceOverrideFor(model);
906
+ if (declared?.kind === 'free')
907
+ return resolved(FREE_RATE, 'user_declared_free', null, model, declarationRevision(declared));
908
+ if (declared?.kind === 'rate')
909
+ return resolved(declaredRate(declared), 'user_declared', null, model, declarationRevision(declared));
910
+ if (declared?.kind === 'like') {
911
+ const target = exactCardRate(pricing, declared.like);
912
+ // A card entry without decimal companions (a hand-written manifest may omit
913
+ // them) would leave a reprice with no exact replacement and roll it back
914
+ // (PR #146 review); the companion is the card's own stated numbers.
915
+ if (target)
916
+ return resolved(withExactCompanion(target.rate), 'user_declared', target.provider, declared.like);
917
+ }
726
918
  // 1) Exact id in the named provider — the price we're surest about.
727
919
  const exact = here?.[model];
728
920
  if (exact)
@@ -755,11 +947,29 @@ export function rateFor(provider, model) {
755
947
  export function exactRateFromModelRate(rate) {
756
948
  return rate.exact === undefined ? null : validateExactModelRate(rate.exact);
757
949
  }
950
+ function zeroExactCost() {
951
+ const zero = money('0', 'USD', 'estimated');
952
+ return Object.freeze({ total: zero, components: Object.freeze({ input: zero, output: zero, cacheWrite: zero, cacheRead: zero }) });
953
+ }
758
954
  function per(tokens, rate) {
759
955
  if (!tokens || !rate)
760
956
  return 0;
761
957
  return (tokens / 1_000_000) * rate;
762
958
  }
959
+ /**
960
+ * The duration a cache write is priced at, and whether the source reported a
961
+ * breakdown. No breakdown: no duration (priced at the standard 5-minute rate,
962
+ * recorded as not recorded). One duration only: that one. Both: the larger
963
+ * share, which is what the request is priced with, and the ledger records it
964
+ * so a later reprice prices it the same way (PR #150 review). A missing
965
+ * breakdown used to be stored as '1h' by the Claude Code import and '5m' by
966
+ * the proxy, each as if it were seen (independent review, 2026-10-18).
967
+ */
968
+ export function cacheWriteDuration(ephemeral5m, ephemeral1h) {
969
+ if (ephemeral5m <= 0 && ephemeral1h <= 0)
970
+ return { observed: false };
971
+ return { ttl: ephemeral1h > ephemeral5m ? '1h' : '5m', observed: true };
972
+ }
763
973
  export function computeCost(provider, model, usage) {
764
974
  const { rate, estimated, pricing } = rateFor(provider, model);
765
975
  // Cache-write rate: prefer the TTL-specific rate, else default to 1.25x input
@@ -776,9 +986,12 @@ export function computeCost(provider, model, usage) {
776
986
  cacheRead: per(usage.cacheReadTokens, readRate),
777
987
  };
778
988
  const costUsd = components.input + components.output + components.cacheWrite + components.cacheRead;
779
- const exactRate = exactRateFromModelRate(rate);
780
- const exact = exactRate === null
781
- ? undefined
782
- : computeExactCost(exactRate, usage, estimated ? 'estimated' : 'list');
989
+ const declared = pricing.rateMatchKind === 'user_declared' || pricing.rateMatchKind === 'user_declared_free';
990
+ const exactRate = rate === FREE_RATE ? null : exactRateFromModelRate(rate);
991
+ const exact = rate === FREE_RATE
992
+ ? zeroExactCost()
993
+ : exactRate === null
994
+ ? undefined
995
+ : computeExactCost(exactRate, usage, estimated || declared ? 'estimated' : 'list');
783
996
  return { costUsd, estimated, pricing, rate, components, ...(exact === undefined ? {} : { exact }) };
784
997
  }
@@ -0,0 +1,130 @@
1
+ import { computeCost, withPricingSnapshot } from './pricing.js';
2
+ import { requestEconomicEventId } from '../economics/request.js';
3
+ /**
4
+ * What priced the replacement amounts, from every row that will change: a
5
+ * report built from the first row alone claimed "local rate card only" for a
6
+ * batch that also held prices the user typed (PR #146 review).
7
+ */
8
+ function repriceEvidence(updates) {
9
+ if (updates.length === 0)
10
+ return null;
11
+ const sources = new Map();
12
+ for (const u of updates) {
13
+ const key = JSON.stringify([u.pricing.costBasis, u.pricing.rateCardSha256, u.pricing.rateCardSourceKind]);
14
+ const s = sources.get(key) ?? { costBasis: u.pricing.costBasis, rateCardSha256: u.pricing.rateCardSha256, rateCardSourceKind: u.pricing.rateCardSourceKind, rows: 0 };
15
+ s.rows += 1;
16
+ sources.set(key, s);
17
+ }
18
+ const list = [...sources.values()].sort((a, b) => b.rows - a.rows);
19
+ const declaredRows = list.filter((s) => s.costBasis === 'user_declared_price').reduce((n, s) => n + s.rows, 0);
20
+ const cards = [...new Set(list.filter((s) => s.rateCardSha256 !== null).map((s) => `${s.rateCardSourceKind} ${s.rateCardSha256}`))];
21
+ const onlyCard = declaredRows === 0 && cards.length === 1 ? list.find((s) => s.rateCardSha256 !== null) : null;
22
+ return {
23
+ localRateCardOnly: declaredRows === 0,
24
+ cardSha256: onlyCard?.rateCardSha256 ?? null,
25
+ sourceKind: onlyCard?.rateCardSourceKind ?? (declaredRows === updates.length ? 'none' : 'mixed'),
26
+ declaredRows,
27
+ sources: list,
28
+ note: declaredRows === 0
29
+ ? 'Local list-price estimate only; never provider invoice, discount, tax, credit, or reconciliation.'
30
+ : `${declaredRows} of ${updates.length} rows take a price you set (your price, not a list price); the rest are local list prices. None is a provider invoice, discount, tax, credit, or reconciliation.`,
31
+ };
32
+ }
33
+ export function previewReprice(store) {
34
+ // Every row of one plan is priced from the same declarations.
35
+ return withPricingSnapshot(() => planReprice(store));
36
+ }
37
+ function planReprice(store) {
38
+ const rows = store.estimatedRequestRows();
39
+ const updates = [];
40
+ const meta = new Map();
41
+ let stillEstimated = 0;
42
+ // Rows with cache writes whose duration was never recorded (before Segreant
43
+ // kept it): priced at the 5-minute rate, as they were first priced, and
44
+ // counted so the preview says so (PR #146 review).
45
+ let cacheDurationUnknown = 0;
46
+ for (const r of rows) {
47
+ // No captured usage: nothing to price it from, so it stays an estimate.
48
+ if (!r.usageCaptured) {
49
+ stillEstimated++;
50
+ continue;
51
+ }
52
+ const c = computeCost(r.provider, r.model, {
53
+ inputTokens: r.inputTokens, outputTokens: r.outputTokens,
54
+ cacheWriteTokens: r.cacheWriteTokens, cacheReadTokens: r.cacheReadTokens,
55
+ ...(r.cacheWriteTtl === null ? {} : { cacheWriteTtl: r.cacheWriteTtl }),
56
+ });
57
+ if (c.estimated) {
58
+ stillEstimated++;
59
+ continue;
60
+ }
61
+ // A row with an exact economic event can only be corrected by an exact
62
+ // amount. A custom card may omit the decimal companions, and the plan used
63
+ // to promise such a row, then --apply refused it and rolled back the whole
64
+ // batch (PR #146 review). It stays an estimate until a card can price it exactly.
65
+ if (c.exact === undefined && store.economic().read(requestEconomicEventId(r.requestId)) !== null) {
66
+ stillEstimated++;
67
+ continue;
68
+ }
69
+ // Counted only for rows the plan will rewrite, so the preview never says a
70
+ // row's writes are priced at the 5-minute rate when it is left untouched.
71
+ if (r.cacheWriteTokens > 0 && r.cacheWriteTtl === null)
72
+ cacheDurationUnknown++;
73
+ updates.push({ requestId: r.requestId, costUsd: c.costUsd, pricing: c.pricing, ...(c.exact === undefined ? {} : { economicAmount: c.exact.total }) });
74
+ meta.set(r.requestId, {
75
+ provider: r.provider, model: r.model, beforeUsd: r.costUsd,
76
+ cacheDurationUnknown: r.cacheWriteTokens > 0 && r.cacheWriteTtl === null,
77
+ });
78
+ }
79
+ return { ...summarize(updates, meta, 0), estimatedRows: rows.length, stillEstimated, cacheDurationUnknown, meta };
80
+ }
81
+ /** Totals, models and evidence for exactly these updates: the plan, or the rows a write touched. */
82
+ function summarize(updates, meta, alreadyRepriced) {
83
+ const byModel = new Map();
84
+ for (const u of updates) {
85
+ const r = meta.get(u.requestId);
86
+ const key = JSON.stringify([r.provider, r.model]);
87
+ const agg = byModel.get(key) ?? {
88
+ provider: r.provider, model: r.model, rows: 0, beforeUsd: 0, afterUsd: 0,
89
+ afterBasis: u.pricing.rateMatchKind === 'user_declared' || u.pricing.rateMatchKind === 'user_declared_free' ? 'your price' : 'list price',
90
+ };
91
+ agg.rows++;
92
+ agg.beforeUsd += r.beforeUsd;
93
+ agg.afterUsd += u.costUsd;
94
+ byModel.set(key, agg);
95
+ }
96
+ return {
97
+ applied: false, repriceable: updates.length,
98
+ /** Planned rows another reprice had already rewritten by the time this one wrote: left alone, not counted. */
99
+ alreadyRepriced,
100
+ totalBeforeUsd: [...byModel.values()].reduce((a, v) => a + v.beforeUsd, 0),
101
+ totalAfterUsd: [...byModel.values()].reduce((a, v) => a + v.afterUsd, 0),
102
+ priceEvidence: repriceEvidence(updates),
103
+ byModel: [...byModel.values()],
104
+ // Internal write plan; never expose the exact bigint money objects on the JSON wire.
105
+ updates,
106
+ realizationSync: null,
107
+ };
108
+ }
109
+ /**
110
+ * Compute once, write that plan through the Store's audited transaction, and
111
+ * report only the rows the transaction wrote. Another process may reprice the
112
+ * same rows between the plan and the write; the store skips a row that is no
113
+ * longer an estimate, and the result used to claim it anyway (PR #146 review).
114
+ */
115
+ export function applyReprice(store) {
116
+ const preview = previewReprice(store);
117
+ if (preview.updates.length === 0)
118
+ return { ...preview, applied: true };
119
+ const { writtenRequestIds, ...realizationSync } = store.applyRepricedCosts(preview.updates);
120
+ const written = new Set(writtenRequestIds);
121
+ const done = preview.updates.filter((u) => written.has(u.requestId));
122
+ const cacheDurationUnknown = done.filter((u) => preview.meta.get(u.requestId).cacheDurationUnknown).length;
123
+ return {
124
+ ...preview,
125
+ ...summarize(done, preview.meta, preview.updates.length - done.length),
126
+ cacheDurationUnknown,
127
+ applied: true,
128
+ realizationSync,
129
+ };
130
+ }
@@ -52,6 +52,7 @@
52
52
  * conflict lands on `coverage`, and the difference is the point rather than an
53
53
  * inconsistency.
54
54
  */
55
+ import { recordedTime } from '../util/time.js';
55
56
  /**
56
57
  * The seven axes that do not vary, and why stating them is the point.
57
58
  *
@@ -136,7 +137,7 @@ export function meteredClaimSupport(input) {
136
137
  notes.push('no priced spend in this window, so pricing coverage is unevidenced rather than complete');
137
138
  if (truncated) {
138
139
  const removed = input.retention.rowsRemoved === 1 ? '1 request row' : `${input.retention.rowsRemoved} request rows`;
139
- notes.push(`retention deleted ${removed} from before ${new Date(input.retention.prunedBeforeMs ?? 0).toISOString()}, `
140
+ notes.push(`retention deleted ${removed} from before ${recordedTime(input.retention.prunedBeforeMs)}, `
140
141
  + 'so this window covers what survived rather than everything metered in it');
141
142
  }
142
143
  return claimSupportPayload({
@@ -189,8 +190,8 @@ export function billedClaimSupport(input) {
189
190
  // deleted by retention bounds nothing either, for a different reason -- the
190
191
  // local total is a known undercount by an unknown amount. Both are "this
191
192
  // residual establishes no bound", which is what this variable feeds.
192
- const boundsNothing = input.latest?.offPathBound === 'none_local_estimate_exceeds_provider'
193
- || input.latest?.offPathBound === 'unknown_local_total_truncated_by_retention';
193
+ const truncatedByRetention = input.latest?.offPathBound === 'unknown_local_total_truncated_by_retention';
194
+ const residualBoundsNothing = input.latest?.offPathBound === 'none_local_estimate_exceeds_provider';
194
195
  return claimSupportPayload({
195
196
  ...PRODUCT_CLAIM_AXES,
196
197
  epistemic: unstable ? 'conflicted' : 'supported',
@@ -198,16 +199,18 @@ export function billedClaimSupport(input) {
198
199
  // immutable and identified by the digest of the result it describes, so it
199
200
  // cannot outlive a change to that result.
200
201
  integrity: 'verified',
201
- coverage: boundsNothing ? 'partial' : 'complete',
202
+ coverage: truncatedByRetention || residualBoundsNothing ? 'partial' : 'complete',
202
203
  monetaryBasis: 'billed',
203
204
  },
204
205
  // An evidence claim about whether a comparison happened, not a second cost
205
206
  // figure. The band carries no dollar in any branch.
206
207
  'not_a_money_claim', unstable
207
208
  ? `provider snapshots disagreed on ${days.length} day(s) of this scope`
208
- : boundsNothing
209
- ? 'the local estimate exceeds the provider total, so the residual bounds no off-path spend'
210
- : undefined);
209
+ : truncatedByRetention
210
+ ? 'retention deleted local request rows in this period, so the residual bounds no off-path spend'
211
+ : residualBoundsNothing
212
+ ? 'the local estimate exceeds the provider total, so the residual bounds no off-path spend'
213
+ : undefined);
211
214
  }
212
215
  /**
213
216
  * Allocation is showback: the claim is whose cost it is, not how much, so the
@@ -263,6 +266,6 @@ export function realizedClaimSupport(input) {
263
266
  }, !supported ? 'withheld_unsupported' : input.valued ? 'shown' : 'withheld_uncosted', conflicted.length > 0
264
267
  ? `${conflictedUnits} mature unit(s) hold contradicted gate evidence at ${conflicted
265
268
  .map(([gate]) => gate)
266
- .join(', ')} and are unadjudicated rather than refuted`
269
+ .join(', ')}; the conflict is unresolved, not a fail`
267
270
  : undefined);
268
271
  }
@@ -18,9 +18,19 @@ export const DASHBOARD_API_CONTRACTS = [
18
18
  { id: 'scan', path: '/api/scan', methods: ['GET', 'POST'], localOnly: ['POST'], responseType: 'ScanPayload', browserBinding: ['modern-api', 'classic'] },
19
19
  { id: 'overview', path: '/api/overview', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'Overview', browserBinding: ['modern-api', 'classic'] },
20
20
  { id: 'billing', path: '/api/billing', methods: ['GET'], localOnly: [], responseType: 'BillingPayload', browserBinding: ['modern-api', 'classic'] },
21
+ { id: 'statement', path: '/api/statement', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'StatementPayload', browserBinding: ['modern-api'] },
22
+ { id: 'sessions', path: '/api/sessions', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'SessionsPayload', browserBinding: ['modern-api'] },
23
+ { id: 'statement-csv', path: '/api/statement.csv', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'text/csv', browserBinding: ['modern-api'] },
21
24
  { id: 'allocation', path: '/api/allocation', methods: ['GET'], localOnly: [], responseType: 'AllocationPayload', browserBinding: ['modern-api', 'classic'] },
25
+ { id: 'allocation-write', path: '/api/allocation/write', methods: ['POST'], localOnly: ['POST'], responseType: 'AllocationWritePayload', browserBinding: ['modern-api'] },
26
+ { id: 'allocation-run-csv', path: '/api/allocation/run.csv', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'text/csv', browserBinding: ['modern-api'] },
27
+ { id: 'billing-write', path: '/api/billing/write', methods: ['POST'], localOnly: ['POST'], responseType: 'BillingWritePayload', browserBinding: ['modern-api'] },
22
28
  { id: 'economic', path: '/api/economic', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'EconomicPayload', browserBinding: ['modern-api'] },
23
29
  { id: 'pricing', path: '/api/pricing', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'PricingPayload', browserBinding: [] },
30
+ { id: 'pricing-unknown', path: '/api/pricing/unknown', methods: ['GET'], localOnly: [], responseType: 'UnknownPricingPayload', browserBinding: ['modern-api'] },
31
+ { id: 'pricing-declare', path: '/api/pricing/declare', methods: ['POST'], localOnly: ['POST'], responseType: 'PricingDeclarePayload', browserBinding: ['modern-api'] },
32
+ { id: 'pricing-unset', path: '/api/pricing/unset', methods: ['POST'], localOnly: ['POST'], responseType: 'PricingUnsetPayload', browserBinding: ['modern-api'] },
33
+ { id: 'reprice', path: '/api/reprice', methods: ['POST'], localOnly: ['POST'], responseType: 'RepricePayload', browserBinding: ['modern-api'] },
24
34
  { id: 'export-csv', path: '/api/export.csv', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'text/csv', browserBinding: ['classic', 'actions'] },
25
35
  { id: 'realization', path: '/api/realization', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'RealizationPayload', browserBinding: [] },
26
36
  { id: 'guide', path: '/api/guide', methods: ['GET', 'HEAD'], localOnly: [], responseType: 'GuidePayload', browserBinding: ['modern-api', 'classic'] },
@@ -59,21 +69,31 @@ export const DASHBOARD_PAYLOAD_CONTRACTS = [
59
69
  { routeId: 'scan', method: 'GET', contentType: 'json', responseType: 'ScanPayload', required: [field('ok', 'boolean'), field('tools', 'array'), field('otherApps', 'array'), field('roots', 'array'), field('repoCount', 'number'), field('reposWithSpend', 'number'), field('hitBudget', 'boolean'), field('dirsVisited', 'number'), field('unreadableDirs', 'number'), field('diff', 'object')] },
60
70
  { routeId: 'scan', method: 'POST', contentType: 'json', responseType: 'ScanSetupPayload', required: [field('ok', 'boolean'), field('totalNew', 'number'), field('imported', 'object'), field('correlated', 'number'), field('discovered', 'array')] },
61
71
  { routeId: 'overview', method: 'GET', contentType: 'json', responseType: 'Overview', required: [field('claimSupport', 'object'), field('range', 'string'), field('demo', 'boolean'), field('generatedAt', 'string'), field('budget', 'object'), field('summary', 'object'), field('pricing', 'object'), field('byModel', 'array'), field('byProject', 'array'), field('attributionEvidence', 'array'), field('byUser', 'array'), field('bySource', 'array'), field('characterization', 'object'), field('dimensions', 'array'), field('series', 'array'), field('recent', 'array'), field('alerts', 'array'), field('alertCoverage', 'object')] },
72
+ { routeId: 'statement', method: 'GET', contentType: 'json', responseType: 'StatementPayload', required: [field('demo', 'boolean'), field('generatedAt', 'string'), field('month', 'string'), field('period', 'object'), field('metered', 'object'), field('billed', 'object'), field('allocated', 'object'), field('realized', 'object'), field('close', 'object')] },
73
+ { routeId: 'sessions', method: 'GET', contentType: 'json', responseType: 'SessionsPayload', required: [field('demo', 'boolean'), field('generatedAt', 'string'), field('range', 'string'), field('window', 'object'), field('totals', 'object'), field('models', 'array'), field('buckets', 'array'), field('sessions', 'array'), field('sessionLimit', 'number'), field('sessionsOmitted', 'number'), field('outcomes', 'object')] },
62
74
  { routeId: 'billing', method: 'GET', contentType: 'json', responseType: 'BillingPayload', required: [field('claimSupport', 'object'), field('demo', 'boolean'), field('generatedAt', 'string'), field('evidence', 'object'), field('summary', 'object'), field('imports', 'array'), field('kernel', 'object'), field('readiness', 'object'), field('mapping', 'object'), field('directOpenAiCosts', 'object'), field('reconciliation', 'object')] },
63
75
  { routeId: 'allocation', method: 'GET', contentType: 'json', responseType: 'AllocationPayload', required: [field('claimSupport', 'object'), field('demo', 'boolean'), field('generatedAt', 'string'), field('kind', 'string'), field('trust', 'string'), field('basis', 'string'), field('excludedFrom', 'array'), field('costCentres', 'array'), field('rules', 'array'), field('runs', 'array'), field('reconciliation', 'object')] },
76
+ { routeId: 'allocation-write', method: 'POST', contentType: 'json', responseType: 'AllocationWritePayload', required: [field('ok', 'boolean'), field('op', 'string'), field('applied', 'boolean')] },
77
+ { routeId: 'billing-write', method: 'POST', contentType: 'json', responseType: 'BillingWritePayload', required: [field('ok', 'boolean'), field('op', 'string'), field('applied', 'boolean')] },
64
78
  { routeId: 'economic', method: 'GET', contentType: 'json', responseType: 'EconomicPayload', required: [field('kind', 'string'), field('schemaVersion', 'number'), field('demo', 'boolean'), field('window', 'object'), field('translation', 'object', true), field('projection', 'object'), field('periodClose', 'object')] },
65
79
  { routeId: 'pricing', method: 'GET', contentType: 'json', responseType: 'PricingPayload', required: [field('demo', 'boolean'), field('generatedAt', 'string'), field('window', 'object'), field('activeRateCard', 'object'), field('total', 'object'), field('provenance', 'array'), field('boundary', 'string')] },
80
+ { routeId: 'pricing-unknown', method: 'GET', contentType: 'json', responseType: 'UnknownPricingPayload', required: [field('models', 'array'), field('boundary', 'string'), field('demo', 'boolean')] },
81
+ { routeId: 'pricing-declare', method: 'POST', contentType: 'json', responseType: 'PricingDeclarePayload', required: [field('ok', 'boolean'), field('model', 'string'), field('price', 'object')] },
82
+ { routeId: 'pricing-unset', method: 'POST', contentType: 'json', responseType: 'PricingUnsetPayload', required: [field('ok', 'boolean'), field('model', 'string'), field('removed', 'boolean')] },
83
+ { routeId: 'reprice', method: 'POST', contentType: 'json', responseType: 'RepricePayload', required: [field('applied', 'boolean'), field('demo', 'boolean'), field('estimatedRows', 'number'), field('repriceable', 'number'), field('stillEstimated', 'number'), field('totalBeforeUsd', 'number'), field('totalAfterUsd', 'number'), field('byModel', 'array'), field('realizationSync', 'object', true)] },
66
84
  { routeId: 'export-csv', method: 'GET', contentType: 'text', responseType: 'text/csv', required: [] },
85
+ { routeId: 'statement-csv', method: 'GET', contentType: 'text', responseType: 'text/csv', required: [] },
86
+ { routeId: 'allocation-run-csv', method: 'GET', contentType: 'text', responseType: 'text/csv', required: [] },
67
87
  { routeId: 'realization', method: 'GET', contentType: 'json', responseType: 'RealizationPayload', required: [field('available', 'boolean'), field('repo', 'string')] },
68
88
  { routeId: 'guide', method: 'GET', contentType: 'json', responseType: 'GuidePayload', required: [field('stage', 'string'), field('headline', 'string'), field('steps', 'array'), field('next', 'object'), field('hint', 'object', true)] },
69
89
  { routeId: 'judge', method: 'POST', contentType: 'json', responseType: 'JudgePayload', required: [] },
70
90
  { routeId: 'kernel', method: 'GET', contentType: 'json', responseType: 'KernelNodePayload', required: [field('found', 'boolean'), field('id', 'string'), field('asOf', 'string', true), field('node', 'object', true)] },
71
- { routeId: 'value', method: 'GET', contentType: 'json', responseType: 'ValuePayload', required: [field('claimSupport', 'object'), field('demo', 'boolean'), field('gitRepo', 'boolean'), field('valueSource', 'string', true), field('repo', 'string'), field('generatedAt', 'string'), field('realization', 'object', true), field('roi', 'object', true), field('frontier', 'object', true), field('budget', 'object', true), field('allocation', 'object', true), field('projects', 'array'), field('projectAllocation', 'object', true), field('usage', 'object', true), field('selfReported', 'object'), field('team', 'object', true), field('drift', 'object', true), field('reclaimed', 'object', true)] },
91
+ { routeId: 'value', method: 'GET', contentType: 'json', responseType: 'ValuePayload', required: [field('claimSupport', 'object'), field('demo', 'boolean'), field('gitRepo', 'boolean'), field('valueSource', 'string', true), field('repo', 'string'), field('generatedAt', 'string'), field('realization', 'object', true), field('roi', 'object', true), field('frontier', 'object', true), field('budget', 'object', true), field('allocation', 'object', true), field('projects', 'array', true), field('projectAllocation', 'object', true), field('usage', 'object', true), field('selfReported', 'object'), field('team', 'object', true), field('drift', 'object', true), field('reclaimed', 'object', true)] },
72
92
  { routeId: 'market', method: 'GET', contentType: 'json', responseType: 'MarketPayload', required: [field('status', 'string'), field('categories', 'array'), field('boundary', 'array'), field('cacheErrors', 'array')] },
73
93
  { routeId: 'outcome-record', method: 'POST', contentType: 'json', responseType: 'OutcomeRecordPayload', required: [field('apply', 'boolean'), field('basis', 'string'), field('event', 'object'), field('matchedRequests', 'number'), field('matchedCostUsd', 'number', true), field('linkStatus', 'string')] },
74
94
  { routeId: 'causal', method: 'GET', contentType: 'json', responseType: 'CausalPayload', required: [field('demo', 'boolean'), field('generatedAt', 'string'), field('studies', 'array'), field('study', 'object', true), field('causalEvidence', 'string'), field('boundary', 'string')] },
75
- { routeId: 'settings', method: 'GET', contentType: 'json', responseType: 'SettingsSnapshot', required: [field('version', 'string'), field('home', 'string'), field('configPath', 'string'), field('dbPath', 'string'), field('proxyPort', 'number'), field('dashboardPort', 'number'), field('retentionDays', 'number'), field('proposalRetentionDays', 'number'), field('metadataOnly', 'boolean'), field('features', 'object'), field('budget', 'object'), field('enforcement', 'object'), field('egress', 'object'), field('connections', 'array')] },
76
- { routeId: 'settings-update', method: 'POST', contentType: 'json', responseType: 'SettingsSnapshot', required: [field('version', 'string'), field('home', 'string'), field('configPath', 'string'), field('dbPath', 'string'), field('proxyPort', 'number'), field('dashboardPort', 'number'), field('retentionDays', 'number'), field('proposalRetentionDays', 'number'), field('metadataOnly', 'boolean'), field('features', 'object'), field('budget', 'object'), field('enforcement', 'object'), field('egress', 'object'), field('connections', 'array')] },
95
+ { routeId: 'settings', method: 'GET', contentType: 'json', responseType: 'SettingsSnapshot', required: [field('version', 'string'), field('home', 'string'), field('configPath', 'string'), field('dbPath', 'string'), field('proxyPort', 'number'), field('dashboardPort', 'number'), field('retentionDays', 'number'), field('proposalRetentionDays', 'number'), field('metadataOnly', 'boolean'), field('demo', 'boolean'), field('features', 'object'), field('budget', 'object'), field('enforcement', 'object'), field('egress', 'object'), field('connections', 'array')] },
96
+ { routeId: 'settings-update', method: 'POST', contentType: 'json', responseType: 'SettingsSnapshot', required: [field('version', 'string'), field('home', 'string'), field('configPath', 'string'), field('dbPath', 'string'), field('proxyPort', 'number'), field('dashboardPort', 'number'), field('retentionDays', 'number'), field('proposalRetentionDays', 'number'), field('metadataOnly', 'boolean'), field('demo', 'boolean'), field('features', 'object'), field('budget', 'object'), field('enforcement', 'object'), field('egress', 'object'), field('connections', 'array')] },
77
97
  { routeId: 'clear-proposals', method: 'POST', contentType: 'json', responseType: 'ClearProposalsPayload', required: [field('ok', 'boolean'), field('removed', 'number')] },
78
98
  ];
79
99
  export function dashboardPayloadContract(routeId, method) {