clearotron 0.2.4 → 0.3.0-beta.1

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 (235) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +62 -38
  4. package/bin/brandowner.mjs +18 -169
  5. package/bin/clearotron.mjs +3 -1
  6. package/bin/connect.mjs +28 -19
  7. package/bin/disconnect.mjs +3 -3
  8. package/bin/example.mjs +7 -7
  9. package/bin/framework-preflight.mjs +49 -0
  10. package/bin/grant.mjs +151 -93
  11. package/bin/onboard.mjs +220 -138
  12. package/bin/start.mjs +146 -137
  13. package/bin/stop.mjs +2 -2
  14. package/bin/update.mjs +1 -1
  15. package/build-info.json +2 -2
  16. package/docs/CLIENT-MCP.md +2 -2
  17. package/docs/E2E.md +12 -2
  18. package/docs/ONBOARDING.md +1 -1
  19. package/docs/PORTAL.md +14 -13
  20. package/docs/SECURITY.md +23 -24
  21. package/docs/architecture/04-configuration-reference.md +12 -5
  22. package/docs/architecture/05-config-governance.md +7 -7
  23. package/docs/architecture/07-quality-and-audit.md +1 -1
  24. package/docs/architecture/08-development-guide.md +5 -0
  25. package/docs/configuration.md +118 -0
  26. package/docs/decisions/0004-documentation-structure.md +2 -2
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
  28. package/driver/CHANGELOG.md +70 -0
  29. package/driver/ask-ledger.mjs +2 -2
  30. package/driver/cancel.mjs +27 -0
  31. package/driver/case-law-sources.mjs +3 -3
  32. package/driver/company-bundle.mjs +261 -0
  33. package/driver/compare.mjs +1 -1
  34. package/driver/compose-read.mjs +2 -2
  35. package/driver/config-inventory.mjs +2 -2
  36. package/driver/contract-audit.mjs +1 -1
  37. package/driver/contract-e3-backlog.mjs +1 -1
  38. package/driver/contract-vocabulary.mjs +1 -1
  39. package/driver/declination-call.mjs +1 -1
  40. package/driver/deliver-trigger.sh +3 -3
  41. package/driver/dev-portal.mjs +3 -1
  42. package/driver/digest-queue.mjs +1 -1
  43. package/driver/disposition-tool.mjs +1 -1
  44. package/driver/doc-constants.mjs +1 -1
  45. package/driver/drain-posture.mjs +2 -2
  46. package/driver/drainer-identity.mjs +1 -1
  47. package/driver/driver.config.mjs +44 -9
  48. package/driver/effective-scope.mjs +30 -1
  49. package/driver/effort-model.mjs +6 -6
  50. package/driver/engine/CONTRACT.md +2 -2
  51. package/driver/engine/anthropic-agent.mjs +11 -11
  52. package/driver/engine/jx-turn.mjs +1 -1
  53. package/driver/engine/mcp/gather-config.mjs +29 -4
  54. package/driver/engine/mcp/recording-server.mjs +73 -1
  55. package/driver/engine/openai-agent.mjs +1 -1
  56. package/driver/engine/probe.mjs +28 -4
  57. package/driver/enqueue-schema.mjs +23 -3
  58. package/driver/findings-model.mjs +2 -2
  59. package/driver/flag-snapshot.mjs +2 -2
  60. package/driver/floor-duty.mjs +2 -2
  61. package/driver/frame-diff-model.mjs +1 -1
  62. package/driver/framework-preflight.mjs +143 -0
  63. package/driver/gateway.mjs +9 -1
  64. package/driver/hit-list.mjs +1 -1
  65. package/driver/jx-lanes.mjs +1 -1
  66. package/driver/jx.mjs +1 -1
  67. package/driver/knockout-assess-record.mjs +1 -1
  68. package/driver/knockout-review-record.mjs +435 -0
  69. package/driver/order-probe.mjs +1 -1
  70. package/driver/outbox-backoff.mjs +2 -2
  71. package/driver/owner-use-check.mjs +2 -2
  72. package/driver/package.json +1 -1
  73. package/driver/pipeline-knockout.mjs +105 -8
  74. package/driver/pipeline.mjs +81 -30
  75. package/driver/plain-register.mjs +77 -3
  76. package/driver/portal-access.mjs +141 -74
  77. package/driver/portal-config-view.mjs +59 -70
  78. package/driver/portal-report.mjs +4 -4
  79. package/driver/portal-service.mjs +348 -141
  80. package/driver/portal-upstream.mjs +105 -17
  81. package/driver/predelivery-lint.mjs +43 -18
  82. package/driver/product-rows.mjs +1 -1
  83. package/driver/products.mjs +1 -1
  84. package/driver/profile-page.html +30 -5
  85. package/driver/profile-service.mjs +197 -26
  86. package/driver/profiles.mjs +48 -1
  87. package/driver/publish/index.mjs +31 -13
  88. package/driver/publish/knockout.mjs +9 -5
  89. package/driver/publish/office-record-links.mjs +189 -0
  90. package/driver/publish/parse.mjs +3 -3
  91. package/driver/publish/publish-inputs.mjs +26 -0
  92. package/driver/publish/render-knockout.mjs +42 -42
  93. package/driver/publish/render.mjs +29 -4
  94. package/driver/publish/report-data.mjs +2 -2
  95. package/driver/publish/seed-pool.mjs +1 -1
  96. package/driver/publish/templates/report.css +8 -8
  97. package/driver/publish/xlsx.mjs +49 -7
  98. package/driver/queue-watch-verdict.mjs +2 -2
  99. package/driver/recipe-service.mjs +1 -1
  100. package/driver/record-carry.mjs +1 -1
  101. package/driver/reference-score.mjs +1 -1
  102. package/driver/reference-strip-signatures.mjs +1 -1
  103. package/driver/register-availability.mjs +4 -3
  104. package/driver/register-count.mjs +3 -3
  105. package/driver/register-records.mjs +1 -1
  106. package/driver/repair-composers.mjs +1 -1
  107. package/driver/repairs.mjs +3 -3
  108. package/driver/replay-archive.mjs +1 -1
  109. package/driver/report-card-record.mjs +1 -1
  110. package/driver/result-noun-fields.mjs +5 -0
  111. package/driver/roster-verdict.mjs +48 -5
  112. package/driver/run-activity.mjs +1 -1
  113. package/driver/run-requirements.mjs +18 -5
  114. package/driver/runner.mjs +24 -15
  115. package/driver/search-policy.mjs +8 -8
  116. package/driver/senior-rights.mjs +1 -1
  117. package/driver/skills/prelim-search/delivery-contract.md +1 -1
  118. package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
  119. package/driver/stages-knockout.mjs +72 -6
  120. package/driver/stages.mjs +10 -10
  121. package/driver/suite-census.json +238 -70
  122. package/driver/synthesis-record.mjs +2 -2
  123. package/driver/systemd/clearotron-client-mcp.service +3 -3
  124. package/driver/systemd/clearotron-deploy.service +2 -2
  125. package/driver/systemd/clearotron-mcp-face.service +1 -1
  126. package/driver/systemd/clearotron-portal.service +3 -3
  127. package/driver/systemd/clearotron-worker.service +5 -5
  128. package/driver/systemd/install-census.mjs +1 -1
  129. package/driver/systemd/render-units.mjs +9 -9
  130. package/driver/terminal-clamp.mjs +1 -1
  131. package/driver/trigger-cap.mjs +18 -2
  132. package/driver/unit-inventory.mjs +8 -8
  133. package/driver/usage-ledger.mjs +5 -3
  134. package/driver/verify-knockout.mjs +7 -7
  135. package/driver/verify.mjs +5 -5
  136. package/driver/whatif-memo-run.mjs +1 -1
  137. package/driver/whatif-queue.mjs +3 -3
  138. package/driver/whatif-worker.mjs +2 -2
  139. package/examples/README.md +1 -1
  140. package/examples/grants.example.json +25 -24
  141. package/mcp-server/CHANGELOG.md +10 -0
  142. package/mcp-server/http-server.mjs +3 -3
  143. package/mcp-server/key-socket.mjs +1 -1
  144. package/mcp-server/lib/audit-view.mjs +3 -3
  145. package/mcp-server/lib/brief.mjs +3 -3
  146. package/mcp-server/lib/driver.mjs +1 -1
  147. package/mcp-server/lib/events.mjs +1 -1
  148. package/mcp-server/lib/http-handler.mjs +2 -2
  149. package/mcp-server/lib/instructions.mjs +2 -2
  150. package/mcp-server/lib/knockout.mjs +1 -1
  151. package/mcp-server/lib/ops.mjs +6 -3
  152. package/mcp-server/lib/options.mjs +15 -4
  153. package/mcp-server/lib/plan.mjs +7 -6
  154. package/mcp-server/lib/runs.mjs +10 -0
  155. package/mcp-server/lib/whatif.mjs +5 -5
  156. package/mcp-server/package.json +1 -1
  157. package/mcp-server/packs/README.md +1 -1
  158. package/mcp-server/remote/client-mcp-apikey.service +1 -1
  159. package/mcp-server/remote/client-mcp.service +2 -2
  160. package/mcp-server/remote/trademark-artifacts-http.service +1 -1
  161. package/mcp-server/server.mjs +38 -24
  162. package/package.json +2 -2
  163. package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
  164. package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
  165. package/portal-ui/dist/index.html +2 -2
  166. package/portal-ui/package.json +1 -1
  167. package/providers/_shared/lane-probe.mjs +9 -3
  168. package/providers/jx-subclass/lookup.mjs +1 -1
  169. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  170. package/providers/oauth-mcp-bridge/package.json +1 -1
  171. package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
  172. package/scripts/citation-drift-report.mjs +1 -1
  173. package/scripts/citation-line-check.mjs +2 -2
  174. package/scripts/drive-env-check.mjs +1 -1
  175. package/scripts/e2e.mjs +5 -5
  176. package/scripts/env-audit.mjs +13 -1
  177. package/scripts/headless-page.mjs +5 -5
  178. package/scripts/live-surface-check.mjs +26 -2
  179. package/scripts/mint-names-in-force.mjs +19 -5
  180. package/scripts/mint-reference-strip-backlog.mjs +1 -1
  181. package/scripts/mint-suite-census.mjs +37 -10
  182. package/scripts/pack-publishable.mjs +1 -1
  183. package/scripts/preinstall-node-check.mjs +1 -1
  184. package/scripts/release-await-cut.mjs +3 -3
  185. package/scripts/release-cut-decision.mjs +1 -1
  186. package/scripts/release-dist-tag.mjs +1 -1
  187. package/scripts/release-install-check.mjs +1 -1
  188. package/scripts/release-notes-lint.mjs +1 -1
  189. package/scripts/release-publish-guard.mjs +1 -1
  190. package/scripts/release-version-pr-checks.mjs +2 -2
  191. package/scripts/release-version.mjs +61 -5
  192. package/scripts/render-brand-banner.mjs +1 -1
  193. package/scripts/render-check.mjs +2 -2
  194. package/scripts/repo-writes.mjs +1 -1
  195. package/scripts/report-frame-check.mjs +1 -1
  196. package/scripts/report-screenshot.mjs +2 -2
  197. package/scripts/retire-bare-refs.mjs +1 -1
  198. package/scripts/revisit-render-check.mjs +1 -1
  199. package/scripts/score.mjs +1 -1
  200. package/scripts/strip-titles-and-attributions.mjs +389 -0
  201. package/scripts/strip-tracker-citations.mjs +122 -5
  202. package/scripts/test-run.mjs +4 -4
  203. package/scripts/third-party-notices.mjs +1 -1
  204. package/scripts/verify-publishable.mjs +1 -1
  205. package/shared/access-audience.mjs +2 -2
  206. package/shared/anon-overlay.mjs +1 -1
  207. package/shared/brand.mjs +15 -1
  208. package/shared/bundle-freshness.mjs +1 -1
  209. package/shared/bundle-rebuild.mjs +1 -1
  210. package/shared/checkout-move.mjs +2 -2
  211. package/shared/client-door.mjs +8 -8
  212. package/shared/connect-clients.mjs +7 -7
  213. package/shared/connector-signin-probe.mjs +1 -1
  214. package/shared/env-aliases.mjs +1 -1
  215. package/shared/env-local.mjs +5 -5
  216. package/shared/grants-edit.mjs +76 -0
  217. package/shared/install-auth.mjs +1 -1
  218. package/shared/listen.mjs +3 -3
  219. package/shared/mcp-challenge.mjs +1 -1
  220. package/shared/names-in-force.mjs +2 -1
  221. package/shared/onboarding-store.mjs +19 -2
  222. package/shared/reference-guard-classes.mjs +44 -2
  223. package/shared/register-selection.mjs +1 -1
  224. package/shared/scope.mjs +223 -56
  225. package/shared/secret-file.mjs +1 -1
  226. package/shared/server-units.mjs +1 -1
  227. package/shared/staff-domain.mjs +45 -78
  228. package/shared/summary-blocks.mjs +2 -2
  229. package/shared/systemd-failure.mjs +3 -3
  230. package/shared/tracked-files.mjs +1 -1
  231. package/shared/trigger-lane.mjs +1 -1
  232. package/shared/tty-style.mjs +1 -1
  233. package/shared/usage-block.mjs +1 -1
  234. package/shared/vacuous-pass.mjs +1 -1
  235. package/shared/verb-shim.mjs +1 -1
