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
@@ -49,7 +49,24 @@ async function request(path, init) {
49
49
  }
50
50
  if (!response.ok) {
51
51
  const detail = await response.text().catch(() => '');
52
- throw new ApiError(detail.slice(0, 400) || `${response.status} ${response.statusText}`, response.status, path);
52
+ // Show the human message, never the raw JSON envelope. The dashboard's own
53
+ // routes answer `{ error: "sentence" }`; some answer `{ error: { message } }`.
54
+ // Only the second was read, so every refusal from a dashboard route, an
55
+ // allocation or billing write included, reached the screen as raw JSON.
56
+ let human = null;
57
+ try {
58
+ const parsed = JSON.parse(detail);
59
+ const error = typeof parsed === 'object' && parsed !== null && 'error' in parsed ? parsed.error : undefined;
60
+ if (typeof error === 'string' && error.trim())
61
+ human = error;
62
+ else if (typeof error === 'object' && error !== null && typeof error.message === 'string') {
63
+ human = error.message;
64
+ }
65
+ }
66
+ catch {
67
+ // Not JSON: keep the text fallback below.
68
+ }
69
+ throw new ApiError(human ?? (detail.slice(0, 400) || `${response.status} ${response.statusText}`), response.status, path);
53
70
  }
54
71
  const payload = await response.json();
55
72
  // The generic type gives the compiler a view of what the caller consumes;
