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
@@ -13,7 +13,7 @@
13
13
  // POST /portal/login rather than in the SPA (CI greps the built bundle for internal
14
14
  // POST /portal/logout variable names, and a login screen has to explain configuration).
15
15
  // Absent on a Cloudflare-fronted instance, where the edge is the door.
16
- // GET /portal/api/me — { role, email, accounts }
16
+ // GET /portal/api/me — { email, permissions, access, organisations, accounts, … }
17
17
  // GET /portal/api/searches[?account=] — registry levels + the account's saved searches
18
18
  // POST /portal/api/run/plan — the CONFIRMATION GATE: validate + resolve + honest summary +
19
19
  // short-TTL HMAC confirmationToken. Nothing spends yet.
@@ -41,14 +41,46 @@ import { clientFailureNote } from "../shared/client-failure-note.mjs"; // —
41
41
  import { bareInvocation, invocationPrefix, installRoute } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
42
42
  import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
43
43
  import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
44
- // — the portal became an ISSUANCE PATH here, deliberately and by owner ruling.
44
+
45
+ /**
46
+ * The ops credential for one trigger call: the boot token's cap, re-taken against the roster now.
47
+ *
48
+ * Separated from the call site and exported because the service's `trigger` is injected in tests, so
49
+ * logic left inside it can only ever be asserted by reading the source — and the case that matters is
50
+ * the one where something fails.
51
+ *
52
+ * BOTH FALLBACKS HAND BACK THE BOOT TOKEN, never an uncapped one. An empty roster is the single case
53
+ * the boot mint reads as "do not cap", so re-deriving it here would turn an unreadable store into a
54
+ * token good for every account. A mint that throws — an unset secret — lands the same way. The failure
55
+ * direction is the whole point: a stale cap refuses the newest company, which is the bug being fixed;
56
+ * a widened cap admits every company, which is a larger and quieter one.
57
+ */
58
+ export function opsTokenFor({ bootToken, roster, mint }) {
59
+ if (!Array.isArray(roster) || !roster.length) return bootToken;
60
+ try {
61
+ return mint({ scope: "ops", sub: "portal", verbs: ["start_run", "stop_run"],
62
+ accounts: roster, ttlSec: 300 });
63
+ } catch (e) {
64
+ // THE FALLBACK DIRECTION IS RIGHT AND ITS SILENCE WAS NOT. Handing back the boot credential is
65
+ // correct — it is narrower than the one that failed to mint, never wider — but a bare catch here
66
+ // restores the exact refusal this lane exists to remove, for a company created after boot, with
67
+ // nothing anywhere naming the cause. An unset signing secret and an unreadable store both land here
68
+ // and both look like the feature simply not working.
69
+ console.error(`[portal] could not re-mint the engine credential (${String(e?.message ?? e)}) — `
70
+ + `falling back to the one minted at boot, which does not cover companies created since`);
71
+ return bootToken;
72
+ }
73
+ }
74
+
75
+ // — the portal became an ISSUANCE PATH here, deliberately and by ruling.
45
76
  // A comment further down this file said "the portal cannot mint from here … this process deliberately
46
77
  // holds no engine/MCP secrets — issuance is one path on purpose". MEASURED 2026-08-31: that wall is not
47
78
  // built. `bin/start.mjs` generates TRADEMARK_MCP_TOKEN_SECRET into `~/.env`, the portal unit loads
48
79
  // `EnvironmentFile=%h/.env`, and `childEnv` passes the same value to the portal child — so this process
49
80
  // has held the signing secret on both start paths for as long as both have existed. The comment has been
50
81
  // corrected in place rather than left to be trusted.
51
- import { mintToken, accountsForEmail, loadGrants } from "../shared/scope.mjs";
82
+ import { mintToken, loadGrants, resolvePerson } from "../shared/scope.mjs";
83
+ import { withPerson, withCompany } from "../shared/grants-edit.mjs";
52
84
  import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, decided once
53
85
  import { fileURLToPath } from "node:url";
54
86
 
@@ -73,7 +105,8 @@ const READ_OFF_NOTE = "Reading a brief is not available on this instance — set
73
105
  import { basename, dirname, join, resolve as pathResolve } from "node:path";
74
106
  import { driverDir } from "../shared/driver-dir.mjs"; //
75
107
  import { createHmac, timingSafeEqual } from "node:crypto";
76
- import { makePrincipal, assertPrincipal, PortalDeny } from "./portal-access.mjs";
108
+ import { makePrincipal, assertPrincipal, genericOrgOf, mayReadRun, reachCovers, principalView, seesEverything, mayRun,
109
+ PortalDeny } from "./portal-access.mjs";
77
110
  // — the THIRD identity source (after the CF Access edge and, until, the deleted bypass). It
78
111
  // decides nothing about access: it turns a passphrase into an email string, which then goes through
79
112
  // makePrincipal and assertPrincipal exactly as a Cloudflare-verified address does.
@@ -92,7 +125,7 @@ import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor
92
125
  import { isDemo, demoPostureLine } from "./demo-posture.mjs";
93
126
  import { triggerCapGap, triggerCapWarning } from "./trigger-cap.mjs"; // F51 — one answer, three surfaces
94
127
  import { makeUpstream } from "./portal-upstream.mjs";
95
- import { flagView, accessView, observedView, authView, staffRuleSource } from "./portal-config-view.mjs";
128
+ import { flagView, accessView, observedView, authView } from "./portal-config-view.mjs";
96
129
  import { livePosture } from "./flag-snapshot.mjs"; // — for the capture-vs-box comparison only, never for a value
97
130
  import { familiesView, groupRuns, ungroupRuns } from "./portal-families.mjs";
98
131
  import { validateJob } from "./enqueue-schema.mjs";
@@ -108,12 +141,12 @@ import { engineCommit } from "./engine-build.mjs"; // — the S
108
141
  import { engineCommitDate, engineProvenance } from "./engine-build.mjs";
109
142
  import { classifySkillsStore } from "./skills-store-provenance.mjs";
110
143
  import { makeStaticHandler, reportCsp, docCsp } from "./portal-static.mjs";
111
- import { bundleVerdict, healthUi } from "../shared/bundle-freshness.mjs"; // one definition of a usable bundle, shared with `doctor` (tracker issue 160)
144
+ import { bundleVerdict, healthUi } from "../shared/bundle-freshness.mjs"; // one definition of a usable bundle, shared with `doctor`
112
145
  import { readReport, reportsOf, resolveReportFile, batchSummaryOf } from "./portal-report.mjs";
113
146
  import { readArchivedSet, updateArchived } from "./publish/archive-tags.mjs";
114
147
  import { readAcks, setAck, withAcks, ACKNOWLEDGEABLE } from "./portal-acks.mjs";
115
148
  import { MAX_BRIEF, makeReadBudget } from "./compose-read.mjs";
116
- import { BRAND, PALETTE, FONT_LINK, FAVICON_LINK, bracketMark, DOOR_ROOT, DOOR_ROOT_DARK, DOOR_THEME_INIT } from "../shared/brand.mjs";
149
+ import { BRAND, ORGANISATION_NAME, PALETTE, FONT_LINK, FAVICON_LINK, bracketMark, DOOR_ROOT, DOOR_ROOT_DARK, DOOR_THEME_INIT } from "../shared/brand.mjs";
117
150
  import { envFrom, pinEnv } from "../shared/env-aliases.mjs"; // — a refusal names the name in force
118
151
  import { accessAudience, audienceLabel } from "../shared/access-audience.mjs"; // — F54; jose-free on purpose
119
152
  import { resolveNumericSetting } from "./numeric-setting.mjs"; // — the same table the engine enforces, without the throw a rendering surface must not take