@@ -32,6 +32,8 @@ import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, de
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { dirname, join } from "node:path";
34
34
  import { storeInRepo, storeOutsideRepoMessage, makeCommittableAudit, commitWithAuditRow, makeStoreCommit } from "../shared/store-in-repo.mjs"; //
35
+ import { resolveFramework, resolvePlatforms, buildProfile, assertRosterAccepts, companyKeyFrom } from "./company-bundle.mjs";
36
+ import { defaultTerritoryState } from "./effective-scope.mjs"; // one reading of which stored territories the engine can search
35
37
  import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mjs"; // — one store for the surface and the runs
36
38
  import {
37
39
  loadProfiles as loadProfilesDefault, validateProfileEdit as validateProfileEditDefault,
@@ -39,7 +41,7 @@ import {
39
41
  assertProfileKey, derivedFloor, derivedBatchSize, CONTEXT_PACK_FILE, DEFAULT_DELIVERY_TEMPLATE,
40
42
  } from "./profiles.mjs";
41
43
 
42
- import { DEFAULT_FRAMEWORK, DEFAULT_WORKED_EXAMPLES, loadFrameworkManifest } from "./framework.mjs";
44
+ import { DEFAULT_FRAMEWORK, DEFAULT_WORKED_EXAMPLES, loadFrameworkManifest, manifestPathFor } from "./framework.mjs";
43
45
  import { config } from "./driver.config.mjs";
44
46
  import { productRows } from "./product-rows.mjs"; // the offering, so the editor never hand-types a menu
45
47
  import { dirname as pathDirname } from "node:path";
@@ -57,7 +59,34 @@ import { accessAudience, audienceLabel } from "../shared/access-audience.mjs";
57
59
  // at the wrong tree looked, from every log, exactly like a customer who simply has no custom framework.
58
60
  // The page said "could not be read" and nothing anywhere said why. Log it once, at the point of loss.
59
61
  const DRIVER_DIR = pathDirname(toPath(import.meta.url));
62
+
63
+ /**
64
+ * SAY IT ONCE WHEN A FRAMEWORK COMES OUT OF THE SHIPPED TREE INSTEAD OF THE STORE.
65
+ *
66
+ * Skill resolution falls back from the config store to the repo, and for a generic methodology file that
67
+ * fallback is the migration working as designed. A FRAMEWORK is the exception, because the shipped tree
68
+ * carries decks under the same filenames customers use for their own — take a customer's deck out of the
69
+ * store and the repo's copy answers. It is readable, it is valid, and it is somebody else's rubric, and
70
+ * the matter is rated under it with nothing raised anywhere.
71
+ *
72
+ * WARNED ONCE PER PATH, DELIBERATELY. These run on every profile view. A line per view on a healthy
73
+ * install is a log flood, and a log flood is silenced, which would reproduce the silence this exists to
74
+ * break. The set is per-process and never cleared: the fact does not change while the process runs.
75
+ */
76
+ const substitutionsSaid = new Set();
77
+ function sayIfSubstituted(rel) {
78
+ try {
79
+ const r = config.resolveSkillPathReport(rel);
80
+ if (r.layer !== "base" || substitutionsSaid.has(r.path)) return;
81
+ substitutionsSaid.add(r.path);
82
+ console.error(`[profile-service] framework served from the shipped tree, not the configured store: ${rel} `
83
+ + `is not in the store, so ${r.path} answered. If a customer's own deck was removed from the store, `
84
+ + `the shipped file of the same name is now rating their matters.`);
85
+ } catch { /* an unreadable overlay is reported by the resolution the caller is about to make */ }
86
+ }
87
+
60
88
  function manifestFor(fwPath) {
89
+ sayIfSubstituted(manifestPathFor(fwPath));
61
90
  try { return loadFrameworkManifest((rel) => config.resolveSkillPath(rel), fwPath); }
62
91
  catch (e) {
63
92
  console.error(`[profile-service] framework manifest unreadable for ${fwPath}: ${String(e?.message ?? e)} `
@@ -78,23 +107,18 @@ const stripMd = (s) => String(s ?? "").replace(/\*+/g, "").trim();
78
107
  // matrix-shaped decks: the deck's "Band meanings" table is the only table whose FIRST cell is
79
108
  // exactly the band label (the matrix table suffixes its labels with the deck's internal indices, e.g.
80
109
  // "**Very High** *(5)*") — take that row's cells as { band, meaning, response }.
81
- function matrixBandMeanings(deck, manifest) {
110
+ function matrixBandRows(deck, manifest) {
82
111
  const tableRows = deck.split("\n").filter((l) => /^\s*\|.*\|\s*$/.test(l));
83
- const out = [];
84
- for (const b of manifest.bands) {
112
+ return manifest.bands.map((b) => {
85
113
  const want = b.label.trim().toLowerCase();
86
- let hit = null;
87
114
  for (const line of tableRows) {
88
115
  const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map(stripMd);
89
- if (cells.length >= 3 && cells[0].toLowerCase() === want && cells[1] && cells[2]) {
90
- hit = { band: b.label, meaning: cells[1], response: cells[2] };
91
- break;
92
- }
116
+ if (cells.length >= 3 && cells[0].toLowerCase() === want && cells[1] && cells[2])
117
+ return { band: b.label, meaning: cells[1], response: cells[2] };
93
118
  }
94
- if (!hit) return null; // ANY miss ⇒ no partial box
95
- out.push(hit);
96
- }
97
- return out;
119
+ return { band: b.label, miss: "no table row starts with this band label and then states a meaning and a response — "
120
+ + "a matrix-shaped deck defines its bands in a three-column table" };
121
+ });
98
122
  }
99
123
 
100
124
  // bands-shaped decks (the house default among them): each band lives under its own heading ("## VERY HIGH RISK") — find
@@ -120,7 +144,7 @@ function matrixBandMeanings(deck, manifest) {
120
144
  // design is the protection it was actually for: a band whose section is missing, or whose section
121
145
  // states no rungs at all, is a GARBLED deck and still returns null, because a half-shown framework
122
146
  // misleads worse than an absent one. A merely renamed rung is not that case.
123
- function bandsBandMeanings(deck, manifest) {
147
+ function bandsBandRows(deck, manifest) {
124
148
  const re = /^#{1,6}[ \t]*([^\n]+)$/gm;
125
149
  const heads = [];
126
150
  for (let m; (m = re.exec(deck)); ) heads.push({ text: stripMd(m[1]), start: m.index, end: m.index + m[0].length });
@@ -129,7 +153,7 @@ function bandsBandMeanings(deck, manifest) {
129
153
  for (const b of manifest.bands) {
130
154
  const want = b.label.trim().toLowerCase();
131
155
  const sec = sections.find((s) => s.head === want || (s.head.startsWith(want) && !/[a-z0-9]/i.test(s.head.charAt(want.length))));
132
- if (!sec) return null;
156
+ if (!sec) { out.push({ band: b.label, miss: "no heading whose text is this band label, or begins with it" }); continue; }
133
157
  // Every top-level "- **Label.** text" bullet the band states, in the deck's order. The label is the
134
158
  // deck's own word for the rung; nothing here decides which rungs exist or what they may be called.
135
159
  const rungs = [];
@@ -138,7 +162,11 @@ function bandsBandMeanings(deck, manifest) {
138
162
  const text = stripMd(m[2]).trim();
139
163
  if (label && text) rungs.push({ label, text });
140
164
  }
141
- if (!rungs.length) return null; // a section stating no rungs is a garbled deck — see above
165
+ if (!rungs.length) {
166
+ out.push({ band: b.label, miss: "the section under this band's heading states no rungs — a bands-shaped deck "
167
+ + "writes each rung as a top-level `- **Label.** text` bullet" });
168
+ continue; // a section stating no rungs is a garbled deck — see above
169
+ }
142
170
  // `meaning` IS KEPT, and it is not vestigial: `driver/profile-page.html` renders these rows too and
143
171
  // reads exactly this field, so dropping it would blank a second surface that nobody asked me to
144
172
  // change. It carries the LAST rung — the consequences one in both the shipped deck and the completed
@@ -148,19 +176,79 @@ function bandsBandMeanings(deck, manifest) {
148
176
  return out;
149
177
  }
150
178
 
151
- /** Pure extraction (exported for the tests): deck text + validated manifest → [{ band, meaning, response? }]
152
- * in manifest band order, or null on ANY miss. Never throws. */
153
- export function extractBandMeanings(deckText, manifest) {
179
+ /**
180
+ * A refusal, worded for the person in the browser.
181
+ *
182
+ * THE SAME FACTS, A DIFFERENT READER. The shared create path's own sentences are written for somebody who
183
+ * typed a command: they name the flag that was missing and the directory that was written to, which is
184
+ * exactly what that reader needs. The person on the New company page typed neither. A sentence about
185
+ * `--platforms` and a filesystem path does not tell them what to do; it tells them they are in the wrong
186
+ * product.
187
+ *
188
+ * So the code is worded here and the facts ride alongside, which is what lets the screen offer a link to
189
+ * the company that already holds a key rather than printing its slug. An unrecognised code falls back to
190
+ * the original message: wrong for this reader, but never blank — an unworded refusal must not become a
191
+ * silent one.
192
+ */
193
+ export function browserRefusal(e) {
194
+ const d = e?.detail ?? {};
195
+ switch (e?.code) {
196
+ case "key_exists":
197
+ return { error: "refused", code: "key_exists", key: d.key,
198
+ message: `A company is already filed under "${d.key}". Open it to make changes, or choose a different name.` };
199
+ case "domain_claimed":
200
+ return { error: "refused", code: "domain_claimed", domain: d.domain, heldBy: d.heldBy,
201
+ message: `${d.domain} is already used by another company. An address can only belong to one, `
202
+ + `because the engine uses it to tell them apart. Remove it here, or take it off the other company first.` };
203
+ case "no_marketplaces":
204
+ return { error: "refused", code: "no_marketplaces",
205
+ message: "This installation has no default marketplaces set up, so there is nothing to search. "
206
+ + "Add at least one marketplace for this company, or ask an administrator to set the defaults." };
207
+ case "framework_missing":
208
+ return { error: "refused", code: "framework_missing",
209
+ message: "The risk framework this company was pointed at is not on this installation, so nothing "
210
+ + "was created. Rating a company under a framework nobody chose is worse than refusing." };
211
+ case "invalid_bundle":
212
+ // The validator collects, so these are already per-field sentences written for a reader.
213
+ return { error: "refused", code: "invalid_bundle", errors: d.errors ?? [],
214
+ message: (d.errors ?? []).join(" ") || String(e.message) };
215
+ default:
216
+ return { error: "refused", message: String(e.message) };
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Every band's row, hit or miss — deck text + validated manifest → one entry per manifest band, in the
222
+ * manifest's order, each either a rendered row or `{ band, miss }` saying what the deck did not do.
223
+ *
224
+ * ONE PREMISE, TWO READERS. `extractBandMeanings` below is the renderer's verdict and collapses to null
225
+ * on any miss, because a half-shown framework misleads worse than an absent one. The pre-flight needs the
226
+ * opposite of a verdict: an author staring at an empty box has to be told WHICH band and WHY. Writing
227
+ * that as a second walk of the same deck would put two matchers on one property, and the day they drift
228
+ * the pre-flight blesses a deck the renderer drops. So the walk happens once, here, and the null is a
229
+ * collapse of this result rather than a separate decision.
230
+ *
231
+ * Never throws: a deck is a customer's file and may be anything at all.
232
+ */
233
+ export function bandMeaningRows(deckText, manifest) {
154
234
  try {
155
235
  if (!deckText || !manifest || !Array.isArray(manifest.bands) || !manifest.bands.length) return null;
156
236
  return manifest.structure?.kind === "matrix"
157
- ? matrixBandMeanings(String(deckText), manifest)
158
- : bandsBandMeanings(String(deckText), manifest);
237
+ ? matrixBandRows(String(deckText), manifest)
238
+ : bandsBandRows(String(deckText), manifest);
159
239
  } catch { return null; }
160
240
  }
161
241
 
242
+ /** Pure extraction (exported for the tests): deck text + validated manifest → [{ band, meaning, response? }]
243
+ * in manifest band order, or null on ANY miss. Never throws. */
244
+ export function extractBandMeanings(deckText, manifest) {
245
+ const rows = bandMeaningRows(deckText, manifest);
246
+ return rows && !rows.some((r) => r.miss) ? rows : null;
247
+ }
248
+
162
249
  function bandMeaningsFor(fwPath, manifest) {
163
250
  if (!manifest) return null;
251
+ sayIfSubstituted(fwPath);
164
252
  try { return extractBandMeanings(readFileSync(config.resolveSkillPath(fwPath), "utf8"), manifest); }
165
253
  catch (e) {
166
254
  // Same reasoning as manifestFor: a silent null here blanks the "What the bands mean" box while the
@@ -194,7 +282,7 @@ function normalizeDefaultProduct(incoming, existing) {
194
282
  }
195
283
  return incoming;
196
284
  }
197
- // marketplaceDensity has NO CONTROL ON ANY SURFACE since the owner ruling of 2026-08-29 ("get rid of it
285
+ // marketplaceDensity has NO CONTROL ON ANY SURFACE since the ruling of 2026-08-29 ("get rid of it
198
286
  // completely. there is no such thing as staff only"), and it is NOT code-owned — so without this it would
199
287
  // be stripped on the next save of any profile that has one.
200
288
  //
@@ -210,7 +298,7 @@ function normalizeDefaultProduct(incoming, existing) {
210
298
  // NO CONTROL ON ANY SURFACE, and profile-page.html reconstructs its payload from the inputs it has — so
211
299
  // each one would be stripped on the next staff save of any profile carrying it.
212
300
  //
213
- // marketplaceDensity the control was removed by owner ruling; the value sizes
301
+ // marketplaceDensity the control was removed by ruling; the value sizes
214
302
  // the grid batch and losing it re-arms a measured truncation crash.
215
303
  // demoData never had a control; losing it turns a demo account into
216
304
  // one indistinguishable from a client, which is the exact failure the marker
@@ -283,7 +371,15 @@ export function makeProfileService({
283
371
  key: profile.key,
284
372
  profile: stripDerived(profile),
285
373
  contextPack: readPack(profile.key),
286
- derived: { minCellsPerVariant: derivedFloor(profile), batchSize: derivedBatchSize(profile) },
374
+ derived: { minCellsPerVariant: derivedFloor(profile), batchSize: derivedBatchSize(profile),
375
+ // The stored default territories the engine cannot search, NAMED. Without this the profile
376
+ // screen shows the entries back exactly as typed and the engine quietly ignores them — which is
377
+ // what a person saw before: a setting that reads as in force and does nothing.
378
+ //
379
+ // It is computed here rather than read off a run, because it is a fact about the PROFILE. A
380
+ // reader who only hears about it on the runs where it happened to apply learns about a broken
381
+ // setting at random.
382
+ unrecognizedTerritories: defaultTerritoryState(profile).unrecognized },
287
383
  framework: {
288
384
  path: fwPath,
289
385
  // CUSTOM MEANS "NOT THE GENERIC DEFAULT", which is what the word means to the lawyer reading the
@@ -318,6 +414,80 @@ export function makeProfileService({
318
414
  const parts = path.replace(/\/+$/, "").split("/").filter(Boolean); // ["profiles", key?, action?]
319
415
  if (parts[0] !== "profiles") return { status: 404, json: { error: "not_found" } };
320
416
 
417
+ // POST /profiles — CREATE, which is not the same act as save and must not route through it.
418
+ //
419
+ // `save` is an upsert and looks like it would do: it would not. `preserveCodeOwned` takes the
420
+ // on-disk value of every code-owned field and DELETES the field when there is none, and a company
421
+ // being created has nothing on disk — so a create through save writes no `frameworkPath`, silently,
422
+ // and the company is rated under the house default from then on. That is live today through the
423
+ // staff page and it is what this route exists not to repeat.
424
+ //
425
+ // Every question below is asked of `driver/company-bundle.mjs`, which the command line asks too.
426
+ // There is no second opinion here about what a valid company is.
427
+ if (parts.length === 1 && method === "POST") {
428
+ const name = isStr(body?.name) ? body.name.trim() : "";
429
+ if (!name) return { status: 400, json: { error: "needs_name", message: "A company needs a name." } };
430
+
431
+ // The key is derived from the name unless one was asked for, and it is REFUSED rather than
432
+ // repaired: a name of punctuation alone derives nothing, and inventing a key there would file a
433
+ // company under something nobody chose.
434
+ const wanted = isStr(body?.key) && body.key.trim() ? body.key.trim().toLowerCase() : companyKeyFrom(name);
435
+ if (!wanted) {
436
+ return { status: 400, json: { error: "needs_key",
437
+ message: `No key could be made from "${name}". Type one — lowercase letters, digits and hyphens.` } };
438
+ }
439
+ try { assertProfileKey(wanted); }
440
+ catch (e) { return { status: 400, json: { error: "bad_key", message: String(e.message) } }; }
441
+
442
+ const existing = resolveExisting();
443
+ let framework, platforms, profile;
444
+ try {
445
+ // ALWAYS SET, NEVER ABSENT — the company's own when one is supplied, the house default named
446
+ // otherwise. This is the whole reason a create does not go through save.
447
+ framework = resolveFramework(isStr(body?.frameworkPath) ? body.frameworkPath : null);
448
+ platforms = resolvePlatforms(Array.isArray(body?.platforms) ? body.platforms : null, existing);
449
+ profile = buildProfile({
450
+ key: wanted, name,
451
+ domains: Array.isArray(body?.matchDomains) ? body.matchDomains : [],
452
+ platforms: platforms.platforms,
453
+ // The whole resolution, not its path: buildProfile reads `.path` off it, and handing it the
454
+ // string writes `frameworkPath: undefined` — which is precisely the silent-absence defect this
455
+ // route exists to prevent, arriving through the route that prevents it.
456
+ framework,
457
+ industry: isStr(body?.industry) && body.industry.trim() ? body.industry.trim() : null,
458
+ // Carried, not dropped. A form that offers a field and a route that ignores it is the silent
459
+ // data loss this whole route exists to stop, arriving one layer further in. Arrays only —
460
+ // anything else is left absent rather than guessed at, and the validator then rules on it.
461
+ tradingNames: Array.isArray(body?.selfExclusionOwners) ? body.selfExclusionOwners : [],
462
+ classes: Array.isArray(body?.defaultClasses) ? body.defaultClasses : [],
463
+ territories: Array.isArray(body?.defaultJurisdictions) ? body.defaultJurisdictions : [],
464
+ });
465
+ // The proposed roster, validated whole. A colliding domain does not fail this company — it
466
+ // stops the deployment resolving ANY of them at the next start, so it is caught before a write
467
+ // rather than discovered by the next process to read profiles.
468
+ assertRosterAccepts({ store: profileDir, key: wanted, profile, loadProfiles });
469
+ } catch (e) {
470
+ if (e?.name === "Refusal") return { status: 400, json: browserRefusal(e) };
471
+ throw e;
472
+ }
473
+
474
+ const { files } = writeProfile({ profileDir, key: wanted, profile, contextPack: "" });
475
+ // WRITTEN AND RECORDED ARE TWO EVENTS. The write is live the instant it renames; the commit can
476
+ // fail on its own. Reporting them as one is how somebody is told nothing happened about a company
477
+ // that is already governing runs.
478
+ const message = `chore(clearotron): create company ${wanted} (via portal, by ${by})`;
479
+ const { commit, commitError } = commitWithAuditRow({ audit, gitCommit, files, message, by,
480
+ row: { event: "profile-create", key: wanted, by, fields: Object.keys(profile) } });
481
+ return { status: 201, json: {
482
+ key: wanted, name, written: true, created: true, commit,
483
+ // The receipt reads off THESE, so it can say which framework and how many marketplaces, and
484
+ // whether each was chosen or defaulted, rather than restating what was typed.
485
+ framework: { path: framework.path, defaulted: framework.source === "default" },
486
+ marketplaces: { count: platforms.platforms.length, defaulted: platforms.source !== "supplied" },
487
+ ...(commitError ? { commitError: `created and LIVE, but the git commit failed (${commitError}) — the audit line records the gap` } : {}),
488
+ } };
489
+ }
490
+
321
491
  // GET /profiles — roster
322
492
  if (parts.length === 1) {
323
493
  if (method !== "GET") return { status: 405, json: { error: "method_not_allowed" } };
@@ -333,6 +503,7 @@ export function makeProfileService({
333
503
  } };
334
504
  }
335
505
 
506
+
336
507
  const key = parts[1];
337
508
  const action = parts[2];
338
509
 
@@ -409,7 +580,7 @@ export function makeProfileService({
409
580
  if (overlayBody.defaultProduct === undefined && priorOverlay?.defaultProduct !== undefined)
410
581
  overlayBody.defaultProduct = priorOverlay.defaultProduct;
411
582
  // marketplaceDensity: same rule, and now permanent rather than "yet" — the control was removed from
412
- // both surfaces by owner ruling, so an overlay's value can only ever come from the file it is in.
583
+ // both surfaces by ruling, so an overlay's value can only ever come from the file it is in.
413
584
  preserveUncontrolled(overlayBody, priorOverlay);
414
585
  // ARCHIVE STATE IS STICKY AGAINST OMISSION — recipe-service.mjs's discipline verbatim: un-archiving
415
586
  // takes an EXPLICIT archived:false, never a body that simply lacks the key. This is not theoretical:
@@ -627,7 +798,7 @@ const PORT = PORT_CHOICE.port;
627
798
  //
628
799
  // `makeAccessVerifier` has ALWAYS accepted issuer/jwksUrl/emailClaim — this service simply never
629
800
  // passed them, which is how one product shipped a provider-agnostic API face and three single-vendor
630
- // services beside it. Owner ruling 2026-08-23: "we can't launch with an identity vendor, they bring
801
+ // services beside it. Ruling 2026-08-23: "we can't launch with an identity vendor, they bring
631
802
  // their own… they pick their own."
632
803
  const OIDC_ISSUER = process.env.PROFILE_OIDC_ISSUER || "";
633
804
  const JWKS_URL = process.env.PROFILE_JWKS_URL || "";
@@ -20,6 +20,7 @@ import { readFileSync, readdirSync, existsSync } from "node:fs";
20
20
  import { join, dirname } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { ORDERABLE_PRODUCTS } from "./search-policy.mjs";
23
+ import { recognizedTerritories } from "./territory-tiers.mjs"; // which stored territories the engine can actually search
23
24
  import { envFrom } from "../shared/env-aliases.mjs"; // — a refusal names the name in force
24
25
 
25
26
  // CLEAROTRON_CUSTOMERS_DIR selects the customer config STORE; the bundled driver/profiles is the
@@ -216,6 +217,31 @@ export function withRunPlatforms(profile, jobPlatforms) {
216
217
  return { profile: { ...profile, platforms: [...(profile?.platforms ?? []), ...added] }, added };
217
218
  }
218
219
 
220
+ // The three facts that tell one company from another at a glance, for the surfaces a person picks on.
221
+ //
222
+ // ONE SHAPE, TWO ROUTES. The staff roster and a client's own /me both answer this, and they answered
223
+ // different questions before: the roster carried key and name, /me carried names alone. A picker that
224
+ // renders a facts line from one and a bare name from the other shows the same company two ways
225
+ // depending on who signed in — the exact defect ownerNames.mjs was written to end for the NAME.
226
+ //
227
+ // THE COUNT, NEVER THE LIST. `platformCount` is what a reader sees ("6 marketplaces"); the platform
228
+ // list is which marketplaces this account has us sweep, and no surface here asks for it. Sending the
229
+ // array so the browser can call `.length` would put that on the wire for every granted identity.
230
+ //
231
+ // Absent industry reads as null and the caller omits the segment: "Generic default" carries no
232
+ // industry and no territories, and a line reading "· 3 marketplaces ·" with empty ends is worse than
233
+ // a shorter line.
234
+ export function companyFactsOf(profile) {
235
+ const industry = typeof profile?.industry === "string" && profile.industry.trim() ? profile.industry.trim() : null;
236
+ return {
237
+ industry,
238
+ platformCount: Array.isArray(profile?.platforms) ? profile.platforms.length : 0,
239
+ territories: Array.isArray(profile?.defaultJurisdictions)
240
+ ? profile.defaultJurisdictions.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim())
241
+ : [],
242
+ };
243
+ }
244
+
219
245
  // F7 — the closed set of profile-file keys (deny-unknown-key). Every key here has a live consumer
220
246
  // recorded in FIELD_CONSUMERS; an unknown key is a dead knob or a typo and hard-fails at load (the
221
247
  // loader silently tolerated unknowns before). `minCellsPerVariant`/`batchSize` are intentionally
@@ -620,6 +646,27 @@ export function validateProfileEdit(key, profileObj, contextPack = "", { sparse
620
646
  if (contextPack && String(contextPack).trim()) {
621
647
  try { assertContextPackShape(String(contextPack), "context pack"); } catch (e) { errors.push(String(e.message)); }
622
648
  }
649
+ // A DEFAULT TERRITORY THE ENGINE CANNOT SEARCH IS REFUSED WHERE IT IS WRITTEN.
650
+ //
651
+ // HERE AND NOT IN validateProfileShape, and the difference is a roster-wide outage. That function runs
652
+ // on the LOAD path, so a refusal there would stop every profile already holding such an entry from
653
+ // loading — and when the loader throws, it does not fail that one bundle, it fails the deployment's
654
+ // whole roster and every customer's clearance with it. This wrapper is the WRITE path: the two doors
655
+ // that create, the two that save, and the repository lint. Nothing here can refuse a profile that is
656
+ // already on disk.
657
+ //
658
+ // It does not narrow what a REQUEST may name. That tolerance is deliberate and is a decision about what
659
+ // a client receives; this is the stored default, which is the opposite case — it is set once, by
660
+ // somebody who then stops watching, and its failure is silent by construction.
661
+ if (!sparse || profileObj?.defaultJurisdictions !== undefined) {
662
+ const { dropped } = recognizedTerritories(profileObj?.defaultJurisdictions ?? []);
663
+ if (dropped.length) {
664
+ errors.push(`profiles/${key}.json: ${dropped.map((d) => JSON.stringify(d)).join(", ")} `
665
+ + `${dropped.length === 1 ? "is not a territory" : "are not territories"} the engine can search, `
666
+ + `so storing ${dropped.length === 1 ? "it" : "them"} would be a default that silently does `
667
+ + `nothing. Use a country name, or a two-letter code such as US, GB or EU.`);
668
+ }
669
+ }
623
670
  return { ok: errors.length === 0, errors };
624
671
  }
625
672
 
@@ -742,7 +789,7 @@ export function loadProfiles({ dir, force = false, includeTestFixtures, includeD
742
789
  : String(process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ?? "").trim() === "1";
743
790
  if (!asked) for (const [k, p] of [...profiles]) if (p?.testFixture === true) profiles.delete(k);
744
791
 
745
- // THE DEMO ACCOUNT IS NOT PART OF A FRESH INSTALL EITHER — owner ruling, 2026-09-08: a clean install
792
+ // THE DEMO ACCOUNT IS NOT PART OF A FRESH INSTALL EITHER — ruling, 2026-09-08: a clean install
746
793
  // resolves `generic` and nothing else, and the demo brings its own account when somebody runs it.
747
794
  // Nobody should have to clean demo material out of an environment they just created.
748
795
  //
@@ -14,7 +14,7 @@ import { parseReport, parseAudit, parseSections, parseBlocks, stripInternal, par
14
14
  import { renderHtml, parseActionBuckets, actYouConditions } from './render.mjs';
15
15
  import { buildAudit } from './xlsx.mjs';
16
16
  import { parseFindingsJson, parseFindingsJsonLenient, deriveDisplayVerdict, joinFindingToBlock, CLIENT_TIER_BY_COMPOSITE, projectCoverageJudgment } from '../findings-model.mjs';
17
- import { readStore, requiredAbsent } from './publish-inputs.mjs';
17
+ import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; // — and why an absence did not close
18
18
  import { clearanceReportData } from './report-data.mjs';
19
19
  import { parseFrameworkManifest } from '../framework.mjs';
20
20
  import { rollupTokens } from '../tokens.mjs';
@@ -132,7 +132,7 @@ export function evaluateClientGate({ coverage = [], lintFailingIds = [], escalat
132
132
  const closing = requiredAbsent(inputsAbsent);
133
133
  if (closing.length)
134
134
  push('publish-input-absent', `a required input to this report was not there (${closing.join(', ')}) — the report was built without it, which is not the same as a search that found nothing`);
135
- return { released: reasons.length === 0, reasons, reasonCodes, inputsAbsent: [...(Array.isArray(inputsAbsent) ? inputsAbsent : [])] };
135
+ return { released: reasons.length === 0, reasons, reasonCodes, inputsAbsent: [...(Array.isArray(inputsAbsent) ? inputsAbsent : [])], notClosing: nonClosingAbsences(inputsAbsent) }; // `notClosing` EXPLAINS a recorded absence that did not hold the release (publish-inputs.mjs nonClosingAbsences) — it decides nothing
136
136
  }
137
137
 
138
138
  // T3 (H3) — the machine-QC INPUT assembly. Mirrors publishReport's own reads exactly (the
@@ -201,7 +201,7 @@ const INDEX_CSS = `
201
201
  .b{display:inline-block;color:#fff;font-weight:700;font-size:12px;padding:2px 9px;border-radius:999px}
202
202
  .b-mh,.b-l3{background:var(--h-amber)}.b-l4{background:var(--crimson)}.b-l2{background:var(--h-green)}.b-l1{background:var(--h-grey)}
203
203
  .stmt{display:block;margin-top:3px;font-size:11.5px;color:var(--muted,#6b5d50);max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
204
- .hold{display:inline-block;background:var(--crimson);color:#fff;font-weight:700;font-size:11px;padding:1px 7px;border-radius:999px;margin-left:6px}
204
+ .hold{display:inline-block;background:var(--crimson);color:#fff;font-weight:700;font-size:11px;padding:1px 7px;border-radius:999px;margin-left:6px} .disc{display:inline-block;border:1px solid var(--line);color:var(--muted);font-size:11px;padding:1px 7px;border-radius:999px;margin-left:6px}
205
205
  .hpill{display:inline-block;color:#fff;font-weight:700;font-size:11.5px;padding:3px 11px;border-radius:999px;white-space:nowrap}
206
206
  .filterbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px}
207
207
  .filterbar .flbl{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--crimson-mid);font-weight:700}
@@ -247,7 +247,7 @@ function indexRows(runs, { reportFile, linkPrefix = '', showAudit = true, client
247
247
  <td>${matterCell}</td>
248
248
  <td>${anonMark(r.title, { key: ck, run: r.runId })}</td>
249
249
  <td>${anonClient(r.client, ck)}</td>
250
- <td><span class="b b-${esc(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${
250
+ <td><span class="b b-${esc(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${!client && !qcFailed && r.clientGate?.inputsAbsent?.length ? ` <span class="disc" title="${escAttr(`published without ${r.clientGate.inputsAbsent.join(', ')} — each declared optional, so the release stands; the report was built without it`)}">◦ built without an input</span>` : ''}${
251
251
  // spec 64 — the stance clause of THE one risk statement beside the (labelled) band pill, so the
252
252
  // index can never show a bare severity word that reads as the whole answer. The tier word leads
253
253
  // the statement; the pill already shows it, so the cell carries the clause after the first " — ".
@@ -859,7 +859,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
859
859
  const esPath = driverDir(runDir ?? dirname(reportMd), 'enforcer-signals.json');
860
860
  if (existsSync(esPath)) { const es = JSON.parse(readFileSync(esPath, 'utf8')); if (Array.isArray(es)) enforcerSignals = es; }
861
861
  } catch { /* absent — no telemetry lines */ }
862
- bindFindingsToRecords(findings, recordsByUri);
862
+ const officeLinks = officeLinksFor(findings, recordsByUri, fetchReceipts); bindFindingsToRecords(findings, recordsByUri);
863
863
  // 404-card caveat (2026-07-22): the V4-2 closure pass persisted every cited record its targeted
864
864
  // fetch definitively could not retrieve (predelivery-lint.json artifactSet.recordFetchFailures);
865
865
  // the evidence join stamps `_recordFetchFailure` from it so the card render carries the
@@ -1030,7 +1030,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1030
1030
  // the same rule (the workbook's own BANNED gate had already started firing on the raw detail —
1031
1031
  // advisory, so CI stayed green). reviewReceipts.lint keeps its raw detail for the internal
1032
1032
  // readers above (fetchState reads registry-record-coverage's URIs out of it).
1033
- counts = await buildAudit({ findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1033
+ counts = await buildAudit({ findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1034
1034
  grpRead(join(poolRunDir, auditFile), 0o640);
1035
1035
  if (counts?.gateViolations?.length) console.warn(`[audit-workbook] advisory: ${counts.gateViolations.join(' | ')}`);
1036
1036
  } catch (e) {
@@ -1052,7 +1052,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1052
1052
  // worse demo than no toggle.
1053
1053
  const reportNav = siteNav(poolRoot, 'report', null, '../', { anon: false });
1054
1054
  // `demoData` is resolved above the report.md write — one answer, every surface.
1055
- writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, providerLabel, seniorRights, findingsSchemaVersion }));
1055
+ writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion }));
1056
1056
  // ONE report (spec 2026-07-30 §5): report.client.html is no longer written. The knockout lane's own
1057
1057
  // collapse note is the precedent: "two renderings of one run is how the wrong link gets sent". The
1058
1058
  // client host serves the same report.html through the portal's readReport() (cleaning built in) — its
@@ -1102,9 +1102,11 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1102
1102
  // carry neither field and consumers null-guard, exactly as they do for markName — a delivered run does
1103
1103
  // not gain a project retroactively, it gains one on republish or not at all.
1104
1104
  let project = null;
1105
+ let organisation = null;
1105
1106
  try {
1106
1107
  const frozen = runDir ? JSON.parse(readFileSync(driverDir(runDir, 'profile.json'), 'utf8')) : null;
1107
1108
  if (frozen?.projectKey) project = { key: String(frozen.projectKey), name: String(frozen.projectName ?? frozen.projectKey) };
1109
+ if (typeof frozen?.organisation === 'string' && frozen.organisation) organisation = frozen.organisation;
1108
1110
  } catch { /* no frozen profile — pre-WS-B run, or a republish with no workspace */ }
1109
1111
  let tokenRollup;
1110
1112
  try { tokenRollup = runDir ? rollupTokens(runDir) : undefined; } catch { tokenRollup = undefined; }
@@ -1142,10 +1144,14 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1142
1144
  projectKey: project?.key ?? undefined,
1143
1145
  projectName: project?.name ?? undefined,
1144
1146
  customerKey: customerKey || 'generic',
1147
+ // WHICH ORGANISATION'S GENERIC this run was filed under, read from the frozen sidecar like the
1148
+ // project. Absent on a company's run and on one filed before organisations existed, so such a meta
1149
+ // stays byte-identical to a pre-stamp one.
1150
+ organisation: organisation ?? undefined,
1145
1151
  issuedAt,
1146
1152
  // WHICH BUILD produced this. null off a git checkout — a provenance stamp never fails a publish.
1147
1153
  engineCommit: engineCommit(),
1148
- kind: 'clearance',
1154
+ kind: 'clearance', recordLinks: officeLinks?.tally ?? undefined, // per office: linked, or cited by number and why; only where the register has no record pages
1149
1155
  searchLevel: searchPolicy?.level ?? undefined,
1150
1156
  // Display-only face of the level ("Depth 4"), frozen alongside it so the list can show which reads
1151
1157
  // have run on a name without the browser having to know the level registry.
@@ -1180,7 +1186,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1180
1186
  // re-rendered without its meta changing shape. When it IS present it is the durable record that
1181
1187
  // this report was assembled without those stores, which the run previously kept nowhere at all.
1182
1188
  clientGate: { released: clientGate.released, reasons: clientGate.reasons,
1183
- inputsAbsent: clientGate.inputsAbsent?.length ? clientGate.inputsAbsent : undefined },
1189
+ inputsAbsent: clientGate.inputsAbsent?.length ? clientGate.inputsAbsent : undefined, notClosing: clientGate.notClosing?.length ? clientGate.notClosing : undefined },
1184
1190
  // PR-9 — present ⇒ report-data.json is beside the report and the portal can render natively (the
1185
1191
  // same stamp the knockout lane writes; the consumer branch had readers before it had a writer).
1186
1192
  // undefined on a producer miss, so the meta never advertises a file that is not there.
@@ -1189,7 +1195,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1189
1195
 
1190
1196
  const total = skipRegen ? null : regenIndex(poolRoot);
1191
1197
  if (!skipRegen) await regenSurfaces(poolRoot);
1192
- // THE PORTAL ROUTE, not the pool's directory layout (tracker issue 289). This was
1198
+ // THE PORTAL ROUTE, not the pool's directory layout. This was
1193
1199
  // `<origin>/<runId>/report.html` — where the documents sit on disk, which is not an application route.
1194
1200
  // Composed rather than spelled here so the report link is built the same way the audit link always was.
1195
1201
  const url = reportRouteFor(poolUrl, runId);
@@ -1244,7 +1250,7 @@ export function accessNoteHtml(font, domain = config.accessDomain) {
1244
1250
  * the edge's own legacy-report regexp character for character, so this function admits exactly the URLs the
1245
1251
  * deployment admits and no others.
1246
1252
  */
1247
- // ── TWO SHAPES, AND THE SECOND ONE IS HISTORY (tracker issue 289) ────────────────────────────────────
1253
+ // ── TWO SHAPES, AND THE SECOND ONE IS HISTORY ────────────────────────────────────────────────────────
1248
1254
  //
1249
1255
  // The block quoted above describes an edge rewrite that IS NOT IN FORCE ON PRODUCTION. Settled by the
1250
1256
  // account owner against the live deployment, signed in as a delivered run's own owner: the emailed link
@@ -1279,7 +1285,7 @@ export function auditRouteFor(origin, runId) {
1279
1285
  }
1280
1286
 
1281
1287
  /**
1282
- * THE REPORT'S ROUTE, from an origin and a run id (tracker issue 289).
1288
+ * THE REPORT'S ROUTE, from an origin and a run id.
1283
1289
  *
1284
1290
  * The delivered link used to be the pool's directory layout pasted behind the public origin —
1285
1291
  * `<origin>/<runId>/report.html` — which is where the documents sit on disk and is not an application
@@ -1387,7 +1393,7 @@ export function composeEmailBody(reportMdPath, url, auditFile, productName = nul
1387
1393
  // section "already renders every open floor in plain language" — it was never built, and the pointer
1388
1394
  // had been promising it since.
1389
1395
  //
1390
- // OWNER RULING 2026-08-19 (relayed): drop the claim; no Coverage section is being designed. So the
1396
+ // RULING 2026-08-19 (relayed): drop the claim; no Coverage section is being designed. So the
1391
1397
  // sentence states the fact and stops. It is deliberately not replaced with a different pointer — the
1392
1398
  // failure mode here was a pointer written before its target, and one true sentence beats two where
1393
1399
  // the second is a promise. When a surface exists that renders open floors in client language, this is
@@ -1612,3 +1618,15 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1612
1618
 
1613
1619
  return `<div style="${FONT}">${reviewHeadline}${footerNote}</div>`;
1614
1620
  }
1621
+
1622
+ // ── The office's own page for each fetched record, where the run's register publishes none of its own ──
1623
+ // Addressed from the record's numbers (office-record-links.mjs), never from the model or the handle, and
1624
+ // null on every other register, so those runs publish exactly as before. The tally goes to meta.json and
1625
+ // the log, so a register whose numbers never fit shows up as a count rather than as silence. Kept down
1626
+ // here, below every line the rest of the tree cites by number.
1627
+ import { recordLinksFor } from './office-record-links.mjs';
1628
+ function officeLinksFor(findings, recordsByUri, fetchReceipts) {
1629
+ const links = recordLinksFor(findings, recordsByUri, fetchReceipts?.[0]?.provider);
1630
+ if (links) console.log(`[record-links] ${links.summary}`);
1631
+ return links;
1632
+ }
@@ -70,7 +70,7 @@ export const knockoutStatement = (framework, marks) =>
70
70
  // renders through publish/render-knockout.mjs in the product's own design language, off the same shared
71
71
  // stylesheet and brand tokens as the clearance report.
72
72
  //
73
- // THE REVIEWER'S NOTES ARE ON THE REPORT SINCE 2026-09-07 (owner ruling, tracker issue 274). This
73
+ // THE REVIEWER'S NOTES ARE ON THE REPORT SINCE 2026-09-07 (ruling). This
74
74
  // paragraph used to end "internal working material (the purple staff notes, the model's registerEstimate)
75
75
  // is not IN the report; it lives in the audit workbook". That is now true of `registerEstimate` only: the
76
76
  // notes render on the page, labelled, and the workbook keeps its copy. See render-knockout.mjs's header
@@ -364,14 +364,14 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
364
364
  let registerRecords = null;
365
365
  try { registerRecords = JSON.parse(readFileSync(driverDir(runDir, 'register-records.json'), 'utf8')); }
366
366
  catch { registerRecords = null; }
367
- // The owner lookups this run made (tracker issue 276), read the same tolerant way as the records above:
367
+ // The owner lookups this run made, read the same tolerant way as the records above:
368
368
  // an archived run that predates the lane has no file, and its cards then render exactly as they were
369
369
  // delivered. The source line the report prints comes from HERE, not from anything the seat typed.
370
370
  let ownerChecks = [];
371
371
  try { ownerChecks = JSON.parse(readFileSync(driverDir(runDir, 'owner-checks.json'), 'utf8')).checks ?? []; }
372
372
  catch { ownerChecks = []; }
373
373
  // The request the run was given, read the same tolerant way as the sidecars above and for the same
374
- // reason (tracker issue 331 A.1). It is what "About this request" states; a run archived before the
374
+ // reason (A.1). It is what "About this request" states; a run archived before the
375
375
  // sidecar existed has none, and its page renders exactly as it was delivered.
376
376
  let instructedScope = null;
377
377
  try { instructedScope = JSON.parse(readFileSync(driverDir(runDir, 'instructed-scope.json'), 'utf8')); }
@@ -492,7 +492,7 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
492
492
  // once as string surgery on the report URL, once as this function, and both spelled a pool path.
493
493
  //
494
494
  // A SINGLE MARK USED TO KEEP THE LEGACY SHAPE, on the reasoning that `<runId>/report.html` is what the
495
- // rewrite exists for and that it resolves. THAT IS FALSE ON PRODUCTION (tracker issue 289) and the
495
+ // rewrite exists for and that it resolves. THAT IS FALSE ON PRODUCTION and the
496
496
  // paragraph is kept, corrected, because its warning is still the right instinct and only its premise
497
497
  // was wrong.
498
498
  //
@@ -526,7 +526,7 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
526
526
  // paragraph names every mark in the batch, and a reader who ordered one name must not be handed a
527
527
  // summary about other clients' marks. What it left behind was a page opening with nothing.
528
528
  //
529
- // The assess stage writes a model-authored paragraph per mark now (owner ruling, full length), so
529
+ // The assess stage writes a model-authored paragraph per mark now (ruling, full length), so
530
530
  // the blank has a replacement rather than being merely correct. Substituting it HERE, into the
531
531
  // per-mark COPY, is what makes one line serve both surfaces: the rendered `<div class="sub">` and
532
532
  // `report-data-<slug>.json`'s `.summary` both read `batch.executiveSummary`, so neither the
@@ -619,6 +619,10 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
619
619
  markName: batchMarkName(markNames) ?? undefined,
620
620
  engineCommit: engineCommit(),
621
621
  client: null, customerKey: customerKey || 'generic',
622
+ // WHICH ORGANISATION'S GENERIC this batch was filed under, read from the frozen sidecar exactly as
623
+ // the clearance publisher reads it. Absent on a company's batch and on one filed before
624
+ // organisations existed.
625
+ organisation: (() => { try { const f = JSON.parse(readFileSync(driverDir(runDir, 'profile.json'), 'utf8')); return typeof f?.organisation === 'string' && f.organisation ? f.organisation : undefined; } catch { return undefined; } })(),
622
626
  issuedAt: (() => { try { const prev = JSON.parse(readFileSync(join(poolRunDir, 'meta.json'), 'utf8')); return prev?.issuedAt ?? new Date().toISOString(); } catch { return new Date().toISOString(); } })(),
623
627
  kind: 'knockout-batch',
624
628
  marks: (findings.marks ?? []).map((m) => ({ name: m.name, band: m.rating })),