@@ -86,6 +103,13 @@ export const api = {
86
103
  overview: (range, signal) => request(`${routePath('overview')}?range=${encodeURIComponent(range)}`, signal ? { signal } : undefined),
87
104
  billing: () => request(routePath('billing')),
88
105
  allocation: () => request(routePath('allocation')),
106
+ /** One calendar month, `YYYY-MM`; the four claims side by side, never summed. */
107
+ statement: (month) => request(month ? `${routePath('statement')}?month=${encodeURIComponent(month)}` : routePath('statement')),
108
+ /** The Workbench: the range's sessions, buckets by model, and what their commits came to. */
109
+ sessions: (range, signal) => request(`${routePath('sessions')}?range=${encodeURIComponent(range)}`, signal ? { signal } : undefined),
110
+ /** A download link, not a fetch: the browser saves the month's CSV. */
111
+ statementCsvHref: (month) => `${routePath('statement-csv')}?month=${encodeURIComponent(month)}`,
112
+ allocationRunCsvHref: (allocationRunId) => `${routePath('allocation-run-csv')}?id=${encodeURIComponent(allocationRunId)}`,
89
113
  economic: (range = '30d', options = {}) => {
90
114
  const params = new URLSearchParams(range === 'all' ? { all: '1' } : { days: '30' });
91
115
  if (options.targetCurrency !== undefined)
@@ -107,8 +131,20 @@ export const api = {
107
131
  importers: () => request(routePath('importers')),
108
132
  /** GET /api/scan is the dry run: it detects and reports, and imports nothing. */
109
133
  scan: () => request(routePath('scan')),
134
+ pricingUnknown: () => request(routePath('pricing-unknown')),
110
135
  /** Mutating calls are grouped so every write in the GUI is greppable in one place. */
111
136
  write: {
137
+ pricingDeclare: (body) => request(routePath('pricing-declare'), { method: 'POST', body: JSON.stringify(body) }),
138
+ pricingUnset: (model) => request(routePath('pricing-unset'), { method: 'POST', body: JSON.stringify({ model }) }),
139
+ reprice: (apply) => request(routePath('reprice'), { method: 'POST', body: JSON.stringify({ apply }) }),
140
+ /**
141
+ * Month close: `scope`, `import`, `adopt`, `reconcile`. Without `apply: true`
142
+ * every op is a preview and writes nothing. An import carries the file's raw
143
+ * bytes in base64, so its digest is the one the CLI computes for the file.
144
+ */
145
+ billing: (body) => request(routePath('billing-write'), { method: 'POST', body: JSON.stringify(body) }),
146
+ /** Cost centres, rules and runs. A run with `apply: false` is a preview and records nothing. */
147
+ allocation: (body) => request(routePath('allocation-write'), { method: 'POST', body: JSON.stringify(body) }),
112
148
  outcome: (input, apply) => request(routePath('outcome-record'), { method: 'POST', body: JSON.stringify({ ...input, apply }) }),
113
149
  settings: (patch) => request(routePath('settings-update'), { method: 'POST', body: JSON.stringify(patch) }),
114
150
  clearProposals: () => request(routePath('clear-proposals'), { method: 'POST' }),
@@ -15,8 +15,8 @@
15
15
  * zero.
16
16
  */
17
17
  import { api } from './api.js';
18
- import { buildClaimLayers } from './claimLayers.js';
19
- export async function loadChain(range) {
18
+ import { buildClaimLayers, chainIsSampleData } from './claimLayers.js';
19
+ export async function loadChainState(range) {
20
20
  const [overview, billing, allocation, value] = await Promise.allSettled([
21
21
  api.overview(range),
22
22
  api.billing(),
@@ -24,10 +24,17 @@ export async function loadChain(range) {
24
24
  api.value(),
25
25
  ]);
26
26
  const ok = (r) => (r.status === 'fulfilled' ? r.value : null);
27
- return buildClaimLayers({
27
+ const inputs = {
28
28
  overview: ok(overview),
29
29
  billing: ok(billing),
30
30
  allocation: ok(allocation),
31
31
  value: ok(value),
32
- }, range);
32
+ };
33
+ return {
34
+ layers: buildClaimLayers(inputs, range),
35
+ demo: chainIsSampleData(inputs),
36
+ };
37
+ }
38
+ export async function loadChain(range) {
39
+ return (await loadChainState(range)).layers;
33
40
  }
@@ -25,19 +25,120 @@
25
25
  * server, no sockets, and no ledger. Every `null` input here means "that
26
26
  * endpoint did not answer", and each layer degrades on its own.
27
27
  */
28
+ import { periodWords } from './periodWords.js';
28
29
  import { projectRenderedAxes, unreachableSupport } from './claimTypes.js';
29
30
  /**
30
31
  * Freshness is either a real recorded instant or the words "not established".
31
32
  * It is never `new Date()` — a timestamp invented at render time would report
32
33
  * the age of the screen as the age of the evidence.
33
34
  */
34
- const iso = (ms) => typeof ms === 'number' ? new Date(ms).toISOString() : 'not established';
35
+ const iso = (ms) => typeof ms === 'number' && Number.isFinite(ms) && ms > 0 && ms <= 8.64e15
36
+ ? new Date(ms).toISOString() : 'not established';
37
+ /**
38
+ * Whether any payload behind the spine is seeded sample data. The spine shows
39
+ * four money figures before any view's own demo banner; on a phone that banner
40
+ * sat a full screen below them, so sample dollars filled the first screen with
41
+ * nothing saying they were samples. A payload that failed to load says nothing.
42
+ */
43
+ export function chainIsSampleData(input) {
44
+ return Object.values(input).some((p) => p?.demo === true);
45
+ }
46
+ const money = (n) => `$${n.toFixed(2)}`;
47
+ /** "gpt-6.1-sol, codex-auto-review and 1 more". */
48
+ function modelsOf(c) {
49
+ const names = c.models.map((m) => m.model);
50
+ return names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} and ${names.length - 2} more`;
51
+ }
52
+ /**
53
+ * The Metered band's basis line. It used to read "priced from a rate card" for
54
+ * every total, including amounts a tool reported and models priced at the
55
+ * generic fallback rate; the inspector then called matched rows "not
56
+ * estimated" (H003). All local cost is an estimate of a bill; what varies is
57
+ * how each part was priced, and that is what this says.
58
+ */
59
+ export function meteredBasisLine(basis) {
60
+ if (!basis || basis.cohorts.length === 0)
61
+ return 'no recorded requests in this period';
62
+ switch (basis.headline) {
63
+ case 'sample_cost': return 'sample data, priced from the price list';
64
+ case 'tool_reported_cost': return 'amounts the tools reported, not verified';
65
+ case 'list_cost': {
66
+ const line = basis.cohorts.some((c) => c.id === 'list_family')
67
+ ? "list prices (some models at the nearest model's price)"
68
+ : "list prices: each model's own published price";
69
+ // A model marked free is priced by its operator's declaration, not by a
70
+ // published price, so a mixed total names it (PR #146 review).
71
+ const free = basis.cohorts.find((c) => c.id === 'user_free');
72
+ return free ? `${line}; no charge on models you marked free (${modelsOf(free)})` : line;
73
+ }
74
+ default: {
75
+ const weak = basis.cohorts.filter((c) => (c.id === 'fallback' || c.id === 'tool_reported' || c.id === 'unrecorded') && c.costUsd >= 0.005);
76
+ // A declared price IS the model's own rate, the one its operator set, so
77
+ // it is named on its own rather than counted as "priced without" a rate
78
+ // (PR #146 review). It still makes the total an estimate: it is not a
79
+ // published list price.
80
+ const declared = basis.cohorts.find((c) => c.id === 'user_declared');
81
+ // With no weak or declared part, the "estimated" headline comes from the
82
+ // mix itself (sample rows beside priced ones, or unpriced requests). It
83
+ // used to print "0% priced without the model's own rate ()".
84
+ if (weak.length === 0 && !declared) {
85
+ if (basis.cohorts.some((c) => c.id === 'demo'))
86
+ return 'mostly sample data, priced from the price list';
87
+ // No rate card priced any of it: every charged request is on a model the
88
+ // user marked free (PR #146 review). It used to read "API list price".
89
+ if (basis.cohorts.some((c) => c.id === 'user_free') && !basis.cohorts.some((c) => c.id === 'list_exact' || c.id === 'list_family')) {
90
+ return basis.cohorts.some((c) => c.id === 'unpriced')
91
+ ? 'no charge on models you marked free; some requests have no price'
92
+ : 'no charge: every request is on a model you marked free';
93
+ }
94
+ if (basis.cohorts.some((c) => c.id === 'unpriced'))
95
+ return 'list prices; some requests have no price';
96
+ return "API list price from the rate card";
97
+ }
98
+ const pct = (usd) => (basis.totalUsd > 0 ? Math.round((usd / basis.totalUsd) * 100) : 0);
99
+ const parts = [];
100
+ if (weak.length > 0)
101
+ parts.push(`${pct(weak.reduce((s, c) => s + c.costUsd, 0))}% priced without the model's own rate (${weak.map((c) => c.label).join(', ')})`);
102
+ if (declared)
103
+ parts.push(`${pct(declared.costUsd)}% at prices you set`);
104
+ return `estimated: ${parts.join('; ')}`;
105
+ }
106
+ }
107
+ }
108
+ /** Share of the dollar total priced by a guess (nearest model or generic rate), 0..1. */
109
+ export function guessedShare(basis) {
110
+ if (!basis || basis.totalUsd <= 0)
111
+ return 0;
112
+ const guessed = basis.cohorts.filter((c) => c.id === 'fallback' || c.id === 'list_family').reduce((s, c) => s + c.costUsd, 0);
113
+ return guessed / basis.totalUsd;
114
+ }
115
+ /** Below this share a guess cannot change what the total says; above it, say where to fix it. */
116
+ export const GUESSED_SHARE_NEXT_STEP = 0.05;
117
+ /** Every part of the total, one clause each, for the inspector. */
118
+ export function meteredBasisParts(basis) {
119
+ if (!basis || basis.cohorts.length === 0)
120
+ return 'no recorded requests in this period';
121
+ return basis.cohorts.map((c) => {
122
+ if (c.id === 'unpriced')
123
+ return `${c.requests} request(s) not priced (${modelsOf(c)})`;
124
+ if (c.id === 'not_sent')
125
+ return `${c.requests} request(s) refused before reaching the provider (no charge)`;
126
+ if (c.id === 'user_free')
127
+ return `${c.requests} request(s) on models you marked free (${modelsOf(c)})`;
128
+ if (c.id === 'tool_reported' && c.costUsd < 0.005)
129
+ return `${c.requests} request(s) the tool reported at no charge (${modelsOf(c)})`;
130
+ const which = c.id === 'list_exact' || c.id === 'demo' || c.id === 'tool_reported' ? '' : ` (${modelsOf(c)})`;
131
+ return `${money(c.costUsd)} ${c.label}${which}`;
132
+ }).join(' · ');
133
+ }
35
134
  export function buildClaimLayers(input, range) {
135
+ // Each claim names its own window; only Metered follows the chosen period.
36
136
  const { overview: o, billing: b, allocation: a, value: v } = input;
37
137
  const estimatedShare = o?.pricing.estimatedSpendShare ?? null;
38
138
  const metered = {
39
139
  id: 'metered',
40
- label: 'Metered',
140
+ label: 'List cost (estimate)',
141
+ period: periodWords(range),
41
142
  claim: 'what we observed',
42
143
  valueUsd: o?.summary.costUsd ?? null,
43
144
  // Metered is the one layer whose figure IS the claim: if the ledger read,
@@ -47,26 +148,36 @@ export function buildClaimLayers(input, range) {
47
148
  support: o?.claimSupport ?? unreachableSupport('withheld_unsupported'),
48
149
  basis: o === null
49
150
  ? 'could not read the ledger'
50
- : 'counted from requests, priced from a rate card',
51
- nextStep: o === null ? 'Check that Segreant is running.' : undefined,
151
+ : meteredBasisLine(o.pricing.basis),
152
+ nextStep: o === null
153
+ ? 'Check that Segreant is running.'
154
+ : guessedShare(o.pricing.basis) >= GUESSED_SHARE_NEXT_STEP
155
+ ? `${Math.round(guessedShare(o.pricing.basis) * 100)}% is priced by a guess. Give those models a price under Data, then re-price.`
156
+ : undefined,
52
157
  inspection: {
53
158
  provenance: 'local request ledger + recorded pricing basis',
54
159
  scope: o ? `${range}; ${o.summary.requests} recorded request(s)` : range,
55
160
  freshness: o?.generatedAt ?? 'not established',
56
161
  coverage: o?.claimSupport?.note
57
- ?? (estimatedShare === null
58
- ? 'pricing coverage unavailable'
59
- : `${Math.round((1 - estimatedShare) * 100)}% of spend priced from a matched rate card, not estimated`),
162
+ ?? (o?.pricing.basis
163
+ ? meteredBasisParts(o.pricing.basis)
164
+ : estimatedShare === null
165
+ ? 'pricing coverage unavailable'
166
+ : `${Math.round((1 - estimatedShare) * 100)}% of spend priced at the model's own card rate; all of it is a list-price estimate`),
60
167
  // The distinction the whole product is built on, stated where someone is
61
168
  // most likely to reach for the metered figure as if it were the bill.
62
169
  enforceability: 'observation claim; local caps can govern future in-path requests, but metered cost does not become billed cost',
63
170
  evidenceSource: 'local request ledger',
64
- assumptions: ['Rate-card cost is an estimate unless provider billing evidence establishes a billed amount.'],
171
+ assumptions: ['Every local amount is an estimate of a bill: list price is what the use would cost at published API rates, with no plan, discount, credit or tax.'],
65
172
  missingEvidence: o === null
66
173
  ? ['a readable local ledger']
67
- : estimatedShare !== null && estimatedShare > 0
68
- ? ['exact rate-card matches for the estimated rows']
69
- : [],
174
+ : [
175
+ ...(o.pricing.basis?.cohorts ?? []).flatMap((c) => c.id === 'fallback' ? [`a model rate for ${modelsOf(c)} (if the card has learned it since, segreant reprice previews the new price)`]
176
+ : c.id === 'tool_reported' ? ['verification of the tool-reported amounts']
177
+ : c.id === 'unrecorded' ? ['the pricing basis of rows recorded before it was kept']
178
+ : []),
179
+ ...(o.pricing.basis ? [] : estimatedShare !== null && estimatedShare > 0 ? ['exact price-list matches for the estimated rows'] : []),
180
+ ],
70
181
  },
71
182
  };
72
183
  // Billed is established only by a recorded reconciliation run. Holding
@@ -81,7 +192,8 @@ export function buildClaimLayers(input, range) {
81
192
  const billedNote = b?.claimSupport?.note ?? null;
82
193
  const billed = {
83
194
  id: 'billed',
84
- label: 'Billed',
195
+ period: 'latest invoice comparison',
196
+ label: 'Billed cost',
85
197
  claim: 'what the provider charged',
86
198
  valueUsd: null,
87
199
  // Holding provider records and having reconciled them are different states,
@@ -92,25 +204,29 @@ export function buildClaimLayers(input, range) {
92
204
  // The band deliberately carries no dollar in any branch: this is an evidence
93
205
  // claim about whether a comparison happened, not a second cost figure.
94
206
  support: b?.claimSupport ?? unreachableSupport('not_a_money_claim'),
95
- basis: billedNote
96
- // A reconciliation whose provider snapshots contradicted each other is not
97
- // a reconciliation that established anything, and saying "reconciled" here
98
- // while the axis says `conflicted` is the disagreement this line exists to
99
- // avoid.
100
- ? `reconciled against a provider report, but ${billedNote}`
101
- : runs > 0
102
- ? 'reconciled against a provider report, with a residual'
103
- : b && b.summary.recordCount > 0
104
- ? `${b.summary.recordCount} provider records held, none reconciled yet`
105
- : 'no provider bill has been compared against this ledger',
207
+ basis: b === null
208
+ ? 'could not read the billing report'
209
+ : billedNote
210
+ // A reconciliation whose provider snapshots contradicted each other is not
211
+ // a reconciliation that established anything, and saying "reconciled" here
212
+ // while the axis says `conflicted` is the disagreement this line exists to
213
+ // avoid.
214
+ ? `compared with your invoice, but ${billedNote}`
215
+ : runs > 0
216
+ ? 'compared with your invoice; see Close for the difference'
217
+ : b && b.summary.recordCount > 0
218
+ ? `${b.summary.recordCount} billing records from your provider, not compared yet`
219
+ : 'no invoice imported',
106
220
  // A contradiction with no next action leaves an operator looking at a
107
221
  // problem they are not told how to work on. Resolving a disagreement is a
108
222
  // different task from collecting more evidence, so it gets its own sentence.
109
- nextStep: b?.claimSupport && projectRenderedAxes(b.claimSupport.profile).epistemic === 'conflicted'
110
- ? 'Re-observe the disagreeing days before relying on this reconciliation.'
111
- : runs > 0
112
- ? undefined
113
- : 'Check readiness in Evidence before spending a credential on it.',
223
+ nextStep: b === null
224
+ ? 'Check that Segreant is running, then retry the billing report.'
225
+ : b?.claimSupport && projectRenderedAxes(b.claimSupport.profile).epistemic === 'conflicted'
226
+ ? 'Re-observe the disagreeing days before relying on this reconciliation.'
227
+ : runs > 0
228
+ ? undefined
229
+ : 'Import one under Close to see billed cost beside list cost.',
114
230
  inspection: {
115
231
  provenance: latestRun?.result.providerSourceKind
116
232
  ?? (b && b.summary.recordCount > 0 ? 'operator-supplied provider evidence, unreconciled' : 'none'),
@@ -134,7 +250,8 @@ export function buildClaimLayers(input, range) {
134
250
  const centres = Array.isArray(a?.costCentres) ? a.costCentres.length : 0;
135
251
  const allocated = {
136
252
  id: 'allocated',
137
- label: 'Allocated',
253
+ period: 'latest cost split',
254
+ label: 'Allocated cost',
138
255
  claim: 'whose cost it is',
139
256
  valueUsd: null,
140
257
  // Cost centres defined with no run recorded is partial coverage of a claim
@@ -143,12 +260,16 @@ export function buildClaimLayers(input, range) {
143
260
  // Showback: the claim is whose cost it is, not how much, so the band never
144
261
  // carries a dollar.
145
262
  support: a?.claimSupport ?? unreachableSupport('not_a_money_claim'),
146
- basis: allocRuns > 0
147
- ? 'apportioned by recorded rules — showback only'
148
- : centres > 0
149
- ? `${centres} cost centre${centres === 1 ? '' : 's'} defined, no allocation recorded`
150
- : 'no cost centres and no rules yet',
151
- nextStep: allocRuns > 0 ? undefined : 'Define a cost centre, then run an allocation.',
263
+ basis: a === null
264
+ ? 'could not read the allocation report'
265
+ : allocRuns > 0
266
+ ? 'split by your rules, for reporting (not a bill)'
267
+ : centres > 0
268
+ ? `${centres} cost centre${centres === 1 ? '' : 's'} set up, nothing split yet`
269
+ : 'no cost split set up yet',
270
+ nextStep: a === null
271
+ ? 'Check that Segreant is running, then retry the allocation report.'
272
+ : allocRuns > 0 ? undefined : 'Set up a cost centre, then split the spend under Allocate.',
152
273
  inspection: {
153
274
  provenance: a?.basis ?? 'no allocation basis recorded',
154
275
  scope: a
@@ -203,7 +324,8 @@ export function buildClaimLayers(input, range) {
203
324
  const valued = ret?.basis === 'usd' && typeof ret.manualEquivalentValueUsd === 'number';
204
325
  const realized = {
205
326
  id: 'realized',
206
- label: 'Realized',
327
+ period: 'outcomes on record',
328
+ label: 'Value (kept work)',
207
329
  claim: 'what it produced',
208
330
  valueUsd: valued ? (ret?.manualEquivalentValueUsd ?? null) : null,
209
331
  // THE CASE THE OLD BOOLEAN COLLAPSED (AII-014). `realizedUnits > 0 && valued`
@@ -219,16 +341,20 @@ export function buildClaimLayers(input, range) {
219
341
  // produced nothing" — an inference from a missing input, in the band whose
220
342
  // entire job is to keep realized value distinct from the three cost claims.
221
343
  support: v?.claimSupport ?? unreachableSupport('withheld_unsupported'),
222
- basis: realizedUnits === 0
223
- ? 'no work units have matured into verified outcomes'
224
- : valued
225
- ? `${realizedUnits} of ${matured?.units ?? 0} matured units shipped and survived; manual-equivalent value, net of rework`
226
- : `${realizedUnits} of ${matured?.units ?? 0} units matured, but no labour rate is set to price what they produced`,
227
- nextStep: realizedUnits === 0
228
- ? 'Connect a repository so outcomes can be observed.'
229
- : valued
230
- ? undefined
231
- : 'Set a labour rate so realized work can be priced.',
344
+ basis: v === null
345
+ ? 'could not read the outcome report'
346
+ : realizedUnits === 0
347
+ ? 'no finished work old enough to judge yet'
348
+ : valued
349
+ ? `${realizedUnits} of ${matured?.units ?? 0} finished pieces of work kept; valued at what the work would cost by hand, less rework`
350
+ : `${realizedUnits} of ${matured?.units ?? 0} finished pieces of work kept, but no hourly rate is set to value them`,
351
+ nextStep: v === null
352
+ ? 'Check that Segreant is running, then retry the outcome report.'
353
+ : realizedUnits === 0
354
+ ? 'Connect a repository so Segreant can see which work you kept.'
355
+ : valued
356
+ ? undefined
357
+ : 'Set an hourly rate to put a dollar value on kept work.',
232
358
  inspection: {
233
359
  provenance: v?.valueSource ?? 'no outcome source established',
234
360
  scope: v?.projectScoped === true
@@ -253,11 +379,13 @@ export function buildClaimLayers(input, range) {
253
379
  ? 'repository history + recorded outcome signals'
254
380
  : (v?.valueSource ?? 'none'),
255
381
  assumptions: v?.roi?.notes ? [...v.roi.notes] : [],
256
- missingEvidence: realizedUnits === 0
257
- ? ['matured outcome evidence']
258
- : valued
259
- ? []
260
- : ['a labour rate, so realized work can be priced'],
382
+ missingEvidence: v === null
383
+ ? ['a readable outcome report']
384
+ : realizedUnits === 0
385
+ ? ['matured outcome evidence']
386
+ : valued
387
+ ? []
388
+ : ['a labour rate, so realized work can be priced'],
261
389
  },
262
390
  };
263
391
  return [metered, billed, allocated, realized];
@@ -182,6 +182,36 @@ export function claimIsUnevidenced(layer) {
182
182
  export function claimShowsFigure(layer) {
183
183
  return layer.support.figure === 'shown';
184
184
  }
185
+ /**
186
+ * What is complete when a claim names no missing evidence. Scoped to THIS
187
+ * claim's own standard. An empty `missingEvidence` list means the claim is
188
+ * complete as stated, not that the claim family is complete or that the figure
189
+ * is a measured outcome: "Nothing is missing" read as blanket completeness on
190
+ * a Realized claim that also says "impact uninstrumented" (H018).
191
+ */
192
+ export function claimCompletionLine(layer, precise) {
193
+ if (layer.id === 'realized') {
194
+ return precise
195
+ ? 'No further evidence is required for this claim as stated: an estimated manual-equivalent value of work that shipped and survived.'
196
+ : 'Nothing more is needed for this claim as stated: an estimated manual-equivalent value of work that shipped and survived.';
197
+ }
198
+ return precise
199
+ ? 'No further evidence is required for this claim at its stated basis.'
200
+ : 'Nothing more is needed for this claim at its stated basis.';
201
+ }
202
+ /**
203
+ * The Realized claim is shown with its basis, and that basis is a value
204
+ * estimate. A causal or business-value claim needs evidence this layer does
205
+ * not carry, so the inspector names that gap whether or not `missingEvidence`
206
+ * is empty. The payload has no structured impact-instrumentation flag (impact
207
+ * appears only as free-text `roi.notes`), so the line is shown for every
208
+ * Realized claim rather than conditioned on a flag that does not exist.
209
+ */
210
+ export function claimStrongerClaimLine(layer) {
211
+ if (layer.id !== 'realized')
212
+ return null;
213
+ return 'A causal or business-value claim would also need independent outcome evidence (impact instrumentation), which this claim does not establish.';
214
+ }
185
215
  /**
186
216
  * Supported, but no figure because something needed to price it is missing.
187
217
  * The case the old boolean reported as "not established", which reads to an
@@ -86,6 +86,23 @@ export function pct(value, digits = 1) {
86
86
  return '—';
87
87
  return `${(value * 100).toFixed(digits)}%`;
88
88
  }
89
+ /**
90
+ * A part of a whole, as a percentage. A part that is not zero never prints as
91
+ * 0%, and a part short of the whole never prints as 100%: either rounding would
92
+ * state something the figures do not. At the default precision those show as
93
+ * "<1%" and ">99%".
94
+ */
95
+ export function share(fraction, digits = 0) {
96
+ if (fraction === null || fraction === undefined || Number.isNaN(fraction))
97
+ return '—';
98
+ const step = 10 ** -digits;
99
+ const percent = fraction * 100;
100
+ if (fraction > 0 && percent < step / 2)
101
+ return `<${step.toFixed(digits)}%`;
102
+ if (fraction < 1 && percent >= 100 - step / 2)
103
+ return `>${(100 - step).toFixed(digits)}%`;
104
+ return pct(fraction, digits);
105
+ }
89
106
  export function dateTime(ms) {
90
107
  if (!ms)
91
108
  return '—';
@@ -93,10 +110,10 @@ export function dateTime(ms) {
93
110
  year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
94
111
  });
95
112
  }
96
- export function date(ms) {
113
+ export function date(ms, timeZone) {
97
114
  if (!ms)
98
115
  return '—';
99
- return new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
116
+ return new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', ...(timeZone ? { timeZone } : {}) });
100
117
  }
101
118
  export function relative(ms) {
102
119
  if (!ms)
@@ -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) {