@@ -221,14 +254,21 @@ const productNameOf = (product) => (typeof product === "string" ? reportIdentity
221
254
  // already fetch one key at a time, so it widens no boundary; it just spares the browser one request per
222
255
  // brand owner against a 120/min limit. It is NOT a wildcard: an empty array matches nothing, which is
223
256
  // the safe direction, and `null` (every account) stays reachable only from scanAllRuns.
224
- export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, includeRetired = false,
257
+ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, generic = null, includeRetired = false,
225
258
  // — THE QUEUES THE RUNNER ACTUALLY DRAINS, which is not the same set as the
226
259
  // directories under `workspaceRoot`. Defaults to [] so a caller that passes none keeps exactly the
227
260
  // behaviour it had; the service passes `config.queueDirs`, the same getter the allowance counter reads.
228
261
  queueDirs = [] }) {
229
262
  const out = [];
230
263
  const only = Array.isArray(account) ? new Set(account) : null;
231
- const mine = (owner) => (only ? only.has(owner) : account === null || owner === account);
264
+ // GENERIC IS MATCHED BY ORGANISATION when the caller says which. `generic` is `{ all: true }` or
265
+ // `{ orgs, unfiled }`: a Generic run matches when it was filed under one of `orgs`, or was filed under
266
+ // none and `unfiled` asks for those too. Without it, `account` alone decides, exactly as before.
267
+ const mine = (owner, organisation = null) => {
268
+ if (owner === "generic" && generic) return generic.all === true
269
+ || (organisation != null ? (generic.orgs ?? []).includes(organisation) : generic.unfiled === true);
270
+ return only ? only.has(owner) : account === null || owner === account;
271
+ };
232
272
  // RETIRED RUNS ARE NOT LISTED HERE — unless the caller is the staff retired view, and asks.
233
273
  //
234
274
  // `pool-admin archive` has always written this sidecar, and until now the ONLY reader was the old
@@ -261,7 +301,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
261
301
  try {
262
302
  const meta = JSON.parse(readFileSync(metaPath, "utf8"));
263
303
  const owner = meta.customerKey || "generic";
264
- if (!mine(owner)) continue;
304
+ if (!mine(owner, meta.organisation ?? null)) continue;
265
305
  // Tagged by runId — the same key pool-admin writes, which is the pool DIRECTORY name. Checking
266
306
  // both guards the one case where they differ: a meta whose runId was rewritten by a republish.
267
307
  const isRetired = retired.has(meta.runId ?? name) || retired.has(name);
@@ -281,7 +321,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
281
321
  const hasReport = docs.length > 0;
282
322
  const { bands, toneFor } = ladderOf(meta);
283
323
  const band = meta.overall ?? meta.verdict ?? null;
284
- out.push({ runId: meta.runId ?? name, account: owner,
324
+ out.push({ runId: meta.runId ?? name, account: owner, ...(owner === "generic" ? { organisation: meta.organisation ?? null } : {}),
285
325
  title: meta.title ?? meta.matter ?? name, kind: meta.kind ?? "clearance",
286
326
  // THE MARK, separate from the report's headline.
287
327
  //
@@ -382,7 +422,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
382
422
  const s = JSON.parse(readFileSync(join(dir, "status.json"), "utf8"));
383
423
  const p = JSON.parse(readFileSync(driverDir(dir, "profile.json"), "utf8"));
384
424
  const owner = p.profileKey ?? p.key ?? "generic";
385
- if (!mine(owner)) return;
425
+ if (!mine(owner, p.organisation ?? null)) return;
386
426
  if (s.state === "delivered") return; // the pool row is the delivered face
387
427
  // ── — RETIREMENT REACHES A RUN THAT NEVER PUBLISHED ──────────────────
388
428
  //
@@ -419,7 +459,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
419
459
  // zombie face this state exists to end. pausedKind "operator" tells the UI which words to use.
420
460
  const paused = s.state === "postponed" || s.state === "recovering" || s.state === "parked-for-human";
421
461
  out.push({ ...(liveRetired ? { retired: true } : {}),
422
- runId: s.runId, account: owner, title: s.markName ?? s.slug, kind: s.lane === "knockout" ? "knockout-batch" : "clearance",
462
+ runId: s.runId, account: owner, ...(owner === "generic" ? { organisation: p.organisation ?? null } : {}), title: s.markName ?? s.slug, kind: s.lane === "knockout" ? "knockout-batch" : "clearance",
423
463
  markName: typeof s.markName === "string" ? s.markName : null,
424
464
  // The project, straight off the frozen sidecar this branch already reads as `p`. A LIVE run needs
425
465
  // no publish stamp and no back-fill — the sidecar is right there, and freezeProfile has written
@@ -444,6 +484,11 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
444
484
  // preserved by writeRunStatus's spread-merge, replaced by the terminal when the honour check
445
485
  // fires. The UI derives "Stopping…" from this beside a non-terminal state.
446
486
  stopRequestedAt: typeof s.stopRequestedAt === "string" ? s.stopRequestedAt : null,
487
+ // — whether a stop can still prevent delivery. FALSE only once a lane has written it, at the
488
+ // moment it commits to publishing. Absent means the run has not reached that point, which is
489
+ // the truthful reading on this build: both lanes write it immediately after their last cancel
490
+ // read. The screen shows its promise on true and withdraws it on false.
491
+ stoppable: s.stoppable === false ? false : true,
447
492
  // A failed run with no reason on screen is a run the user has to phone somebody about. Both
448
493
  // fields are already written by every failure path (pipeline-knockout.mjs, the driver's
449
494
  // writeRunStatus); the listing simply used to drop them.
@@ -533,12 +578,12 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, inclu
533
578
  try {
534
579
  const j = JSON.parse(readFileSync(join(dir, f), "utf8"));
535
580
  const owner = j.profileKey ?? "generic";
536
- if (!mine(owner)) continue;
581
+ if (!mine(owner, j.tenant ?? null)) continue;
537
582
  // ALL the names, spelled the way the run's own status.json and its delivered meta.json spell
538
583
  // them — this took `marks[0].name` and named ONE mark of N, so a client who had just ordered a
539
584
  // three-name knockout screen saw a single name on the row that tells them it went in.
540
585
  const mark = batchMarkName(j.marks, typeof j.markName === "string" && j.markName ? j.markName : undefined);
541
- queuedRows.push({ lane: dir, laneIdx, row: { runId: j.id ?? f.replace(/\.json$/, ""), account: owner, title: mark ?? (j.id ?? "Queued"),
586
+ queuedRows.push({ lane: dir, laneIdx, row: { runId: j.id ?? f.replace(/\.json$/, ""), account: owner, ...(owner === "generic" ? { organisation: j.tenant ?? null } : {}), title: mark ?? (j.id ?? "Queued"),
542
587
  // THE JOB'S OWN PIPELINE, not the literal "clearance". The row already carried
543
588
  // `product: "knockout-search"` and still called itself a clearance, so it was the one row in
544
589
  // the listing that contradicted its own product field — and Result.tsx gates the names line on
@@ -665,10 +710,10 @@ export const concurrentRunsCap = () => {
665
710
  };
666
711
 
667
712
  function forRole(runs, principal) {
668
- // Staff see failure reasons verbatim; clients get the plain note. That redaction is the ONLY
713
+ // A person who sees everything reads failure reasons verbatim; everyone else gets the plain note. That redaction is the ONLY
669
714
  // role-shaping left: the held-run suppression is retired (one report, spec 2026-07-30 §5 — a run you
670
715
  // have rights to is always listed; the machine-QC record lives on the audit workbook).
671
- if (principal?.role === "staff") return runs;
716
+ if (seesEverything(principal)) return runs;
672
717
  return runs.map((r) => {
673
718
  let out = r;
674
719
  // — the redaction must take `reasonDetail` with it. `reason` is replaced by a fixed note for a
@@ -799,6 +844,8 @@ export const PORTAL_JOB_FIELDS = Object.freeze({
799
844
  carries: Object.freeze([
800
845
  "id", "profileKey", "forwarder", "forwarderEmail",
801
846
  "markName", "marks", "classes", "goods", "ref", "projectKey",
847
+ // which organisation's Generic, resolved by jobFor from the verified principal — see `stamped`
848
+ "tenant",
802
849
  "jurisdictions", "platforms", "geography",
803
850
  "product", "recipeKey", "nativeLanguage", "caseLaw", "searchLevel", "deliveryRoute",
804
851
  "upfrontInstructions", "commercialFlexibility", "priorUse", "campaignShape", "deadline",
@@ -809,7 +856,7 @@ export const PORTAL_JOB_FIELDS = Object.freeze({
809
856
  ]),
810
857
  // The subset of `carries` whose value is the DOOR's, not the requester's. A body value for one of
811
858
  // these is ignored on purpose, so the guard drives them with a lie and requires the lie to lose.
812
- stamped: Object.freeze(["id", "profileKey", "forwarder", "forwarderEmail", "clientPrincipal"]),
859
+ stamped: Object.freeze(["id", "profileKey", "forwarder", "forwarderEmail", "clientPrincipal", "tenant"]),
813
860
  notCarried: Object.freeze({
814
861
  // — A CLIENT MAY NEVER DECLARE A RUN A DEMO. The banner it produces says the report
815
862
  // is fiction, and a field the requester controls that can mark their own report fiction — or, arriving
@@ -820,7 +867,7 @@ export const PORTAL_JOB_FIELDS = Object.freeze({
820
867
  registerFixtures: "a run that reads canned register payloads instead of calling a register. A CLIENT may "
821
868
  + "never ask for that: the result would be fiction wearing a real report's clothes, which is the failure "
822
869
  + "the demo marker exists to prevent one level up. Refused by omission here, and only ever set by a job "
823
- + "file somebody wrote deliberately (tracker issue 2038).",
870
+ + "file somebody wrote deliberately.",
824
871
  promptParts: "the requester's declaration that the prose rides as SIDECAR files (<base>.brief.md, …), "
825
872
  + "which exists because the hand-emitting email-loop agent can only `write` files. The portal composer "
826
873
  + "sends structured fields and writes no sidecars, so a job it built is never in that shape — carrying "
@@ -1028,12 +1075,16 @@ function outcomeRow({ event = "request-refused", method, path, email = null, sta
1028
1075
 
1029
1076
  export function makePortalService({
1030
1077
  poolRoot, workspaceRoot, recipesDir = undefined, secret,
1031
- staffDomains = [], grants = null,
1032
- // Where the staff-domain rule is written, for the People & access page to name. INJECTED, because
1033
- // the answer is a fact about the PROCESS — which file, if any, it took its configuration from — and
1034
- // this constructor is deliberately pure over its inputs. Null means "no rule, or could not tell",
1035
- // and the page then says nothing rather than guessing at a path.
1036
- staffRule = null,
1078
+ grants = null,
1079
+ // WHETHER THIS INSTALL SIGNS PEOPLE IN LOCALLY — one address and one passphrase, which cannot hold a
1080
+ // second person. The People page reads it to disable Add and say why. Injected, like every fact about
1081
+ // the process, because this constructor is pure over its inputs.
1082
+ localSignIn = false,
1083
+ // How the People page and company creation write the grants file. INJECTED so the service stays pure
1084
+ // over its inputs and an arm can watch the write; boot hands it an atomic write to
1085
+ // CLEAROTRON_ACCESS_FILE. Null means this service cannot write the file, and the routes that would
1086
+ // need to say so rather than pretend.
1087
+ writeGrants = null,
1037
1088
  // The queue directories the RUNNER drains — the same list it hands checkRunCaps. The allowance counter
1038
1089
  // and the quota pre-check read their ledger beside these, so they count what the wall counts (:
1039
1090
  // they used to reconstruct a workspace-relative path that resolved to nothing once the queue moved out
@@ -1164,7 +1215,7 @@ export function makePortalService({
1164
1215
  //
1165
1216
  // The owner, on his own install with a partial register: "i cannot press the button for Global
1166
1217
  // clearotron search. Why. it doesnt appear disabled, no message etc — but i cant select it." The
1167
- // product is orderable now (owner ruling on that issue), and what the register does not reach
1218
+ // product is orderable now (ruling on that issue), and what the register does not reach
1168
1219
  // is a SENTENCE on a live row rather than the reason a dead one cannot be pressed.
1169
1220
  //
1170
1221
  // A SEPARATE FIELD, because the two say opposite things to the screen: `unavailableNote`
@@ -1253,7 +1304,7 @@ export function makePortalService({
1253
1304
  // reported back as conflicts. On this door the applicant is already known: the account resolved it,
1254
1305
  // and the profile behind it is staff-curated. Letting a body re-state it would let a client widen or
1255
1306
  // silence their own exclusion, which is a rating-authority change wearing a scope field's clothes.
1256
- const jobFor = ({ principal, account, body }) => {
1307
+ const jobFor = ({ principal, account, tenant = null, body }) => {
1257
1308
  const marks = Array.isArray(body.marks)
1258
1309
  ? body.marks.map((m) => (typeof m === "string" ? { name: m } : m)).filter((m) => m?.name && String(m.name).trim())
1259
1310
  : undefined;
@@ -1261,6 +1312,11 @@ export function makePortalService({
1261
1312
  const job = {
1262
1313
  id: `portal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
1263
1314
  profileKey: account,
1315
+ // WHICH ORGANISATION'S GENERIC — the pair's other half, on a Generic run only, and resolved from the
1316
+ // principal rather than read off the body: `genericOrgOf` has already refused an organisation this
1317
+ // person does not see. Absent means filed under none, which only a person who sees everything can
1318
+ // order (portal-access.mjs).
1319
+ ...(account === "generic" && genericOrgOf(principal, tenant) != null ? { tenant: genericOrgOf(principal, tenant) } : {}),
1264
1320
  forwarder: "portal", forwarderEmail: principal.email,
1265
1321
  markName: body.markName != null ? String(body.markName) : marks?.[0]?.name,
1266
1322
  marks, classes: Array.isArray(body.classes) ? body.classes.map(Number).filter(Number.isFinite) : undefined,
@@ -1376,7 +1432,8 @@ export function makePortalService({
1376
1432
  }
1377
1433
 
1378
1434
  async function route(method, path, identity, body = {}, query = {}) {
1379
- const principal = makePrincipal({ email: identity?.email, grants: grantsNow(), staffDomains });
1435
+ const grantsHere = grantsNow();
1436
+ const principal = makePrincipal({ email: identity?.email, grants: grantsHere });
1380
1437
  const parts = path.replace(/\/+$/, "").split("/").filter(Boolean); // ["portal", ...]
1381
1438
  if (parts[0] !== "portal") return { status: 404, json: { error: "not_found" } };
1382
1439
  try {
@@ -1395,18 +1452,35 @@ export function makePortalService({
1395
1452
  // Cordillera's customer list on the one route every signed-in identity can reach — the exact
1396
1453
  // leak /profiles is never proxied for (portal-upstream: "a client reaching it would learn the
1397
1454
  // customer base"). `"*"` identities get {} and keep using the roster, which is staff-gated.
1455
+ //
1456
+ // `accountFacts` rides in the SAME loop, under the SAME scoping decision, for the same reason
1457
+ // the names do: the pick panel shows industry, marketplace count and territories per company,
1458
+ // and a client with several grants meets that panel. One loop, so there is one answer to
1459
+ // "which accounts may this identity learn about" rather than two that can drift apart. A `"*"`
1460
+ // identity gets {} here exactly as it does for names and keeps reading the staff-only roster.
1398
1461
  let accountNames = {};
1399
- if (Array.isArray(principal.accounts) && principal.accounts.length) {
1462
+ let accountFacts = {};
1463
+ // GENERIC IS NAMED TOO when the person sees an organisation's Generic. It is not a company, so it is
1464
+ // never in `accounts`, and without a name here the switcher printed the raw key at everyone below
1465
+ // everything. An organisation with no company yet is the case with an empty `accounts`, so the
1466
+ // guard is on the names to look up, not on the company list.
1467
+ const named = Array.isArray(principal.accounts)
1468
+ ? [...principal.accounts, ...(principal.genericOrgs?.length ? ["generic"] : [])] : [];
1469
+ if (named.length) {
1400
1470
  try {
1471
+ const { companyFactsOf } = await import("./profiles.mjs");
1401
1472
  const profiles = await loadProfilesImpl();
1402
- for (const key of principal.accounts) {
1403
- const name = profiles.get(key)?.name;
1473
+ for (const key of named) {
1474
+ const profile = profiles.get(key);
1475
+ const name = profile?.name;
1404
1476
  if (typeof name === "string" && name) accountNames[key] = name;
1477
+ if (profile) accountFacts[key] = companyFactsOf(profile);
1405
1478
  }
1406
1479
  } catch {
1407
1480
  // A name is a nicety; the door is not. An unreadable profile store must not lock a user
1408
1481
  // out of the portal, so this degrades to the keys the UI already falls back to.
1409
1482
  accountNames = {};
1483
+ accountFacts = {};
1410
1484
  }
1411
1485
  }
1412
1486
  // HOW MANY RUNS THIS DEPLOYMENT EXECUTES AT ONCE. Deployment-wide, not account-scoped, which is
@@ -1434,8 +1508,9 @@ export function makePortalService({
1434
1508
  // READ ONCE. The payload names it and the program reading below is gated on it; two calls to
1435
1509
  // `flagView` here would be two reads of the same file that could disagree with each other.
1436
1510
  const meEngineMode = flagView(poolRoot).engineMode;
1437
- return { status: 200, json: { role: principal.role, email: principal.email, accounts: principal.accounts, accountNames,
1438
- concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode: meEngineMode,
1511
+ return { status: 200, json: { email: principal.email, ...principalView(principal, grantsHere, accountNames),
1512
+ accounts: principal.accounts, accountNames, accountFacts,
1513
+ concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode,
1439
1514
  // WHETHER THE PROGRAM IS ON THIS BOX WHILE THE ENGINE CANNOT SEE IT — true, false, or null
1440
1515
  // for "this could not be checked". The screen above renders one of three remedies from it,
1441
1516
  // and they are different remedies: install the CLI, restart the service that cannot see it,
@@ -1451,7 +1526,7 @@ export function makePortalService({
1451
1526
  // — a button that always fails must not render as available. The reason is
1452
1527
  // operator-shaped and staff-only; a client reads the generic sentence the button carries.
1453
1528
  controls: { stop: { available: stopControl.available !== false,
1454
- reason: principal.role === "staff" ? (stopControl.reason ?? null) : null } } } };
1529
+ reason: seesEverything(principal) ? (stopControl.reason ?? null) : null } } } };
1455
1530
  }
1456
1531
  // /portal/api/about — the AGPL §13 source offer ()
1457
1532
  //
@@ -1471,7 +1546,7 @@ export function makePortalService({
1471
1546
  // /portal/api/searches
1472
1547
  if (parts[1] === "api" && parts[2] === "searches" && method === "GET") {
1473
1548
  const account = assertPrincipal(principal, { account: query.account ?? null });
1474
- if (!account) return { status: 400, json: { error: "name an account (?account=) — staff must pick who they act for" } };
1549
+ if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
1475
1550
  return { status: 200, json: { account, ...searchesFor(account) } };
1476
1551
  }
1477
1552
  // /portal/api/compose/read — turn a pasted brief into a filled-in composer.
@@ -1616,7 +1691,7 @@ export function makePortalService({
1616
1691
  * later. The two can disagree by a run under concurrency; the wall is the one that decides, and
1617
1692
  * it refuses by CLARIFYING rather than dropping, so nothing is ever lost to the gap.
1618
1693
  *
1619
- * Staff are never checked: role is decided by the principal, here, where it is authoritative.
1694
+ * A person who sees everything is never checked; the cap binds everyone else, decided here from the principal.
1620
1695
  */
1621
1696
  // WHICH PRODUCT IS THIS, AND WHERE WOULD IT POINT — asked ONCE, and everything the plan says
1622
1697
  // derives from that one answer: the availability gate, the name, the scope shown at review, and the
@@ -1639,12 +1714,17 @@ export function makePortalService({
1639
1714
  try { return quoteForJob({ job, profile, searchPolicy: resolved }); } catch { return null; }
1640
1715
  };
1641
1716
 
1642
- const quotaRefusal = async (account) => {
1643
- if (principal.role !== "client" || !upstream) return null;
1717
+ const quotaRefusal = async (account, organisation = null) => {
1718
+ if (seesEverything(principal) || !upstream) return null;
1644
1719
  let caps = null, capsRead = false;
1645
1720
  try {
1646
- const r = await upstream.getProfile(principal, account);
1647
- if (r.status === 200) { caps = r.json?.readOnly?.runCaps ?? null; capsRead = true; }
1721
+ // Generic's caps are the neutral profile's — one file, every organisation's lane — so they are
1722
+ // read from the store rather than through the settings wall, which answers per company.
1723
+ if (account === "generic") { caps = (await loadProfilesImpl()).get("generic")?.runCaps ?? null; capsRead = true; }
1724
+ else {
1725
+ const r = await upstream.getProfile(principal, account);
1726
+ if (r.status === 200) { caps = r.json?.readOnly?.runCaps ?? null; capsRead = true; }
1727
+ }
1648
1728
  } catch { return null; } // cannot read the cap ⇒ do not invent one; the wall still holds
1649
1729
  // The same DEFAULT the wall applies (runner.mjs DEFAULT_CLIENT_DAILY_RUNS): a profile READ
1650
1730
  // successfully with no dailyRuns is capped, not uncapped. Mirrored rather than imported because
@@ -1654,7 +1734,7 @@ export function makePortalService({
1654
1734
  if (!capsRead) return null;
1655
1735
  const limit = Number.isInteger(caps?.dailyRuns) ? caps.dailyRuns : DEFAULT_CLIENT_DAILY_RUNS;
1656
1736
  if (!Number.isInteger(limit)) return null;
1657
- const used = accountUsage({ queueDirs: queueDirs(), account });
1737
+ const used = accountUsage({ queueDirs: queueDirs(), account, organisation });
1658
1738
  // A COUNT WE COULD NOT TAKE IS NOT A ZERO. `complete: false` means no ledger was reachable, and
1659
1739
  // 0 would then mean "nothing recorded" and "nothing readable" at once — the exact confusion that
1660
1740
  // made this refusal unreachable for as long as the path was wrong. Same call the branch
@@ -1680,12 +1760,13 @@ export function makePortalService({
1680
1760
 
1681
1761
  // /portal/api/run/plan — the confirmation gate (no spend)
1682
1762
  if (parts[1] === "api" && parts[2] === "run" && parts[3] === "plan" && method === "POST") {
1683
- const account = assertPrincipal(principal, { account: body.account ?? query.account ?? null });
1684
- if (!account) return { status: 400, json: { error: "name an account — staff must pick who they act for" } };
1685
- const job = jobFor({ principal, account, body });
1763
+ const tenant = body.tenant ?? query.tenant ?? null;
1764
+ const account = assertPrincipal(principal, { account: body.account ?? query.account ?? null, tenant, run: true });
1765
+ if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
1766
+ const job = jobFor({ principal, account, tenant, body });
1686
1767
  const gates = planGates(job);
1687
1768
  if (gates.fail) return gates.fail;
1688
- const overQuota = await quotaRefusal(account);
1769
+ const overQuota = await quotaRefusal(account, job.tenant ?? null);
1689
1770
  if (overQuota) return overQuota;
1690
1771
  // ONE resolution, and the name, the stage, the turnaround and the effort all come off it.
1691
1772
  const { profile: planProfile, resolved: planResolved, scope: planScope } = resolveFor(job);
@@ -1731,14 +1812,14 @@ export function makePortalService({
1731
1812
  }
1732
1813
  // /portal/api/run — verify + trigger (the ONLY spend path)
1733
1814
  if (parts[1] === "api" && parts[2] === "run" && parts.length === 3 && method === "POST") {
1734
- const account = assertPrincipal(principal, { account: body.account ?? null });
1815
+ const account = assertPrincipal(principal, { account: body.account ?? null, tenant: body.tenant ?? null, run: true });
1735
1816
  if (!account) return { status: 400, json: { error: "name an account" } };
1736
- const job = jobFor({ principal, account, body });
1817
+ const job = jobFor({ principal, account, tenant: body.tenant ?? null, body });
1737
1818
  const gates = planGates(job); // re-gated: the token is necessary, never sufficient
1738
1819
  if (gates.fail) return gates.fail;
1739
1820
  // re-checked for the same reason the gates are: a token minted while the account still had
1740
1821
  // allowance must not spend it after another tab has used the last one.
1741
- const overQuota = await quotaRefusal(account);
1822
+ const overQuota = await quotaRefusal(account, job.tenant ?? null);
1742
1823
  if (overQuota) return overQuota;
1743
1824
  sweepJtis();
1744
1825
  const bad = verifyConfirmation({ secret, token: body.confirmationToken, account, email: principal.email,
@@ -1747,7 +1828,7 @@ export function makePortalService({
1747
1828
  const selector = selectorOf(body);
1748
1829
  // ── — A DEMO WALKS THE REAL FLOW AND LANDS ON A FINISHED RUN ─────────
1749
1830
  //
1750
- // Owner ruling, 2026-08-31, revising his own ruling of an hour earlier: "i think its OK for
1831
+ // Ruling, 2026-08-31, revising his own ruling of an hour earlier: "i think its OK for
1751
1832
  // someone to be able to press New Clearance in demo mode and see it work and get the static
1752
1833
  // results, right?" — so the form is real, the plan is real, the confirmation is real, and the
1753
1834
  // only thing that is not real is the dispatch.
@@ -1828,10 +1909,10 @@ export function makePortalService({
1828
1909
  r = await trigger({
1829
1910
  ...job,
1830
1911
  profileKey: account,
1831
- // The daily-allowance stamp. Set ONLY here, and only on a genuine client principal —
1832
- // this is the one place in the system where that role is authoritative. See checkRunCaps
1833
- // for why the polarity is positive-only and why every inferred alternative fails unsafely.
1834
- ...(principal.role === "client" ? { clientPrincipal: true } : {}),
1912
+ // The daily-allowance stamp. Set ONLY here, on everyone but a person who sees everything —
1913
+ // this is the one place in the system where that is decided. See checkRunCaps for why the
1914
+ // polarity is positive-only and why every inferred alternative fails unsafely.
1915
+ ...(!seesEverything(principal) ? { clientPrincipal: true } : {}),
1835
1916
  ...(demoRunFlag ? { demoRun: true } : {}),
1836
1917
  });
1837
1918
  } catch (e) {
@@ -1842,7 +1923,7 @@ export function makePortalService({
1842
1923
  // for operators and name infrastructure: the unwired-trigger case reads "PORTAL_MCP_URL /
1843
1924
  // PORTAL_OPS_TOKEN unset", which is an internal variable name rendered in a client's browser.
1844
1925
  // Staff get it verbatim because they are the ones who can act on it; a client gets the fact.
1845
- const staff = principal.role === "staff";
1926
+ const staff = seesEverything(principal);
1846
1927
  // One cause is worth distinguishing even for staff: an instance with no engine attached is not
1847
1928
  // a failure, it is an instance that was never finished. Saying "could not be queued" invites
1848
1929
  // someone to retry, re-read logs and file a bug against a working system.
@@ -1867,7 +1948,7 @@ export function makePortalService({
1867
1948
  //
1868
1949
  // /portal/api/run/<runId>/stop — end a run that has already started.
1869
1950
  if (parts[1] === "api" && parts[2] === "run" && parts[3] && parts[4] === "stop" && method === "POST") {
1870
- const account = assertPrincipal(principal, { account: query.account ?? null });
1951
+ const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null, run: true });
1871
1952
  if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
1872
1953
  const runId = decodeURIComponent(parts[3]);
1873
1954
  // OWNERSHIP FIRST, and off the run list rather than off the request: the engine's own account
@@ -1880,7 +1961,7 @@ export function makePortalService({
1880
1961
  return { status: 409, json: { ok: false, error: "This run has already finished.", state: mine.state } };
1881
1962
  // ── — WHICH STOP THE READER ASKED FOR ────────────────────────────────
1882
1963
  //
1883
- // Owner ruling, on his second encounter with the same wait: "a stop is a stop — maybe it should
1964
+ // Ruling, on his second encounter with the same wait: "a stop is a stop — maybe it should
1884
1965
  // be a 'stop immediately or at next boundary to preserve data' kind of question when you press
1885
1966
  // it." The driver half landed the mode; this carries the reader's answer to it.
1886
1967
  //
@@ -1903,7 +1984,7 @@ export function makePortalService({
1903
1984
  // for operators while telling the user only that it did not happen.
1904
1985
  const unwired = /not wired|verb|scope/i.test(detail);
1905
1986
  return { status: 502, json: { ok: false, unwired,
1906
- error: principal.role === "staff" ? detail : `The run could not be stopped just now. It is still running, and ${BRAND.name} can see what happened.` } };
1987
+ error: seesEverything(principal) ? detail : `The run could not be stopped just now. It is still running, and ${BRAND.name} can see what happened.` } };
1907
1988
  }
1908
1989
  // ── — WHICH STOP IS ACTUALLY IN PROGRESS, AND NOTHING ELSE ──────────
1909
1990
  //
@@ -1933,7 +2014,7 @@ export function makePortalService({
1933
2014
  // A knockout over several names has no combined document, so its assessment — the one piece of
1934
2015
  // prose that reads the names against each other — is written to `report.md` and, until this route,
1935
2016
  // reached nobody: `meta.reports` lists the per-mark HTMLs only, and the pool path is not one the
1936
- // edge serves. Owner ruling 2026-08-26: the grouped page carries it.
2017
+ // edge serves. Ruling 2026-08-26: the grouped page carries it.
1937
2018
  //
1938
2019
  // ITS OWN ROUTE RATHER THAN A FIELD ON THE RUN ROW. The row is what /portal/api/runs returns for
1939
2020
  // every run the caller owns, and it is fetched by every screen that lists runs — where this prose
@@ -1951,8 +2032,7 @@ export function makePortalService({
1951
2032
  const dir = join(poolRoot, runId);
1952
2033
  let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
1953
2034
  const owner = meta.customerKey || "generic";
1954
- if (owner === "generic" && principal.role !== "staff") return { status: 404, json: { error: "not_found" } };
1955
- try { assertPrincipal(principal, { account: owner }); } catch { return { status: 404, json: { error: "not_found" } }; }
2035
+ if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
1956
2036
  // ANSWERED FOR EVERY RUN THAT HAS ONE, grouped or not. A gate to grouped runs only stood here
1957
2037
  // and was wrong in the way this file keeps guarding against: a single-document run HAS this
1958
2038
  // prose — `report.md` is written on every run — so 404 would have said "there is none" about
@@ -1975,7 +2055,7 @@ export function makePortalService({
1975
2055
  // /portal/api/queue/<id>/cancel — drop a job that has not started. No spend has happened, so
1976
2056
  // there is nothing to account for and no row is left behind.
1977
2057
  if (parts[1] === "api" && parts[2] === "queue" && parts[3] && parts[4] === "cancel" && method === "POST") {
1978
- const account = assertPrincipal(principal, { account: query.account ?? null });
2058
+ const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null, run: true });
1979
2059
  if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
1980
2060
  const id = decodeURIComponent(parts[3]);
1981
2061
  const mine = scanAccountRuns({ poolRoot, workspaceRoot, account, queueDirs: queueDirs() }).find((r) => r.runId === id && r.state === "queued");
@@ -1987,7 +2067,7 @@ export function makePortalService({
1987
2067
  const detail = String(e?.message ?? e);
1988
2068
  audit({ event: "queue-cancel", by: principal.email, account, id, ok: false, error: detail });
1989
2069
  return { status: 502, json: { ok: false, unwired: /not wired|verb|scope/i.test(detail),
1990
- error: principal.role === "staff" ? detail : "It could not be cancelled just now. Nothing has been charged." } };
2070
+ error: seesEverything(principal) ? detail : "It could not be cancelled just now. Nothing has been charged." } };
1991
2071
  }
1992
2072
  audit({ event: "queue-cancel", by: principal.email, account, id, ok: Boolean(r?.ok), action: r?.action ?? null });
1993
2073
  // ALREADY-CLAIMED IS A RACE, NOT AN ERROR. The runner picked it up between the click and the
@@ -2006,7 +2086,7 @@ export function makePortalService({
2006
2086
  // for no gain. The tenancy wall is the same one every other route uses — the caller's resolved
2007
2087
  // account — applied per id.
2008
2088
  if (parts[1] === "api" && parts[2] === "queue" && parts[3] === "order" && method === "POST") {
2009
- const account = assertPrincipal(principal, { account: query.account ?? null });
2089
+ const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null, run: true });
2010
2090
  if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
2011
2091
  const asked = Array.isArray(body?.order) ? body.order.filter((s) => typeof s === "string" && s) : null;
2012
2092
  if (!asked) return { status: 400, json: { error: "send { order: [id, …] }" } };
@@ -2083,6 +2163,22 @@ export function makePortalService({
2083
2163
  }
2084
2164
  return r;
2085
2165
  }
2166
+ // /portal/api/config/companies — CREATE. No account segment, because there is no account yet.
2167
+ //
2168
+ // It does not hang off `profile` for a reason worth stating: every route under that word acts on
2169
+ // ONE company, resolved from the caller, and this one is the act of there not being one. Reusing
2170
+ // the noun would put "the company you are in" and "the company you are making" behind the same
2171
+ // path, which is the conflation the pick panel exists to remove.
2172
+ //
2173
+ // The audit row is written upstream by the create route itself, in the same commit as the file,
2174
+ // so there is none here. An audit call copied from its neighbours would also be wrong twice: it
2175
+ // would test `status === 200` against a 201, and file nothing.
2176
+ if (parts[3] === "companies") {
2177
+ if (parts.length === 4 && method === "POST") return await upstream.createCompany(principal, body);
2178
+ // 404 for a wrong verb, as the profile branch above explains at length: 405 would make this
2179
+ // endpoint distinguishable from one that does not exist.
2180
+ return { status: 404, json: { error: "not_found" } };
2181
+ }
2086
2182
  // /portal/api/config/projects[/:project[/{validate,save}]]
2087
2183
  if (parts[3] === "projects") {
2088
2184
  if (parts.length === 4 && method === "GET") return await upstream.listProjects(principal, acct);
@@ -2119,16 +2215,21 @@ export function makePortalService({
2119
2215
  // per-identity: a staff member acting for three clients has three different answers, and one of
2120
2216
  // them is never "yours". Cheap enough to fetch beside the composer and the run list.
2121
2217
  if (parts[1] === "api" && parts[2] === "usage" && method === "GET") {
2122
- const account = assertPrincipal(principal, { account: query.account ?? null });
2123
- if (!account) return { status: 400, json: { error: "name an account (?account=) — staff must pick who they act for" } };
2218
+ const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null });
2219
+ if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
2220
+ // Generic is counted per organisation — the lane the wall caps (runner.mjs checkRunCaps).
2221
+ const organisation = account === "generic" ? genericOrgOf(principal, query.tenant ?? null) : null;
2124
2222
  // Caps live on the customer profile and are code-owned (never client-editable). A deployment
2125
2223
  // with no config surface still answers, with counts and no caps — "we cannot tell you your
2126
2224
  // limit" is a better answer than a fabricated one.
2127
2225
  let caps = null, capsRead = false;
2128
2226
  if (upstream) {
2129
2227
  try {
2130
- const r = await upstream.getProfile(principal, account);
2131
- if (r.status === 200) { caps = r.json?.readOnly?.runCaps ?? null; capsRead = true; }
2228
+ if (account === "generic") { caps = (await loadProfilesImpl()).get("generic")?.runCaps ?? null; capsRead = true; }
2229
+ else {
2230
+ const r = await upstream.getProfile(principal, account);
2231
+ if (r.status === 200) { caps = r.json?.readOnly?.runCaps ?? null; capsRead = true; }
2232
+ }
2132
2233
  } catch { /* a settings surface fault must not take the counter down */ }
2133
2234
  }
2134
2235
  // `complete` rides out with the counts (see usage-ledger.mjs): false means no ledger was read and
@@ -2138,17 +2239,17 @@ export function makePortalService({
2138
2239
  // `basis` is dropped here on purpose. What a client is owed is "we could not count", which
2139
2240
  // `complete` says; WHICH kind of nothing it was is an operator's diagnosis of our deployment and
2140
2241
  // belongs in the audit trail, where the quota pre-check puts it.
2141
- const { basis: _basis, ...used } = accountUsage({ queueDirs: queueDirs(), account });
2142
- return { status: 200, json: { account, ...used,
2242
+ const { basis: _basis, ...used } = accountUsage({ queueDirs: queueDirs(), account, organisation });
2243
+ return { status: 200, json: { account, ...(account === "generic" ? { tenant: organisation } : {}), ...used,
2143
2244
  // The EFFECTIVE daily allowance: the profile's value, else the default the wall applies — a
2144
2245
  // client reading "—" while the wall enforces 2 is the same lie as a wrong count. But a profile
2145
2246
  // we could NOT read still reports null: "we cannot tell you your limit" is a different
2146
2247
  // statement from "your limit is 2", and only one of them is honest when settings are down.
2147
2248
  dailyRuns: Number.isInteger(caps?.dailyRuns) ? caps.dailyRuns : (capsRead ? DEFAULT_CLIENT_DAILY_RUNS : null),
2148
2249
  monthlyRuns: caps?.monthlyRuns ?? null, maxQueued: caps?.maxQueued ?? null,
2149
- // Staff are not capped, and the UI needs to know that to avoid showing a client's allowance
2150
- // to someone it does not bind. See checkRunCaps for why role is decided here and nowhere else.
2151
- capped: principal.role === "client" } };
2250
+ // A person who sees everything is not capped, and the UI needs to know that to avoid showing an
2251
+ // allowance to someone it does not bind. See checkRunCaps for why it is decided here and nowhere else.
2252
+ capped: !seesEverything(principal) } };
2152
2253
  }
2153
2254
 
2154
2255
  // /portal/api/mcp-access — the connection details for driving the engine from your own assistant.
@@ -2198,7 +2299,7 @@ export function makePortalService({
2198
2299
  //
2199
2300
  // COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
2200
2301
  // path, so the three surfaces stating this route cannot drift apart even if someone tries.
2201
- const stdio = principal.role === "staff"
2302
+ const stdio = seesEverything(principal)
2202
2303
  ? stdioConnectOffer({ workDir: process.env.CLEAROTRON_WORK_DIR || null })
2203
2304
  : null;
2204
2305
 
@@ -2292,10 +2393,9 @@ export function makePortalService({
2292
2393
  if (!url) return { status: 409, json: { error: "no_connector" } };
2293
2394
  const identity = principal.email ?? null;
2294
2395
  if (!identity) return { status: 403, json: { error: "no_identity" } };
2295
- const granted = accountsForEmail(identity, loadGrants());
2296
- if (Array.isArray(granted) && granted.length === 0) {
2297
- return { status: 403, json: { error: "not_enrolled" } };
2298
- }
2396
+ // Enrolled means a principal exists: the door check above has already refused an address with no
2397
+ // access anywhere. Asking for a company list here instead read a person whose only reach is their
2398
+ // organisation's Generic as not enrolled.
2299
2399
  let key;
2300
2400
  try { key = mintToken({ scope: "account", sub: identity, ttlSec: 90 * 24 * 3600 }); }
2301
2401
  catch { return { status: 503, json: { error: "cannot_issue" } }; }
@@ -2346,13 +2446,13 @@ export function makePortalService({
2346
2446
  // reader put down". Every branch below goes through it — a route that stamped only the scope=mine
2347
2447
  // path would leave the same run acknowledged on one screen and not on another.
2348
2448
  const mine = (runs) => withAcks(runs, readAcks(poolRoot, principal?.email));
2349
- // "All brand owners". STAFF ONLY, and it is a distinct code path rather than a wildcard passed
2350
- // to assertPrincipal — a client must never reach a branch that skips the account resolution,
2351
- // even by accident. A client who asks for it gets the same 404 as any account not theirs.
2449
+ // "All brand owners". ONLY FOR A PERSON WHO SEES EVERYTHING, and it is a distinct code path rather
2450
+ // than a wildcard passed to assertPrincipal — nobody else may reach a branch that skips the account
2451
+ // resolution, even by accident. Anyone else who asks for it gets the same 404 as any account not theirs.
2352
2452
  if (query.account === "*") {
2353
- // THE DOOR FIRST. This branch read `principal.role` directly, and `principal` is null for any
2354
- // identity that is neither staff nor granted anything — the exact caller this route most needs
2355
- // to refuse. Reading `.role` off null threw a TypeError, which is not a PortalDeny, so the
2453
+ // THE DOOR FIRST. This branch once read a field off `principal` directly, and `principal` is null
2454
+ // for an identity with no access anywhere — the exact caller this route most needs to refuse.
2455
+ // Reading a field off null threw a TypeError, which is not a PortalDeny, so the
2356
2456
  // catch at the bottom of route() rethrew it and the handler answered 500 "internal". An
2357
2457
  // unenrolled prober therefore got a SERVER ERROR from the one route that lists every customer,
2358
2458
  // while every other surface refused them cleanly — and a 500 is both the wrong answer and a
@@ -2363,7 +2463,7 @@ export function makePortalService({
2363
2463
  // account (portal-access.mjs), so the staff-only rule below is untouched: a client still gets
2364
2464
  // the plain 404 on the line after this one.
2365
2465
  assertPrincipal(principal, { door: true });
2366
- if (principal.role !== "staff") return { status: 404, json: { error: "not_found" } };
2466
+ if (!seesEverything(principal)) return { status: 404, json: { error: "not_found" } };
2367
2467
  // One pass over the pool, not one request per account. The alternative — the browser fanning
2368
2468
  // out across the roster — would spend a roster-sized chunk of the 120/min rate limit on every
2369
2469
  // poll, which is exactly what the limiter is there to stop.
@@ -2387,7 +2487,7 @@ export function makePortalService({
2387
2487
  // has to ask who is looking, which is the whole point — there is no staff layout.
2388
2488
  if (parts[1] === "api" && parts[2] === "runs" && method === "GET" && query.scope === "mine") {
2389
2489
  assertPrincipal(principal, { door: true });
2390
- if (principal.role === "staff") {
2490
+ if (seesEverything(principal)) {
2391
2491
  return { status: 200, json: { account: "*", runs: mine(forRole(scanAllRuns({ poolRoot, workspaceRoot, queueDirs: queueDirs() }), principal)) } };
2392
2492
  }
2393
2493
  // Resolved from `principal.accounts`, NEVER from the query — this is the same set
@@ -2396,17 +2496,25 @@ export function makePortalService({
2396
2496
  // `accounts === "*"` is the grants-file-absent posture (enforcement OFF). That is a
2397
2497
  // SENTINEL, NOT A LIST: expanding it here would turn a missing config file into a
2398
2498
  // cross-tenant read, so it falls through to the same refusal as holding nothing.
2399
- if (Array.isArray(principal.accounts) && principal.accounts.length) {
2400
- const own = principal.accounts.filter((a) => a !== "generic"); // the house account is staff-only, everywhere
2401
- if (own.length) return { status: 200, json: { account: "*", runs: mine(forRole(scanAccountRuns({ poolRoot, workspaceRoot, account: own, queueDirs: queueDirs() }), principal)) } };
2499
+ // Their companies, and the Generic of each organisation they hold whole.
2500
+ if (Array.isArray(principal.accounts) && (principal.accounts.length || principal.genericOrgs?.length)) {
2501
+ return { status: 200, json: { account: "*", runs: mine(forRole(scanAccountRuns({ poolRoot, workspaceRoot, account: principal.accounts,
2502
+ generic: { orgs: principal.genericOrgs ?? [] }, queueDirs: queueDirs() }), principal)) } };
2402
2503
  }
2403
2504
  return { status: 404, json: { error: "not_found" } };
2404
2505
  }
2405
- const account = assertPrincipal(principal, { account: query.account ?? null });
2506
+ const account = assertPrincipal(principal, { account: query.account ?? null, tenant: query.tenant ?? null });
2406
2507
  if (!account) return { status: 400, json: { error: "name an account (?account=)" } };
2407
- // untagged/generic runs are STAFF-only, matching the MCP face + the LEAK-#9 rule (a client
2408
- // surface never lists generic — review 2026-07-18: the two boundaries disagreed)
2409
- if (account === "generic" && principal.role !== "staff") return { status: 404, json: { error: "not_found" } };
2508
+ // GENERIC IS LISTED PER ORGANISATION. assertPrincipal has already refused an organisation whose
2509
+ // Generic this person does not see. A named or implied organisation lists its own Generic runs,
2510
+ // and a person who sees everything also gets the ones filed under none — on a one-organisation
2511
+ // install those are that organisation's. Unnamed, with several organisations, a person who sees
2512
+ // everything lists every Generic run, which is what they saw before organisations existed.
2513
+ if (account === "generic") {
2514
+ const org = genericOrgOf(principal, query.tenant ?? null);
2515
+ const generic = org == null ? { all: true } : { orgs: [org], unfiled: seesEverything(principal) };
2516
+ return { status: 200, json: { account, tenant: org, runs: mine(forRole(scanAccountRuns({ poolRoot, workspaceRoot, account: [], generic, queueDirs: queueDirs() }), principal)) } };
2517
+ }
2410
2518
  return { status: 200, json: { account, runs: mine(forRole(scanAccountRuns({ poolRoot, workspaceRoot, account, queueDirs: queueDirs() }), principal)) } };
2411
2519
  }
2412
2520
  // ── /portal/api/ack — "I have seen that one" ─────────────────────────────────────────────
@@ -2467,8 +2575,7 @@ export function makePortalService({
2467
2575
  const dir = join(poolRoot, runId);
2468
2576
  let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
2469
2577
  const owner = meta.customerKey || "generic";
2470
- if (owner === "generic" && principal.role !== "staff") return { status: 404, json: { error: "not_found" } };
2471
- try { assertPrincipal(principal, { account: owner }); } catch { return { status: 404, json: { error: "not_found" } }; }
2578
+ if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
2472
2579
  // NOT staff-only any more. This route used to 404 a client on role alone — "the workbook is the
2473
2580
  // working paper behind the opinion, not the opinion" — with the note that widening it was a
2474
2581
  // disclosure decision and not one to make in passing. The owner made it on 2026-07-27, in the same
@@ -2504,8 +2611,8 @@ export function makePortalService({
2504
2611
  const dir = join(poolRoot, runId);
2505
2612
  let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
2506
2613
  const owner = meta.customerKey || "generic";
2507
- if (owner === "generic" && principal.role !== "staff") return { status: 404, json: { error: "not_found" } }; // generic = staff-only (LEAK-#9 alignment)
2508
- try { assertPrincipal(principal, { account: owner }); } catch { return { status: 404, json: { error: "not_found" } }; } // foreign = 404, never 403
2614
+ // Foreign is 404, never 403, and a Generic run is its organisation's — the same question the connector asks (LEAK-#9 alignment).
2615
+ if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
2509
2616
  // ONE REPORT PER MARK: `/portal/report/<runId>/` serves a run that has one document, and
2510
2617
  // `/portal/report/<runId>/<slug>/` serves one name's document out of a batch. A batch has no
2511
2618
  // run-level document at all — resolveReportFile returns null and this 404s — because serving mark
@@ -2533,7 +2640,7 @@ export function makePortalService({
2533
2640
  // The injection stays where it is (portal-report.mjs's FEEDBACK_CSS/FEEDBACK_JS, still exercised
2534
2641
  // by portal-report.test.mjs's `feedback: true` arms). The owner ruled disable, not delete, so
2535
2642
  // re-enabling the document half is this argument coming back.
2536
- return { status: 200, html: readReport(dir, { log: auditLog, staff: principal.role === "staff", poolRoot, file: reportFile }) };
2643
+ return { status: 200, html: readReport(dir, { log: auditLog, staff: seesEverything(principal), poolRoot, file: reportFile }) };
2537
2644
  }
2538
2645
  // POST /portal/api/feedback — a lawyer flags one finding on a delivered report.
2539
2646
  //
@@ -2573,10 +2680,9 @@ export function makePortalService({
2573
2680
  const dir = join(poolRoot, runId);
2574
2681
  let meta; try { meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); } catch { return { status: 404, json: { error: "not_found" } }; }
2575
2682
  // Ownership, exactly as GET /portal/report/<id> checks it — foreign is 404, never 403, and
2576
- // `generic` is staff-only. A reader who cannot READ the report cannot flag it either.
2683
+ // a Generic run is its organisation's. A reader who cannot READ the report cannot flag it either.
2577
2684
  const owner = meta.customerKey || "generic";
2578
- if (owner === "generic" && principal.role !== "staff") return { status: 404, json: { error: "not_found" } };
2579
- try { assertPrincipal(principal, { account: owner }); } catch { return { status: 404, json: { error: "not_found" } }; }
2685
+ if (!mayReadRun(principal, { owner, organisation: meta.organisation ?? null })) return { status: 404, json: { error: "not_found" } };
2580
2686
 
2581
2687
  const verdict = typeof body?.verdict === "string" ? body.verdict : "";
2582
2688
  if (!VERDICTS.has(verdict))
@@ -2700,9 +2806,11 @@ export function makePortalService({
2700
2806
  });
2701
2807
  return { status: 201, json: { id: rec.id } };
2702
2808
  }
2703
- // /portal/admin/* — staff-only surfaces (clients get 404: the surface does not exist for them)
2809
+ // /portal/admin/* — the install-wide surfaces, for a person who sees everything, and People, for a
2810
+ // person with Manage. Everyone else gets 404: the surface does not exist for them.
2704
2811
  if (parts[1] === "admin") {
2705
- assertPrincipal(principal, { staffOnly: true });
2812
+ assertPrincipal(principal, parts[2] === "access" || parts[2] === "people"
2813
+ ? { door: true, manage: true } : { door: true, everything: true });
2706
2814
  if (parts[2] === "roster" && method === "GET") {
2707
2815
  const profiles = await loadProfilesImpl();
2708
2816
  // `generic` IS in the roster — this route is staff-only (asserted above), and untagged runs
@@ -2710,7 +2818,13 @@ export function makePortalService({
2710
2818
  // by typing ?account=generic but invisible in the switcher, which is how they stop being
2711
2819
  // looked at. The client-facing boundary is unchanged: /portal/api/runs and the report route
2712
2820
  // both 404 `generic` for a non-staff principal.
2713
- return { status: 200, json: { customers: [...profiles.values()].map((p) => ({ key: p.key, name: p.name })) } };
2821
+ // THE FACTS RIDE WITH THE NAME. The pick panel tells companies apart by industry, marketplace
2822
+ // count and territories, and it renders for staff from here and for a client from /me. Shaping
2823
+ // both through `companyFactsOf` is what stops the same company reading two ways depending on
2824
+ // who signed in. Dynamic import deliberately: `profiles.mjs` captures the store directory at
2825
+ // MODULE LOAD (see this file's note above), so it is never pulled in at our own load time.
2826
+ const { companyFactsOf } = await import("./profiles.mjs");
2827
+ return { status: 200, json: { customers: [...profiles.values()].map((p) => ({ key: p.key, name: p.name, ...companyFactsOf(p) })) } };
2714
2828
  }
2715
2829
  // /portal/admin/config — what this deployment actually has switched on. Read from the SNAPSHOT,
2716
2830
  // never from process.env: this process has no engine environment, so asking its own env would
@@ -2727,7 +2841,7 @@ export function makePortalService({
2727
2841
  // below, so it is the authoritative source. Fed from process.env at the seam rather than
2728
2842
  // inside authView, so the one process entitled to answer is visibly the one reading.
2729
2843
  //
2730
- // AND THE ENVIRONMENT IS READ ONCE MORE, FOR ONE PURPOSE (tracker issue 170): to say whether
2844
+ // AND THE ENVIRONMENT IS READ ONCE MORE, FOR ONE PURPOSE: to say whether
2731
2845
  // the capture still DESCRIBES this box, never to supply a value. A deployment being
2732
2846
  // configured runs nothing, so its capture cannot age into a warning while its contents drift
2733
2847
  // — the disagreement is the signal the age never was. Degraded rather than fatal: a page that
@@ -2871,7 +2985,7 @@ export function makePortalService({
2871
2985
  // /portal/admin/access — who is granted what, and where an enrolment is half done.
2872
2986
  if (parts[2] === "access" && method === "GET") {
2873
2987
  const { loadProfiles } = await import("./profiles.mjs");
2874
- const knownAccounts = [...loadProfiles({ force: true }).keys()];
2988
+ const companies = Object.fromEntries([...loadProfiles({ force: true }).values()].map((p) => [p.key, p.name ?? p.key]));
2875
2989
  // Where to go to change this. The stat is done HERE rather than inside accessView because
2876
2990
  // that function is pure and its tests call it with no filesystem at all; giving it IO would
2877
2991
  // cost that for a filename and a date.
@@ -2884,7 +2998,46 @@ export function makePortalService({
2884
2998
  const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
2885
2999
  if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
2886
3000
  } catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
2887
- return { status: 200, json: accessView({ grants: grantsNow(), staffDomains, knownAccounts, grantsFile, staffRule }) };
3001
+ return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn }) };
3002
+ }
3003
+ // /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
3004
+ //
3005
+ // THE ADDER'S OWN REACH BOUNDS WHAT THEY GIVE. A point outside it is a 404, exactly as asking to
3006
+ // see it would be. Switches belong to the person, not to a point, so they are set only when the
3007
+ // person's whole access sits inside the adder's; otherwise the points are added, the switches stay
3008
+ // as they were, and the answer says which happened. Nobody sets their own switches, and nobody
3009
+ // gives Run without holding it.
3010
+ if (parts[2] === "people" && parts.length === 3 && method === "POST") {
3011
+ if (localSignIn) return { status: 409, json: { error: "local_sign_in" } };
3012
+ if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
3013
+ const email = String(body?.email ?? "").trim().toLowerCase();
3014
+ if (!email || email.indexOf("@") <= 0 || email.indexOf("@") !== email.lastIndexOf("@"))
3015
+ return { status: 400, json: { error: "Enter one email address." } };
3016
+ const points = [];
3017
+ for (const a of Array.isArray(body?.access) ? body.access : []) {
3018
+ const key = typeof a?.key === "string" ? a.key : "";
3019
+ if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) points.push({ tenant: key });
3020
+ else if (a?.kind === "company" && principal.accountOrgs?.[key]) points.push({ tenant: principal.accountOrgs[key], account: key });
3021
+ else return { status: 404, json: { error: "not_found" } };
3022
+ }
3023
+ if (!points.length) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
3024
+ const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true };
3025
+ if (want.run && !mayRun(principal)) return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
3026
+ const existing = resolvePerson(email, grantsHere);
3027
+ const setSwitches = email !== principal.email && (!existing || reachCovers(principal, existing));
3028
+ let next;
3029
+ try { next = withPerson(grantsHere, { email, points, switches: want, setSwitches }); }
3030
+ catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
3031
+ try { await writeGrants(next); }
3032
+ catch (e) {
3033
+ audit({ event: "person-add", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
3034
+ return { status: 500, json: { error: "The guest list could not be written, so nobody was added." } };
3035
+ }
3036
+ audit({ event: "person-add", by: principal.email, person: email, points: points.length, switchesApplied: setSwitches, ok: true, status: 201 });
3037
+ const { loadProfiles } = await import("./profiles.mjs");
3038
+ const companies = Object.fromEntries([...loadProfiles({ force: true }).values()].map((p) => [p.key, p.name ?? p.key]));
3039
+ const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email === email) ?? null;
3040
+ return { status: 201, json: { person, switchesApplied: setSwitches } };
2888
3041
  }
2889
3042
  // /portal/admin/observed — who has actually USED this instance lately, from the audit log.
2890
3043
  //
@@ -2988,7 +3141,7 @@ export function makePortalService({
2988
3141
  * "a comment asserting a wall that is not built is worse than no comment: it is the reason nobody goes
2989
3142
  * to look". Nobody went to look, for the same reason.
2990
3143
  *
2991
- * The portal is now an issuance path ON PURPOSE — `/portal/api/connect-key`, owner ruling 2026-08-31 —
3144
+ * The portal is now an issuance path ON PURPOSE — `/portal/api/connect-key`, ruling 2026-08-31 —
2992
3145
  * minting an account key for the CALLER and for nobody else. What it still cannot do is mint for another
2993
3146
  * identity: `sub` comes from the authenticated principal and never from the request. What it CAN also do
2994
3147
  * is read the credential it was
@@ -3037,7 +3190,7 @@ export function bundleFreshnessCached(present, { now = Date.now(), ttl = BUNDLE_
3037
3190
  /**
3038
3191
  * How to re-mint the trigger token WITH an accounts cap, and without changing anything else about it.
3039
3192
  *
3040
- * ── tracker issue 107 ────────────────────────────────────────────────────────────────────────────
3193
+ * ── THE RE-MINT COMMAND ──────────────────────────────────────────────────────────────────────────
3041
3194
  *
3042
3195
  * This used to be a fixed string in the warning itself: `--sub portal --verbs start_run,stop_run`. An
3043
3196
  * operator whose token carries a third verb, or a different subject, was told to re-mint as something
@@ -3528,7 +3681,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
3528
3681
  // Null when unset or unrecognised, which is an absence and reads as one. No inference from the
3529
3682
  // account or the path: a guessed box is worse than an unknown one, because it answers wrongly
3530
3683
  // instead of leaving the question open.
3531
- // ── IS THE BUNDLE THE ONE ITS SOURCES WOULD BUILD? (tracker issue 160) ──────────────────────
3684
+ // ── IS THE BUNDLE THE ONE ITS SOURCES WOULD BUILD? ──────────────────────────────────────────
3532
3685
  //
3533
3686
  // `ui` had two states, present and absent, and no third for *present and older than the sources
3534
3687
  // it was built from*. On the source route `git pull` can never update `portal-ui/dist` — it is
@@ -3872,7 +4025,7 @@ const PORT = PORT_CHOICE.port;
3872
4025
  // guards — a boot that is going to refuse for a missing grants file must not write a passphrase
3873
4026
  // first and tell somebody to keep it.
3874
4027
  if (!LOCAL_USER) {
3875
- log("FATAL: PORTAL_AUTH_MODE=local names no user — set PORTAL_LOCAL_USER to the one email address that signs in here, and enrol that same address in the grants file (" + "CLEAROTRON_ACCESS_FILE" + ") or on a staff domain (PORTAL_STAFF_DOMAINS). Refusing to start.");
4028
+ log("FATAL: PORTAL_AUTH_MODE=local names no user — set PORTAL_LOCAL_USER to the one email address that signs in here, and give that same address an entry in the grants file (" + "CLEAROTRON_ACCESS_FILE" + "). Refusing to start.");
3876
4029
  process.exit(1);
3877
4030
  }
3878
4031
  if (!LOCAL_USER.includes("@") || LOCAL_USER.indexOf("@") !== LOCAL_USER.lastIndexOf("@")) {
@@ -3939,12 +4092,11 @@ const PORT = PORT_CHOICE.port;
3939
4092
  // the difference is the point.
3940
4093
  //
3941
4094
  // shared/scope.mjs, first line of accountsForEmail: `if (!grants) return "*"`. With CLEAROTRON_ACCESS_FILE
3942
- // unset, EVERY identity this portal admits resolves to accounts:"*". makePrincipal hands back
3943
- // { role: "client", accounts: "*" } (portal-access.mjs — the "*" branch is honoured, only the ROLE
3944
- // stays client), assertPrincipal then approves whatever ?account= is asked for, and one signed-in
3945
- // customer reads every other customer's runs, held reports and configuration. Nothing throws, nothing
3946
- // 403s, nothing appears in a log: it is a silent read-all across the whole book of business, and the
3947
- // only outward sign is that the account picker offers names its owner has never heard of.
4095
+ // unset, that flat view answers "every customer" for every identity. The portal no longer reads it that
4096
+ // way — `makePrincipal` resolves nobody without a grants file — but other readers of the same function
4097
+ // did, and a portal running with no file would be an install nobody can enter and nothing explains.
4098
+ // Before the access model this was a silent read-all across the whole book of business, and the guard
4099
+ // is kept for the reason it was written: the absence of the one file that decides must stop the start.
3948
4100
  //
3949
4101
  // UNCONDITIONAL, unlike the MCP's. There the guard sits inside the auth-disabled branch, because with
3950
4102
  // auth ON a caller has proven a firm email domain and read-all is what firm staff are supposed to
@@ -3953,12 +4105,8 @@ const PORT = PORT_CHOICE.port;
3953
4105
  // "auth is ON" says nothing whatever about whether the caller may see everything. In both modes the
3954
4106
  // grants file is the only thing that decides, so in both modes it is required.
3955
4107
  //
3956
- // The check further down — `!staffDomains.length && !grants()` — does NOT cover this and never did.
3957
- // It is satisfied by PORTAL_STAFF_DOMAINS alone, which is set on every real deployment, so it stays
3958
- // green while the read-all is live. It answers "could anybody sign in?", not "is anybody bounded?".
3959
- //
3960
- // An empty roster is a legitimate answer: a file containing only {"tenants":{}} admits staff by domain
3961
- // and grants no client anything, which is the correct starting state for a fresh instance. What is not
4108
+ // An empty roster is a legitimate answer: a file containing only {"tenants":{}} admits nobody yet, which
4109
+ // is where a fresh instance starts before setup writes its first person. What is not
3962
4110
  // legitimate is having no file, because that is indistinguishable from "not configured yet" and means
3963
4111
  // the opposite of what it looks like.
3964
4112
  if (!envFrom(process.env, "CLEAROTRON_ACCESS_FILE")) {
@@ -3975,16 +4123,12 @@ const PORT = PORT_CHOICE.port;
3975
4123
  // throws when set-but-unreadable), never a silent fallback.
3976
4124
  try { loadGrants({}); } catch (e) { log(`FATAL: grants file unreadable: ${e.message}`); process.exit(1); }
3977
4125
  const grants = () => loadGrants({});
3978
- const staffDomains = (process.env.PORTAL_STAFF_DOMAINS || "").split(",").map((s) => s.trim()).filter(Boolean);
3979
- if (!staffDomains.length && !grants()) { log(`FATAL: neither PORTAL_STAFF_DOMAINS nor CLEAROTRON_ACCESS_FILE configured — nobody could ever sign in (fail-closed).`); process.exit(1); }
3980
- // WHERE THAT RULE IS WRITTEN, resolved once at boot and handed to the service. `loaded` is this
3981
- // process's own report of which file configured it — never a path composed here, which would answer
3982
- // for a process that read nothing (see `envFileRead`'s note in shared/env-local.mjs).
3983
- const { loaded, unitEnvPath, envLocalPath } = await import("../shared/env-local.mjs");
3984
- const staffRule = staffRuleSource({
3985
- value: process.env.PORTAL_STAFF_DOMAINS, envLoad: loaded,
3986
- unitEnvFile: unitEnvPath(), cliEnvFile: envLocalPath(),
3987
- });
4126
+ // How the People page and company creation write the grants file: atomically, so the per-request
4127
+ // reader sees the old file or the new one and never half of either.
4128
+ const writeGrants = async (g) => {
4129
+ const { atomicWrite } = await import("./progress.mjs");
4130
+ atomicWrite(envFrom(process.env, "CLEAROTRON_ACCESS_FILE"), `${JSON.stringify(g, null, 2)}\n`);
4131
+ };
3988
4132
 
3989
4133
  const { config } = await import("./driver.config.mjs");
3990
4134
  const { appendFileSync: append } = await import("node:fs");
@@ -4059,7 +4203,7 @@ const PORT = PORT_CHOICE.port;
4059
4203
  + "`clearotron start` derives PORTAL_MCP_URL from its resolved ports; a box that launches this "
4060
4204
  + "service directly must set it to the engine door's own ORIGIN (no /mcp — the client appends it).");
4061
4205
  }
4062
- // ── AND WHETHER THE DOOR WILL TAKE IT (tracker issue 174) ────────────────────────────────────────
4206
+ // ── AND WHETHER THE DOOR WILL TAKE IT ────────────────────────────────────────────────────────────
4063
4207
  //
4064
4208
  // Every line in this block describes the PORTAL'S HALF — the token, its verbs, its accounts, its
4065
4209
  // expiry — and not one of them asks whether the door it is pointed at will accept a caller shaped
@@ -4104,7 +4248,7 @@ const PORT = PORT_CHOICE.port;
4104
4248
  // than judged — with the mechanism, so a reader knows what to look at. Claiming more than was
4105
4249
  // measured is how a check starts refusing deployments that work.
4106
4250
  if (lane.state === "fail") log(`WARNING: trigger lane — ${lane.message}`);
4107
- // NOT `WARNING:` (tracker issue 222). A connection nothing answered at boot is a startup race far
4251
+ // NOT `WARNING:`. A connection nothing answered at boot is a startup race far
4108
4252
  // more often than an outage — the units carry no ordering, so the portal routinely binds first —
4109
4253
  // and the old text stated an outage in the present tense with a 502 attached to it. It printed on
4110
4254
  // every reboot, on boxes that were fine. Reserving `WARNING:` for what did answer wrongly is what
@@ -4123,13 +4267,29 @@ const PORT = PORT_CHOICE.port;
4123
4267
  }
4124
4268
  }
4125
4269
 
4270
+ // ── A RETIRED SETTING STILL SET IS SAID HERE, BESIDE THE OTHER GAPS ─────────────────────────────────
4271
+ //
4272
+ // Who may use the portal used to follow PORTAL_STAFF_DOMAINS: everyone at a listed email domain was
4273
+ // staff. That rule is deleted and nothing configures from the setting, so a deployment that still sets
4274
+ // it starts normally and admits nobody by domain — and the setting is the first place an operator looks
4275
+ // when people cannot sign in. Named in this table and looked up, never read as a setting, because it
4276
+ // decides nothing any more.
4277
+ const RETIRED_PORTAL_SETTINGS = {
4278
+ PORTAL_STAFF_DOMAINS: "who may use the portal is each person's own entry in the grants file "
4279
+ + "(CLEAROTRON_ACCESS_FILE), with \"everything\": true under \"people\" for anyone who should see every "
4280
+ + "company. No email domain admits anyone.",
4281
+ };
4282
+ for (const [name, now] of Object.entries(RETIRED_PORTAL_SETTINGS)) {
4283
+ if (process.env[name]) log(`WARNING: ${name} is set and ignored — ${now}`);
4284
+ }
4285
+
4126
4286
  if (OPS_TOKEN) {
4127
4287
  const expiry = posture.expiresAt ? ` expires=${posture.expiresAt.slice(0, 10)} (${posture.daysLeft}d)` : " expires=UNKNOWN";
4128
4288
  // PREFIXED WHEN THE LANE IS DEAD, so this line cannot be read on its own as evidence of a working
4129
4289
  // lane — a journal is skimmed by grepping one phrase, and the phrase people grep is this one.
4130
4290
  log(`trigger lane${laneWired ? "" : " (NOT WIRED — see above)"}: ops token sub=${posture.sub ?? "-"} verbs=${posture.verbs?.join(",") ?? "(full ops)"} accounts=${posture.accounts?.join(",") ?? "UNCAPPED (every account)"}${expiry}${posture.readable ? "" : " — token payload unreadable, posture unknown"}`);
4131
4291
  if (!posture.accountCapped) {
4132
- // ── THE PRINTED COMMAND IS DERIVED FROM THE TOKEN IN HAND (tracker issue 107) ──────────────────
4292
+ // ── THE PRINTED COMMAND IS DERIVED FROM THE TOKEN IN HAND ──────────────────────────────────────
4133
4293
  //
4134
4294
  // It used to be a fixed string: `--sub portal --verbs start_run,stop_run`. An operator whose
4135
4295
  // token carries a third verb, or a different subject, is told to re-mint as something NARROWER
@@ -4200,13 +4360,48 @@ const PORT = PORT_CHOICE.port;
4200
4360
  }
4201
4361
  }
4202
4362
  const { mcpToolCall } = await import("./portal-mcp-client.mjs");
4363
+ // ── the trigger credential is stamped WHEN A SEARCH STARTS, not when the portal booted ────────────
4364
+ //
4365
+ // `bin/start.mjs` mints PORTAL_OPS_TOKEN capped to the roster as it stood at boot. That cap is a real
4366
+ // second wall and it stays. What was wrong is its AGE: a company created afterwards is not in it, so
4367
+ // the portal offered the clearance and the engine door refused it —
4368
+ //
4369
+ // FORBIDDEN (start_run): your grant [generic] does not include account "<key>"
4370
+ //
4371
+ // — which is the first thing a person meets after being told to set a company up. `brandowner add`
4372
+ // detects it and prints NOT YET STARTABLE with a remedy, deliberately reporting rather than
4373
+ // re-minting, because a create COMMAND holding the signing secret would be a larger surprise than the
4374
+ // bug. That boundary is about the command, and it is untouched: this process is not a create command,
4375
+ // it already holds the secret, and it already mints account credentials with it for the connector.
4376
+ //
4377
+ // So the cap is re-taken against the roster as it stands, for the length of one call.
4378
+ //
4379
+ // FALLS BACK TO THE BOOT TOKEN, never to an uncapped one. If the secret is unreadable or the store
4380
+ // cannot be listed, this returns exactly what it returned before — a stale cap that refuses a new
4381
+ // company at the door — rather than widening the wall to get the call through. The failure direction
4382
+ // matters more than the feature: "the newest company cannot search yet" is the bug being fixed, and
4383
+ // "any account can be started" is not a worse version of it, it is a different and larger one.
4384
+ const currentOpsToken = async () => {
4385
+ let roster = [];
4386
+ try {
4387
+ const { loadProfiles } = await import("./profiles.mjs");
4388
+ roster = [...loadProfiles({ force: true }).keys()];
4389
+ } catch (e) {
4390
+ // Same rule one layer out: the roster could not be read at all, so there is nothing to re-mint
4391
+ // against. Narrow and said out loud, rather than narrow and silent.
4392
+ console.error(`[portal] could not read the company roster to re-mint the engine credential `
4393
+ + `(${String(e?.message ?? e)}) — falling back to the credential minted at boot`);
4394
+ return OPS_TOKEN;
4395
+ }
4396
+ return opsTokenFor({ bootToken: OPS_TOKEN, roster, mint: mintToken });
4397
+ };
4203
4398
  const trigger = async (args) => {
4204
4399
  if (!MCP_URL || !OPS_TOKEN) throw new Error("PORTAL_MCP_URL / PORTAL_OPS_TOKEN unset — the trigger lane is not wired on this instance");
4205
- return mcpToolCall({ url: MCP_URL, token: OPS_TOKEN, tool: "start_run", args });
4400
+ return mcpToolCall({ url: MCP_URL, token: await currentOpsToken(), tool: "start_run", args });
4206
4401
  };
4207
4402
  const stopRun = async (args) => {
4208
4403
  if (!MCP_URL || !OPS_TOKEN) throw new Error("PORTAL_MCP_URL / PORTAL_OPS_TOKEN unset — the stop lane is not wired on this instance");
4209
- return mcpToolCall({ url: MCP_URL, token: OPS_TOKEN, tool: "stop_run", args });
4404
+ return mcpToolCall({ url: MCP_URL, token: await currentOpsToken(), tool: "stop_run", args });
4210
4405
  };
4211
4406
 
4212
4407
  // The config surface. profile-service is constructed IN-PROCESS rather than called over HTTP: it
@@ -4331,6 +4526,11 @@ const PORT = PORT_CHOICE.port;
4331
4526
  // directory. The engine refuses that fallback for the same reason; so does this.
4332
4527
  const recipesDir = process.env.CLEAROTRON_RECIPES_DIR || "";
4333
4528
  let callRecipes = null;
4529
+ // WHY THE FEATURE IS OFF, kept rather than only logged. The boot log already carried the exact
4530
+ // diagnosis and the screen carried "try again shortly" — a permanent, already-understood
4531
+ // configuration state rendered as a transient fault, with advice that can never work. The reason
4532
+ // travels with the refusal now so the surface can say what the log knows.
4533
+ let recipesOff = null;
4334
4534
  // — the recipe store's own reachability, decided BEFORE the branch so an unreachable one turns
4335
4535
  // saved searches off rather than throwing. A throw here would land in the catch below and take the
4336
4536
  // PROFILE surface down with it, and the two stores are configured independently: `recipeRepoRoot`
@@ -4345,6 +4545,7 @@ const PORT = PORT_CHOICE.port;
4345
4545
  log(`saved searches OFF — ${storeOutsideRepoMessage({ storeVar: "CLEAROTRON_RECIPES_DIR", storeDir: recipeReach.store, repoVar: "RECIPE_REPO_ROOT", repoRoot: recipeReach.repo })} `
4346
4546
  + `The repo root came from ${recipeResolved.from} (tried ${recipeResolved.tried.join(", ")} in that order). `
4347
4547
  + "Routes answer 404 rather than accepting a save that would orphan its file.");
4548
+ recipesOff = { code: "store_outside_repo", detail: storeOutsideRepoMessage({ storeVar: "CLEAROTRON_RECIPES_DIR", storeDir: recipeReach.store, repoVar: "RECIPE_REPO_ROOT", repoRoot: recipeReach.repo }) };
4348
4549
  } else if (recipesDir) {
4349
4550
  const { makeRecipeService } = await import("./recipe-service.mjs");
4350
4551
  const recipeRepoRoot = recipeResolved.root;
@@ -4357,11 +4558,17 @@ const PORT = PORT_CHOICE.port;
4357
4558
  log(`saved searches ON — store=${recipesDir} repo=${recipeRepoRoot}`);
4358
4559
  } else {
4359
4560
  log("saved searches OFF — CLEAROTRON_RECIPES_DIR unset, so /portal/api/config/searches answers 404");
4561
+ recipesOff = { code: "not_configured", detail: "CLEAROTRON_RECIPES_DIR is not set on this deployment, so there is no store for saved searches." };
4360
4562
  }
4361
4563
 
4362
4564
  return makeUpstream({
4363
4565
  callUpstream: (method, path, body, identity) => profiles.route(method, path, { email: identity?.email }, body ?? {}),
4364
4566
  callRecipes,
4567
+ recipesOff,
4568
+ // A company created from the portal is filed under its organisation in the same breath, so the
4569
+ // person who made it can use it: access is decided by the grants file, and the create writes only
4570
+ // the company's own file, which decides nothing about who sees it.
4571
+ fileCompany: async ({ tenant, account }) => writeGrants(withCompany(loadGrants({}), { tenant, account })),
4365
4572
  });
4366
4573
  } catch (e) {
4367
4574
  // A settings surface that cannot start must not take the whole portal down — clearances and
@@ -4426,7 +4633,7 @@ const PORT = PORT_CHOICE.port;
4426
4633
  const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
4427
4634
  // Re-read per request (a getter that rescans), so a workspace created after boot is counted.
4428
4635
  queueDirs: () => config.queueDirs,
4429
- secret, staffDomains, staffRule, grants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4636
+ secret, grants, localSignIn: LOCAL_MODE, writeGrants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4430
4637
  // — the ONLY place the environment is read for this. `bin/start.mjs` is the
4431
4638
  // only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
4432
4639
  // environment through, so a stray `.env` can neither put a live install into demo mode nor take a
@@ -4515,8 +4722,8 @@ const PORT = PORT_CHOICE.port;
4515
4722
  // exactly as it would for a Cloudflare-verified stranger. Warned at boot because the symptom
4516
4723
  // otherwise arrives as "I signed in and got refused", which reads as a broken login.
4517
4724
  try {
4518
- if (!makePrincipal({ email: LOCAL_USER, grants: grants(), staffDomains }))
4519
- log(`WARNING: ${LOCAL_USER} can sign in but holds no portal access — it is on no staff domain (PORTAL_STAFF_DOMAINS) and in no grants row (CLEAROTRON_ACCESS_FILE), so every page will refuse it at the door. Add it to one of them.`);
4725
+ if (!makePrincipal({ email: LOCAL_USER, grants: grants() }))
4726
+ log(`WARNING: ${LOCAL_USER} can sign in but holds no portal access — it has no entry in the grants file (CLEAROTRON_ACCESS_FILE), so every page will refuse it at the door. Give it one.`);
4520
4727
  } catch (e) {
4521
4728
  // A WARNING must never be the thing that takes the service down. The grants file was already
4522
4729
  // read successfully by the mandatory guard above; anything that fails here is a race with
@@ -4542,7 +4749,7 @@ const PORT = PORT_CHOICE.port;
4542
4749
  // on the route: on a PACKAGED install the bundle ships inside the tarball, so absent means a broken
4543
4750
  // package; on a SOURCE checkout it has simply never been built, which is the ordinary first-run
4544
4751
  // state and why the message names the build command. The freshness verdict twenty lines below
4545
- // (tracker issue 160) has always had this right.
4752
+ // has always had this right.
4546
4753
  //
4547
4754
  // Not fatal on purpose: the API is independently useful (the MCP face, the connector, a debugging
4548
4755
  // curl), and taking the whole service down over a UI asset would turn a cosmetic failure into an