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
@@ -21,9 +21,11 @@
21
21
  //
22
22
  // Three rules follow, and each is enforced here rather than trusted to a caller:
23
23
  //
24
- // 1. THE ACCOUNT COMES FROM THE PRINCIPAL, NEVER FROM THE PATH OR THE BODY. A client cannot name an
25
- // account at all — theirs is substituted. Staff must name one, and it is checked against the
26
- // roster. There is no code path where a body field or a URL segment picks the tenant.
24
+ // 1. THE ACCOUNT COMES FROM THE PRINCIPAL, NEVER FROM THE PATH OR THE BODY. A named account must be
25
+ // inside the person's access, and a person with one company has it substituted. A person who sees
26
+ // everything must name one, and whether it EXISTS is answered downstream: the profile service 404s
27
+ // an unknown key and this wall passes that 404 through unchanged. There is no code path where a
28
+ // body field or a URL segment picks the tenant.
27
29
  //
28
30
  // 2. A FOREIGN ACCOUNT IS 404, byte-identical to a nonexistent one. Never 403. A 403 confirms the
29
31
  // account exists, which is exactly the fact a competitor would be probing for.
@@ -40,7 +42,7 @@
40
42
  // the archived ones. Tenancy is untouched: it lives in resolveAccount and rule 2, as it always did.
41
43
 
42
44
  import { KNOWN_PROFILE_KEYS, PROJECT_KEYS } from "./profiles.mjs";
43
- import { assertPrincipal, PortalDeny } from "./portal-access.mjs";
45
+ import { assertPrincipal, PortalDeny, seesEverything, mayManage } from "./portal-access.mjs";
44
46
 
45
47
  /**
46
48
  * The five fields the UI must never write.
@@ -172,11 +174,15 @@ export function frameworkView(framework, { staff = false } = {}) {
172
174
  * account — the same shape portal-service already turns into a 404 body — so a caller cannot forget to
173
175
  * check a return value and proceed with an unresolved account.
174
176
  */
175
- export function resolveAccount(principal, requested) {
176
- // assertPrincipal is the existing chokepoint: it forces a client to their own grant and 404s a
177
- // foreign one. Reusing it means tenancy has ONE implementation, not two that must agree.
178
- const account = assertPrincipal(principal, { account: requested ?? null });
179
- if (!account) throw new PortalDeny(400, "name an account — staff must pick who they act for");
177
+ export function resolveAccount(principal, requested, gates = {}) {
178
+ // assertPrincipal is the existing chokepoint: it holds a person to their own access and 404s a
179
+ // foreign account. Reusing it means tenancy has ONE implementation, not two that must agree. `gates`
180
+ // carries the permission a write needs — `manage` for settings, `run` for saved searches.
181
+ const account = assertPrincipal(principal, { account: requested ?? null, ...gates });
182
+ if (!account) throw new PortalDeny(400, "name an account (?account=)");
183
+ // Generic's settings are one file for the whole install, whichever organisation's Generic is open, so
184
+ // changing them is a change at the top of the tree.
185
+ if (account === "generic" && gates.manage && !seesEverything(principal)) throw new PortalDeny(404, "not found");
180
186
  return account;
181
187
  }
182
188
 
@@ -193,7 +199,7 @@ export function resolveAccount(principal, requested) {
193
199
  * there, and this is the channel that respects it. What must never happen is the reverse: a body field
194
200
  * that looks like an author and is quietly trusted by some future reader.
195
201
  */
196
- export function makeUpstream({ callUpstream, callRecipes = null, roster = async () => [] }) {
202
+ export function makeUpstream({ callUpstream, callRecipes = null, recipesOff = null, roster = async () => [], fileCompany = null }) {
197
203
  const call = async (method, path, body, identity) => {
198
204
  const r = await callUpstream(method, path, body, identity);
199
205
  // Upstream 404s (unknown profile) and ours (not yours) are deliberately the same answer.
@@ -204,7 +210,20 @@ export function makeUpstream({ callUpstream, callRecipes = null, roster = async
204
210
  // owns a different store and a different validator. Absent ⇒ every saved-search route answers 404,
205
211
  // which is what a deployment with no recipe store configured should say: the feature is not here.
206
212
  const callRec = async (method, path, body, identity) => {
207
- if (!callRecipes) return { status: 404, json: { error: "not_found" } };
213
+ // A CONFIGURED-OFF FEATURE IS NOT A MISSING PAGE. Both answer 404 — the route genuinely is not
214
+ // there — but a bare `not_found` is what let the screen say "try again shortly" about a permanent
215
+ // state the portal had already diagnosed at boot. The code names it and the detail carries the
216
+ // diagnosis, so the surface can say what must change instead of advising a retry that cannot work.
217
+ //
218
+ // THE DETAIL IS FOR A PERSON WITH ACCESS TO EVERYTHING, as a company's file paths are. It names
219
+ // environment variables and filesystem paths on the server, and this screen is company-scoped — a
220
+ // person holding one company can reach it. The CODE travels to everyone, because "this installation
221
+ // does not have saved searches" is true and useful for them and gives away nothing; only the
222
+ // sentence saying which variable to change is held back.
223
+ if (!callRecipes) {
224
+ return { status: 404, json: { error: recipesOff?.code ?? "not_found",
225
+ ...(seesEverything(identity) && recipesOff?.detail ? { detail: recipesOff.detail } : {}) } };
226
+ }
208
227
  const r = await callRecipes(method, path, body, identity);
209
228
  if (r.status === 404) return { status: 404, json: { error: "not_found" } };
210
229
  return r;
@@ -223,7 +242,7 @@ export function makeUpstream({ callUpstream, callRecipes = null, roster = async
223
242
  const r = await call("GET", `/profiles/${encodeURIComponent(account)}`, undefined, principal);
224
243
  if (r.status !== 200) return r;
225
244
  const p = r.json?.profile ?? r.json ?? {};
226
- const staff = principal?.role === "staff";
245
+ const staff = seesEverything(principal);
227
246
  return { status: 200, json: {
228
247
  account,
229
248
  profile: serializeProfile(p),
@@ -242,7 +261,8 @@ export function makeUpstream({ callUpstream, callRecipes = null, roster = async
242
261
  */
243
262
  async writeProfile(principal, requested, action, body) {
244
263
  if (action !== "validate" && action !== "save") return { status: 404, json: { error: "not_found" } };
245
- const account = resolveAccount(principal, requested);
264
+ // A company's settings are Manage's to change.
265
+ const account = resolveAccount(principal, requested, { manage: true });
246
266
  const profile = stripCodeOwned(body?.profile);
247
267
  if (!profile || typeof profile !== "object" || Array.isArray(profile))
248
268
  return { status: 400, json: { error: "a profile object is required" } };
@@ -284,7 +304,8 @@ export function makeUpstream({ callUpstream, callRecipes = null, roster = async
284
304
 
285
305
  async writeProject(principal, requested, project, action, body) {
286
306
  if (action !== "validate" && action !== "save") return { status: 404, json: { error: "not_found" } };
287
- const account = resolveAccount(principal, requested);
307
+ // A project sits in the tree under its company, so adding or changing one is Manage's.
308
+ const account = resolveAccount(principal, requested, { manage: true });
288
309
  if (!isSlug(project)) return { status: 404, json: { error: "not_found" } };
289
310
  // A project overlay may only carry PROJECT_KEYS. Customer-only keys are rejected upstream with a
290
311
  // real 400, but the same stripping discipline applies one level down: identity and rating
@@ -335,7 +356,8 @@ export function makeUpstream({ callUpstream, callRecipes = null, roster = async
335
356
  */
336
357
  async writeSearch(principal, requested, slug, action, body) {
337
358
  if (action !== "validate" && action !== "save") return { status: 404, json: { error: "not_found" } };
338
- const account = resolveAccount(principal, requested);
359
+ // A saved search is how a clearance is ordered again, so it is Run's.
360
+ const account = resolveAccount(principal, requested, { run: true });
339
361
  if (!isSlug(slug)) return { status: 404, json: { error: "not_found" } };
340
362
  const recipe = body?.recipe;
341
363
  if (!recipe || typeof recipe !== "object" || Array.isArray(recipe))
@@ -346,14 +368,80 @@ export function makeUpstream({ callUpstream, callRecipes = null, roster = async
346
368
  }, principal);
347
369
  },
348
370
 
349
- /** Staff-only: the customer roster, for the account picker. Clients never reach this. */
371
+ /** Every company on the install, for the switcher of a person who sees everything. Nobody else reaches this. */
350
372
  async listRoster(principal) {
351
- if (principal?.role !== "staff") return { status: 404, json: { error: "not_found" } };
373
+ if (!seesEverything(principal)) return { status: 404, json: { error: "not_found" } };
352
374
  return { status: 200, json: { customers: await roster() } };
353
375
  },
376
+
377
+ /**
378
+ * Create a company. The ONE method here that does not resolve an account, because the account is
379
+ * what it is making.
380
+ *
381
+ * THE RULE THIS LOOKS LIKE IT BREAKS. Rule 1 at the top of this file says the account comes from
382
+ * the principal and never from the path or the body — and a create's key IS a body field. The rule
383
+ * is about REACHING an account: substituting the caller's own so nobody can name someone else's. A
384
+ * create names no existing account, so there is nothing to substitute and nothing to reach. What
385
+ * takes its place is the permission test on the line below, and the roster check further upstream,
386
+ * which refuses a key that already exists rather than opening it.
387
+ *
388
+ * That makes this the second account-less method, after the roster, and it is gated the same way
389
+ * and for the same reason. The browser's own Manage check decides whether the control is drawn; it
390
+ * is not a boundary and cannot be one. This line is the boundary.
391
+ *
392
+ * 404 rather than 403, per rule 2: a refusal that distinguishes "you may not" from "there is no
393
+ * such endpoint" tells a stranger which endpoints exist.
394
+ *
395
+ * THE FIELDS ARE NAMED, not forwarded. Two reasons, and the second is the load-bearing one. A body
396
+ * passed through whole hands the upstream route whatever a future caller invents. And
397
+ * `frameworkPath` is a real input on that route — so forwarding the body would put a framework
398
+ * chooser one fetch away from any browser, which the design rejected outright: no browser path sets
399
+ * a framework, the create resolves the house default and the receipt names it. Leaving the field
400
+ * out here is what makes that true by construction rather than by the screen's good manners.
401
+ */
402
+ async createCompany(principal, body) {
403
+ if (!mayManage(principal)) return { status: 404, json: { error: "not_found" } };
404
+ if (!body || typeof body !== "object" || Array.isArray(body))
405
+ return { status: 400, json: { error: "a company is required" } };
406
+ // WHICH ORGANISATION IT BELONGS TO. A company belongs to exactly one, and the person creating it
407
+ // must hold that organisation whole: Manage on one company cannot add a sibling beside it. Named by
408
+ // `tenant`, or implied when the person holds exactly one organisation. A person who sees everything
409
+ // on an install with no organisation yet creates it under none, and sees it because they see
410
+ // everything.
411
+ const orgs = principal.genericOrgs ?? [];
412
+ const named = typeof body.tenant === "string" && body.tenant.trim() ? body.tenant.trim() : null;
413
+ let org = null;
414
+ if (named != null) {
415
+ if (!orgs.includes(named)) return { status: 404, json: { error: "not_found" } };
416
+ org = named;
417
+ } else if (orgs.length === 1) org = orgs[0];
418
+ else if (orgs.length > 1) return { status: 400, json: { error: "name the organisation this company belongs to (tenant)" } };
419
+ else if (!seesEverything(principal)) return { status: 404, json: { error: "not_found" } };
420
+ const draft = {};
421
+ for (const k of CREATABLE_FIELDS) if (body[k] !== undefined) draft[k] = body[k];
422
+ const r = await call("POST", "/profiles", draft, principal);
423
+ if (r.status !== 201 || org == null || !fileCompany) return r;
424
+ const key = r.json?.key ?? r.json?.profile?.key ?? draft.key;
425
+ try { await fileCompany({ tenant: org, account: key }); }
426
+ catch (e) {
427
+ return { status: 500, json: { error: `The company was created, but it could not be filed under its organisation (${String(e?.message ?? e).slice(0, 200)}). A person with access to everything can see it and file it.`, key } };
428
+ }
429
+ return { ...r, json: { ...r.json, tenant: org } };
430
+ },
354
431
  };
355
432
  }
356
433
 
434
+ /**
435
+ * What a browser may state when creating a company.
436
+ *
437
+ * Everything else the profile carries has a default the create path resolves, or is code-owned and set
438
+ * there. `frameworkPath` is deliberately absent — see `createCompany`.
439
+ */
440
+ export const CREATABLE_FIELDS = Object.freeze([
441
+ "name", "key", "industry", "matchDomains", "platforms",
442
+ "selfExclusionOwners", "defaultClasses", "defaultJurisdictions",
443
+ ]);
444
+
357
445
  /**
358
446
  * A project key must be a plain slug.
359
447
  *
@@ -33,6 +33,7 @@ import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } fro
33
33
  import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
34
34
  import { normalizeBand } from "./framework.mjs";
35
35
  import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
36
+ import { isEngineAppendedCaveat } from "./verify-knockout.mjs"; // ONE derivation for "the engine appended this caveat, not a seat"
36
37
 
37
38
  // V4-3: diacritics FOLD (NFD strip) instead of being deleted — "Televisión" must normalize to
38
39
  // "television" (deletion made it "televisin", so a diacritic mention never matched its introduction).
@@ -700,7 +701,7 @@ export function competitorClaimChecks({ text, ownerScreen, recordsByUri, markVoc
700
701
  return failures.length ? failures : [check("competitor-claim-verification", "registry", surface, true, "")];
701
702
  }
702
703
 
703
- // ── COVERAGE CLAIMS IN PROSE vs WHAT THE RUN ACTUALLY SEARCHED (tracker issue 134) ──────────────────
704
+ // ── COVERAGE CLAIMS IN PROSE vs WHAT THE RUN ACTUALLY SEARCHED ──────────────────────────────────────
704
705
  //
705
706
  // THE DEFECT. `coverage_line:` is code-stamped from scope-facts.json; the narrative is model-written
706
707
  // prose. Nothing bound them to one searched-territory set. On one recorded run the masthead read
@@ -2354,7 +2355,7 @@ const knockoutSurfaces = (findings) => {
2354
2355
  return { report: report.filter(Boolean).join("\n\n"), working: working.filter(Boolean).join("\n\n") };
2355
2356
  };
2356
2357
 
2357
- // ── PLAIN LANGUAGE ON WHAT A READER SEES FIRST (tracker issue 333) ─────────────────────────────────
2358
+ // ── PLAIN LANGUAGE ON WHAT A READER SEES FIRST ─────────────────────────────────────────────────────
2358
2359
  //
2359
2360
  // The report goes to a lawyer who layers advice on top, and that lawyer's client reads the same page.
2360
2361
  // The band, the summary, the basis line and the one-liners are the whole product for the second reader,
@@ -2362,7 +2363,7 @@ const knockoutSurfaces = (findings) => {
2362
2363
  // comparison", "subsisting European rights", "the confusion comparison meets on every limb".
2363
2364
  //
2364
2365
  // THE PARTITION IS DEFAULT-VISIBLE vs FOLDED, WHICH IS NOT THE report/working SPLIT ABOVE. Since the
2365
- // page folds each card's argument (tracker issue 331 A.3), a finding's `basis` is now behind a click,
2366
+ // page folds each card's argument, a finding's `basis` is now behind a click,
2366
2367
  // and 333 rule 2 allows the lawyer's vocabulary there where a plain word would lose precision. The same
2367
2368
  // is true of the long `assessment`. So this check reads exactly the fields the renderer DRAWS without
2368
2369
  // a click, and reads nothing else — a check whose population is "the report" would flag the very
@@ -2399,22 +2400,39 @@ const knockoutSurfaces = (findings) => {
2399
2400
  /**
2400
2401
  * The fields a reader of the knockout meets before opening anything, named one by one rather than
2401
2402
  * derived, so adding a field to the page is a deliberate addition here too.
2403
+ *
2404
+ * EVERY ENTRY CARRIES A TYPED ADDRESS as well as its human label, and the reviewing pass binds its
2405
+ * rewrites to that address rather than to the label. The label is prose for a person; two marks whose
2406
+ * names differ only in case produce two different labels for one field, and a rewrite matched on prose
2407
+ * would land on whichever the seat quoted. `at` is {field, mark?, index?, ordinal?} and is what the
2408
+ * merge joins on — the knockout has no correction cycle to inherit a join key from, so this lane
2409
+ * defines its own rather than resolving prose after the fact.
2410
+ *
2411
+ * `at.engineOwned` marks the sentences the ENGINE appends, not a seat: the survivor-boundary note and
2412
+ * the skipped-capability note. They stay in the walk because the internal lint should still read
2413
+ * them — an over-long engine caveat is a real defect, for whoever edits the code that emits it. They
2414
+ * are not offered to the reviewing pass: a rewrite there would either break `SURVIVOR_BOUNDARY_RE` at
2415
+ * delivery or append a second copy of the note, and both are silent. `isEngineAppendedCaveat` is the
2416
+ * derivation that already tells them apart; this does not write a second one.
2402
2417
  */
2403
- function knockoutVisibleProse(findings) {
2418
+ export function knockoutVisibleProse(findings) {
2404
2419
  const out = [];
2405
- const add = (where, v) => { const t = String(v ?? "").trim(); if (t) out.push({ where, text: t }); };
2406
- add("the batch summary", findings?.batch?.executiveSummary);
2407
- for (const c of findings?.batch?.standardCaveats ?? []) add("a standing caveat", c);
2420
+ const add = (where, v, at) => { const t = String(v ?? "").trim(); if (t) out.push({ where, text: t, at }); };
2421
+ add("the batch summary", findings?.batch?.executiveSummary, { field: "batch.executiveSummary" });
2422
+ (findings?.batch?.standardCaveats ?? []).forEach((c, i) =>
2423
+ add("a standing caveat", c, { field: "batch.standardCaveats", index: i, engineOwned: isEngineAppendedCaveat(c) }));
2408
2424
  for (const m of findings?.marks ?? []) {
2409
2425
  const n = String(m?.name ?? "a mark");
2410
- add(`${n}'s basis line`, m?.basis);
2411
- for (const f of m?.factors ?? []) add(`${n}'s "why this band" list`, f);
2412
- for (const f of m?.counterFactors ?? []) add(`${n}'s "why not the next band" list`, f);
2413
- add(`${n}'s mitigation line`, m?.mitigation);
2414
- for (const p of m?.purpleNotes ?? []) add(`${n}'s note to the reviewing lawyer`, p?.text ?? p);
2415
- for (const r of m?.registerReads ?? []) add(`${n}'s read of a filing`, r?.read);
2426
+ add(`${n}'s basis line`, m?.basis, { field: "basis", mark: n });
2427
+ (m?.factors ?? []).forEach((f, i) => add(`${n}'s "why this band" list`, f, { field: "factors", mark: n, index: i }));
2428
+ (m?.counterFactors ?? []).forEach((f, i) => add(`${n}'s "why not the next band" list`, f, { field: "counterFactors", mark: n, index: i }));
2429
+ add(`${n}'s mitigation line`, m?.mitigation, { field: "mitigation", mark: n });
2430
+ (m?.purpleNotes ?? []).forEach((p, i) => add(`${n}'s note to the reviewing lawyer`, p?.text ?? p, { field: "purpleNotes", mark: n, index: i }));
2431
+ (m?.registerReads ?? []).forEach((r, i) => add(`${n}'s read of a filing`, r?.read, { field: "registerReads", mark: n, index: i }));
2416
2432
  // The finding's ONE sentence. Its `basis` is folded and is deliberately not read here.
2417
- for (const f of m?.findings ?? []) add(`${n} conflict ${f?.ordinal ?? ""}`.trim(), f?.net);
2433
+ // BOUND BY ORDINAL, NOT BY POSITION: the merged gate re-ranks and renumbers findings on the band,
2434
+ // so an index into this array is a different row after normalisation and the ordinal is not.
2435
+ for (const f of m?.findings ?? []) add(`${n} conflict ${f?.ordinal ?? ""}`.trim(), f?.net, { field: "findings.net", mark: n, ordinal: f?.ordinal });
2418
2436
  }
2419
2437
  return out;
2420
2438
  }
@@ -2453,7 +2471,14 @@ export function plainLanguageChecks({ findings } = {}) {
2453
2471
  const vocab = [];
2454
2472
  const longSentences = [];
2455
2473
  for (const { where, text } of fields) {
2456
- for (const [term] of PLAIN_FORMS) if (termMatcher(term).test(text)) vocab.push(`${where}: "${term}"`);
2474
+ // THE REPLACEMENT TRAVELS WITH THE FLAG. Naming the term and pointing at a document for the swap is
2475
+ // the half the doctrine calls load-bearing, left out — and it is the half a pass that REWRITES the
2476
+ // line needs in hand. The pinned source already carries a worked plain form for every term; there
2477
+ // was no reason for this flag to name only the fault.
2478
+ for (const [term, plain] of PLAIN_FORMS) {
2479
+ if (!termMatcher(term).test(text)) continue;
2480
+ vocab.push(plain ? `${where}: "${term}" → "${plain}"` : `${where}: "${term}" (an engine word — cut it)`);
2481
+ }
2457
2482
  for (const sentence of text.split(/(?<=[.!?])\s+|\n+/)) {
2458
2483
  const n = sentence.trim().split(/\s+/).filter(Boolean).length;
2459
2484
  if (n > SENTENCE_WORD_LIMIT) longSentences.push(`${where}: ${n} words`);
@@ -2487,7 +2512,7 @@ export function plainLanguageChecks({ findings } = {}) {
2487
2512
  check("reviewer-note-subject", "voice", surface, misfiled.length === 0,
2488
2513
  misfiled.length ? `a note about what was asked that never names the request — it will print under this name's conflicts rather than at the top of the page, where a question about the request belongs. Name the request in the note: ${say(misfiled)}` : ""),
2489
2514
  check("plain-language-vocabulary", "voice", surface, vocab.length === 0,
2490
- vocab.length ? `the lawyer's vocabulary on lines a reader meets before opening anything — rewrite the line in the words the reader already owns (the skill carries the swaps): ${say(vocab)}` : ""),
2515
+ vocab.length ? `the lawyer's vocabulary on lines a reader meets before opening anything — rewrite the sentence in the words the reader already owns, do not swap the word: ${say(vocab)}` : ""),
2491
2516
  check("plain-language-sentence-length", "voice", surface, longSentences.length === 0,
2492
2517
  longSentences.length ? `a default-visible sentence carrying more than one idea (over ${SENTENCE_WORD_LIMIT} words) — split it, conclusion first: ${say(longSentences)}` : ""),
2493
2518
  ];
@@ -2569,7 +2594,7 @@ export function runKnockoutLint({ findings }) {
2569
2594
  // (publish/report-registry.mjs re-renders archived findings without re-running the merged validator)
2570
2595
  // this scan is the only permission-prose coverage there is.
2571
2596
  if (working.trim()) checks.push(...permissionProseChecks({ text: working, surface: "findings", idSuffix: ":knockout-working", structural: true, cards: false }));
2572
- // tracker issue 333 — the plain-language reviewer, over the fields a reader meets before opening a
2597
+ // the plain-language reviewer, over the fields a reader meets before opening a
2573
2598
  // fold. Internal by surface, so a hit reaches whoever is fixing the run and never a delivery surface.
2574
2599
  checks.push(...plainLanguageChecks({ findings }));
2575
2600
  const failures = checks.filter((c) => !c.pass);
@@ -2714,7 +2739,7 @@ export function deliveryFlagLines(failures) {
2714
2739
  const id = String(f?.id ?? "");
2715
2740
  const base = id.split(":")[0];
2716
2741
  const family = String(f?.family ?? "");
2717
- // A CHECK WHOSE DISTINCTION LIVES IN ITS SUFFIX COULD NOT BE SAID HERE (tracker issue 267).
2742
+ // A CHECK WHOSE DISTINCTION LIVES IN ITS SUFFIX COULD NOT BE SAID HERE.
2718
2743
  //
2719
2744
  // Grouping on `base` alone is right for the common case — a word-cap violation on nine write-ups is
2720
2745
  // one delivery line, not nine. But it also collapsed `narrative-write-ups:could-not-read` into the
@@ -54,7 +54,7 @@ import { leversFromResolved, turnaround, turnaroundHours } from "./effort-model.
54
54
  * multi-country-focus-search 2.5h 1.5–2.5 hours
55
55
  * full-country-search 2.5h 1.5–2.5 hours
56
56
  *
57
- * ALL THREE CLEARANCES ARE IDENTICAL HERE and that is the owner ruling, not a flattening to fix.
57
+ * ALL THREE CLEARANCES ARE IDENTICAL HERE and that is the ruling, not a flattening to fix.
58
58
  * The table above used to separate them with lane adders (+0.5 case law, +0.5 native, +0.5 single
59
59
  * territory). Eight delivered runs refuted that: the full-country run — the only one carrying every lane,
60
60
  * quoted 3.0h — came in at 2.33h, SHORTER than five of the other seven, which carried fewer lanes and were
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
3
  // products.mjs — THE OFFERING. Which of the four products a request is, and what that product carries.
4
4
  //
5
- // Owner ruling, 2026-08-06. There are four products and a client buys one of them:
5
+ // Ruling, 2026-08-06. There are four products and a client buys one of them:
6
6
  //
7
7
  // Knockout search worldwide or a chosen set; up to 8 names; no case law; no native language
8
8
  // Global preliminary search WORLDWIDE, nothing else; no case law; no native language
@@ -261,7 +261,7 @@ function collect() {
261
261
  // (set outside this page) must survive a save round-trip instead of being silently rewritten
262
262
  const delivery = { ...((current.profile && current.profile.delivery) || {}), email: $("f_email").value };
263
263
  // THE MARKING IS THREE-STATE ON THE WIRE and this control has two live options, so the default one
264
- // writes ABSENCE and never `false` (tracker issue 1991). It was a checkbox, which rendered absent and
264
+ // writes ABSENCE and never `false`. It was a checkbox, which rendered absent and
265
265
  // `false` identically and wrote a boolean on every save — so a customer who had given no instruction
266
266
  // was recorded as "strip the line" the first time anybody pressed Save on an unrelated field, and
267
267
  // their next report shipped with no confidentiality line. That is the incident this door has already had.
@@ -379,18 +379,43 @@ async function submit(write) {
379
379
  }
380
380
  showMsg("", write ? "Saving…" : "Checking…");
381
381
  const action = write ? "save" : "validate";
382
- const r = await jpost(`${API}${encodeURIComponent(key)}/${action}`, { profile, contextPack });
382
+ // CREATING IS ITS OWN ROUTE, and this is the whole of the defect it fixes. `save` is an upsert: its
383
+ // preserve step takes the on-disk value of every code-owned field and DELETES the field when there is
384
+ // nothing on disk. A customer being created has nothing on disk, so creating through `save` wrote no
385
+ // risk framework at all, silently, and every matter for that customer was rated under the general
386
+ // default from then on with nothing on any screen saying so.
387
+ //
388
+ // The create route asks the same questions the command line asks, always sets the framework, and
389
+ // refuses a colliding email domain before writing rather than leaving the next start unable to
390
+ // resolve ANY customer. Its body is FLAT; `save` takes a nested profile.
391
+ const r = (write && current.isNew)
392
+ ? await jpost(API.replace(/\/$/, ""), {
393
+ name: profile.name, key,
394
+ industry: profile.industry,
395
+ matchDomains: profile.matchDomains,
396
+ platforms: profile.platforms,
397
+ })
398
+ : await jpost(`${API}${encodeURIComponent(key)}/${action}`, { profile, contextPack });
383
399
  if (!write) {
384
400
  if (r.json.ok) showMsg("ok", r.json.isNew ? "Looks good — ready to create this customer." : "Looks good — ready to save.");
385
401
  else showServerErrors("Almost there — please fix:", r.json.errors);
386
402
  return;
387
403
  }
388
- if (r.status === 200 && r.json.written) {
389
- showMsg("ok", `Saved — ${r.json.created?"created":"updated"} ${key} · commit ${String(r.json.commit||"").slice(0,9)}`);
404
+ if ((r.status === 200 || r.status === 201) && r.json.written) {
405
+ // 201 from the create route, 200 from a save. Testing only for 200 would report a successful
406
+ // creation as a failure, which is the one answer that sends somebody to make the customer twice.
407
+ const fw = r.json.framework
408
+ ? ` · ${r.json.framework.defaulted ? "general default rating" : "its own rating framework"}`
409
+ : "";
410
+ showMsg("ok", `Saved — ${r.json.created?"created":"updated"} ${key}${fw} · commit ${String(r.json.commit||"").slice(0,9)}`);
390
411
  await loadRoster(key);
391
412
  if (current.isNew) openCustomer(key);
392
413
  } else if (r.json.error === "validation_failed") {
393
414
  showServerErrors("Couldn’t save — please fix:", r.json.errors);
415
+ } else if (r.json.message) {
416
+ // The create route names its refusals in a sentence and puts a machine token beside it. Showing
417
+ // the token is showing engine vocabulary to a lawyer.
418
+ showMsg("err", r.json.message);
394
419
  } else {
395
420
  showMsg("err", `Couldn’t save (${r.status}). ${r.json.error||"Please try again."}`);
396
421
  }
@@ -492,7 +517,7 @@ function collectProject() {
492
517
  // delivery is merged as a WHOLE field (project → customer): overriding it here intentionally replaces the
493
518
  // customer's delivery, so a project's delivery carries only email + privileged (style/template are the
494
519
  // customer's presentation defaults; a project that needs its own sets them via a code change, like the framework).
495
- // Same three-state rule as the customer form (tracker issue 1991): the default option writes NO key, so
520
+ // Same three-state rule as the customer form: the default option writes NO key, so
496
521
  // an overlay never invents "strip the marking" for a project that gave no instruction.
497
522
  const email = $("p_email").value; if (email) { o.delivery = { email }; if ($("p_priv").value === "no") o.delivery.privileged = false; }
498
523
  const app = ($("p_appetite").value||"").trim(); if (app) o.riskAppetite = app;