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
package/bin/onboard.mjs CHANGED
@@ -70,10 +70,9 @@ import { standFrom } from "../shared/invocation.mjs"; // is this tree one npm
70
70
  import { installShim } from "../shared/verb-shim.mjs"; // — the verb goes on PATH
71
71
  import { styleFor, banner } from "../shared/tty-style.mjs"; // — weight where the meaning is
72
72
  import { bracketAsciiCells, BRAND } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
73
- // ONE CLASSIFIER, shared with `bin/start.mjs`. The wizard asks the question; the launcher enforces the
74
- // answer. Two copies of "what does this domain admit" is a wizard that consents to one rule and a
75
- // launcher that builds another.
76
- import { classifyStaffDomain, domainOfEmail, staffDomainRefusal, staffGrantSentence } from "../shared/staff-domain.mjs";
73
+ // THE REFUSALS ABOUT THE SIGN-IN ADDRESS ITSELF, shared with `bin/start.mjs`. Two copies would be a
74
+ // wizard that accepts an address the launcher then sends back.
75
+ import { addressRefusal } from "../shared/staff-domain.mjs";
77
76
  import { join, dirname, isAbsolute, resolve } from "node:path";
78
77
  import { fileURLToPath, pathToFileURL } from "node:url";
79
78
  import { delimiter } from "node:path";
@@ -95,14 +94,15 @@ import { resolveAuthMode } from "../driver/engine/auth.mjs";
95
94
  import { isInsideCheckout } from "../shared/inside-checkout.mjs"; // — one copy of the rule, and it is testable
96
95
  import { packagedBuild as sharedPackagedBuild } from "../shared/packaged-build.mjs"; // — one reader of build-info.json, reachable from the driver
97
96
  import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
98
- import { programsFromAnotherCheckout } from "../shared/checkout-move.mjs"; // tracker issue 193
97
+ import { programsFromAnotherCheckout } from "../shared/checkout-move.mjs";
99
98
  import { entrypointOf } from "../driver/systemd/install-census.mjs"; // one ExecStart parser
100
99
  import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
101
100
  import { engineInventory, engineMode, ENGINE_MODES } from "../driver/config-inventory.mjs"; //
102
- import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC } from "../driver/engine/probe.mjs";
101
+ import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
102
+ import { runRequiredNames, missingRequirements, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
103
103
  import { pinEnv, envFrom } from "../shared/env-aliases.mjs";
104
104
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
105
- // tracker issues 1861/1882 — one synopsis reader for every verb that prints one.
105
+ // one synopsis reader for every verb that prints one.
106
106
  import { usageBlock } from "../shared/usage-block.mjs";
107
107
  import { invoke } from "../shared/invocation.mjs"; // — name a command the reader can actually type
108
108
  import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // — ONE KEY=value reader; a second copy would drift from what systemd actually reads
@@ -120,8 +120,8 @@ function readIfPresent(path) {
120
120
 
121
121
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
122
122
  const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed: one resolver, so moving this file later is one line
123
- // WHAT IS READ IS NOT ALWAYS WHERE THE NEXT WRITE GOES. An install configured before the move (tracker
124
- // issue 159) still has its file at the old path, and the loader still reads it — so every READ here asks
123
+ // WHAT IS READ IS NOT ALWAYS WHERE THE NEXT WRITE GOES. An install configured before the move still
124
+ // has its file at the old path, and the loader still reads it — so every READ here asks
125
125
  // the same resolver the loader asks, or this command reports "no configuration" over a file every other
126
126
  // command is applying. Writes stay on ENV_PATH: a writer that followed the file backwards would keep an
127
127
  // install in the directory npm replaces forever.
@@ -145,7 +145,7 @@ const PROBE_PROVIDERS = has("--probe-providers");
145
145
  const PROBE_CONNECTOR = has("--probe-connector");
146
146
 
147
147
  // ── output ───────────────────────────────────────────────────────────────────────────────────────────
148
- // WEIGHT WHERE THE MEANING IS (owner ruling 2026-08-31). Every line here used to
148
+ // WEIGHT WHERE THE MEANING IS (ruling 2026-08-31). Every line here used to
149
149
  // be the same weight, which is why the passphrase and the coverage reason both disappeared into their
150
150
  // surroundings — two defects on that issue that are formatting defects wearing other clothes.
151
151
  //
@@ -175,7 +175,7 @@ const blocking = (s) => { blockers.push(s); say(` ${style.warn("!")} ${s}`); };
175
175
  // ── A FOURTH STATE, AND THE COMPOSITE EXIT CONTRACT IT COMPLETES ────────────────────────────────────
176
176
  //
177
177
  // `inertSetting` is for configuration the operator SET that the build does not read: a retired
178
- // spelling left behind by an upgrade (tracker issue 168), and anything else of that shape. It needed
178
+ // spelling left behind by an upgrade, and anything else of that shape. It needed
179
179
  // its own state because neither existing one is honest about it.
180
180
  //
181
181
  // `warn` prints `!` and keeps rc 0 — but rc 0 is the exact reading that let thirteen ignored
@@ -190,10 +190,10 @@ const blocking = (s) => { blockers.push(s); say(` ${style.warn("!")} ${s}`); };
190
190
  // ── THE CONTRACT, STATED ONCE BECAUSE TWO ISSUES IN ONE BUNDLE BOTH CHANGE IT ───────────────────────
191
191
  //
192
192
  // rc 1 means: SOMETHING THE OPERATOR SET IS NOT DOING WHAT THEY THINK IT IS DOING.
193
- // · `problem` — set wrongly. A credential present but unusable is this (tracker issue 173): the
193
+ // · `problem` — set wrongly. A credential present but unusable is this: the
194
194
  // file exists, doctor called it enrolled, and the report then disclosed an outage
195
195
  // that never happened.
196
- // · `inertSetting` — set, and not read at all (tracker issue 168).
196
+ // · `inertSetting` — set, and not read at all.
197
197
  // rc 0 means: nothing set is being ignored or misapplied. An ABSENCE stays rc 0 — a fresh machine is
198
198
  // not a broken one, and `blocking` exists so an absence that stops the product is still said out loud.
199
199
  //
@@ -316,7 +316,7 @@ const prose = (...parts) => { for (const l of wrapProse(parts.join(" "), proseWi
316
316
  * @returns {"no-sources"|"unbuilt"|"unversioned"|"guarded"|"tracked-unguarded"|"unmeasured"|"current"|"stale"}
317
317
  */
318
318
  // `bundleFreshness` and its three readers moved to `shared/bundle-freshness.mjs` when `/portal/health`
319
- // became a second caller (tracker issue 160): health answered `ui: "built", ok: true` over a tree this
319
+ // became a second caller: health answered `ui: "built", ok: true` over a tree this
320
320
  // command refuses at rc 1, because the two surfaces each had their own idea of what a usable bundle is.
321
321
  // One definition, both readers, so they cannot disagree again.
322
322
 
@@ -565,7 +565,7 @@ export async function offerUsptoSync(dbPath, io, deps = {}) {
565
565
  // sales-gated. It used to open with the free tier, and a test asserted that it must — an assertion that
566
566
  // argued a case the ADR had already answered. The reasoning is in ADR-0001 and in providers/README.md,
567
567
  // which is the canonical statement; this list carries no competing recommendation of its own.
568
- // ── THE REGISTER SELECTION TABLE MOVED TO `shared/register-selection.mjs` (tracker issue 216) ───────
568
+ // ── THE REGISTER SELECTION TABLE MOVED TO `shared/register-selection.mjs` ───────────────────────────
569
569
  //
570
570
  // Re-exported here under the name every existing reader uses — this file's own call sites read
571
571
  // `PROVIDERS`, and so does the run-requirements arm. It moved because the runner's intake wall and the
@@ -592,40 +592,47 @@ export const AMBIENT_KEYS = [
592
592
  const present = (v) => typeof v === "string" && v.trim() !== "";
593
593
 
594
594
  /**
595
- * Ask who signs in, and take a real yes for the access rule that address implies.
595
+ * Ask who signs in, and what their organisation is called.
596
596
  *
597
- * ── WHY THIS QUESTION EXISTS AT ALL ─────────────────────────────────────────────────────────────────
597
+ * ── WHY THE ADDRESS IS ASKED AT ALL ─────────────────────────────────────────────────────────────────
598
598
  *
599
599
  * Setup never asked for an address. `clearotron start` therefore took one from `--user`, from
600
- * `PORTAL_LOCAL_USER`, or from the local account as `<account>@localhost` — and derived a STAFF RULE
601
- * from everything after its `@`. On the local-account default that rule is `localhost`: one machine,
602
- * one identity, nothing granted to anyone. Given a real address it is that address's whole domain, and
603
- * the settings page then reports it back as "Anyone at <domain> — a rule, not a person".
600
+ * `PORTAL_LOCAL_USER`, or from the local account as `<account>@localhost`. An outside install reached a
601
+ * state nobody chose that way: an assistant filled a documentation address into the environment file on
602
+ * the operator's behalf, because this wizard had no prompt for it. So the address is asked for here,
603
+ * where a person is definitionally present.
604
604
  *
605
- * An outside install reached exactly that state. Nobody typed the address into this wizard, because
606
- * this wizard had no prompt for it; an assistant filled one into the environment file on the operator's
607
- * behalf, and the install granted a documentation domain. They read their own settings page and
608
- * reported it as a back door. They were right to: a grant to a group had been made, by nobody.
605
+ * WHAT IT NO LONGER DECIDES. The part after its `@` used to decide who was an administrator: everyone at
606
+ * that domain was staff. That rule is deleted, with the question about granting the domain and the
607
+ * setting it wrote. The address is the first person on the install — Run, Manage, access to everything —
608
+ * written as their own entry in the grants file by `clearotron start` (`installerGrants`), and it admits
609
+ * nobody else whatever its domain.
609
610
  *
610
- * So the address is asked for here, where a person is definitionally present, and the grant it implies
611
- * is shown in the words the settings page uses BEFORE it is written. `start` no longer guesses: it
612
- * refuses a rule wider than one machine unless `PORTAL_STAFF_DOMAINS` says so in writing, which is what
613
- * a yes here writes.
611
+ * TWO REFUSALS ABOUT THE ADDRESS ITSELF STAY: not a single email address, and a public or reserved
612
+ * domain (shared/staff-domain.mjs, shared with `clearotron start` so the two refuse the same addresses).
613
+ *
614
+ * THE ORGANISATION'S NAME, directly after the address and required. It is the organisation the install
615
+ * starts with and the label the portal shows for it; `clearotron start` files it in the grants file the
616
+ * first time that file holds none, and it is renamed there. WRITTEN QUOTED, because the loader reads
617
+ * `.env` with Node's `parseEnv`, which ends an unquoted value at `#` and trims it — measured: "Café #1
618
+ * Sàrl" written bare comes back "Café". Quoted it survives whole, except a double quote, which ends the
619
+ * value, so a name holding one is sent back.
614
620
  *
615
621
  * THE DEFAULT IS THE LOCAL ACCOUNT AND NOTHING ELSE — not the git author, not the hostname, not a shell
616
- * variable. An identity that becomes an access rule comes from the person, or from the one source that
622
+ * variable. The address that becomes the first person comes from the person, or from the one source that
617
623
  * cannot name a second person.
618
624
  *
619
- * `io` IS INJECTED for the reason `offerUsptoSync`'s is: the branch that matters is the one where a
620
- * reader pressed Enter at the grant question and NOTHING must be granted, and closed over a terminal
621
- * that branch is asserted nowhere. Returns the `.env` keys to write and nothing else.
625
+ * `io` IS INJECTED for the reason `offerUsptoSync`'s is: the branches that matter are the answers that
626
+ * must be sent back, and closed over a terminal they are asserted nowhere. Returns the `.env` keys to
627
+ * write and nothing else.
622
628
  */
623
- export async function askSignIn(io, { localAccount = "user", staffLabel = "Staff", envPath = "" } = {}) {
624
- const { askValue, confirm, say = () => {}, ok = () => {}, info = () => {},
625
- warn = () => {}, problem = () => {} } = io;
629
+ export async function askSignIn(io, { localAccount = "user" } = {}) {
630
+ const { askValue, ok = () => {}, problem = () => {} } = io;
626
631
  const localDefault = `${localAccount}@localhost`;
627
- prose("The portal admits one address on this install, and that address is also what decides who is an",
628
- "administrator. Enter accepts the local-account form, which is this machine and nobody else.");
632
+ prose("The portal admits one address on this install, and that address is the first person on it:",
633
+ "access to everything, with both permissions — Run clearances and Manage. Enter accepts the",
634
+ "local-account form, which is this machine and nobody else.");
635
+ let address;
629
636
  for (;;) {
630
637
  const typed = String(await askValue("Sign-in address:", { def: localDefault })).trim().toLowerCase();
631
638
  if (!typed.includes("@") || typed.indexOf("@") !== typed.lastIndexOf("@")) {
@@ -633,35 +640,27 @@ export async function askSignIn(io, { localAccount = "user", staffLabel = "Staff
633
640
  + "so this would sign in and then be denied at the door.");
634
641
  continue;
635
642
  }
636
- const domain = domainOfEmail(typed);
637
- const verdict = classifyStaffDomain(domain);
638
- if (verdict === "public" || verdict === "reserved") {
639
- // The classifier's own sentence, never a second copy: `clearotron start` prints these same words
640
- // when it meets the same domain, and two wordings of one refusal is how a reader comes to believe
641
- // they have met two different problems.
642
- problem(staffDomainRefusal(domain));
643
+ // The refusal's own sentence, never a second copy: `clearotron start` prints these same words when it
644
+ // meets the same address, and two wordings of one refusal is how a reader comes to believe they have
645
+ // met two different problems.
646
+ const refusal = addressRefusal(typed);
647
+ if (refusal) { problem(refusal); continue; }
648
+ address = typed;
649
+ break;
650
+ }
651
+ ok(`${address} — the first person on this install, with access to everything.`);
652
+ for (;;) {
653
+ const name = String(await askValue("Your organisation's name:") ?? "").trim();
654
+ if (!name) {
655
+ problem("A name is needed here: it is the organisation this install starts with. It can be renamed later.");
643
656
  continue;
644
657
  }
645
- if (verdict === "narrow") {
646
- ok(`${typed} — this machine only. No domain rule is written, and nobody else is granted anything.`);
647
- return { PORTAL_LOCAL_USER: typed };
658
+ if (name.includes('"')) {
659
+ problem(`${name} holds a double quote, and the settings file cannot keep one — it ends the value. Leave it out.`);
660
+ continue;
648
661
  }
649
- // `wide`: a real domain, so the grant is a grant. Stated first, in the settings page's own words,
650
- // and Enter is NOT a yes — the default is no, because this is the one answer in the wizard that
651
- // admits people the reader has never met.
652
- say("");
653
- warn(`${typed} makes this a rule about ${domain}, not about you.`);
654
- say(` ${staffGrantSentence(domain, { staffLabel })}`);
655
- say("");
656
- say(` Everyone at ${domain} who gets past this install's sign-in door would see every brand owner`);
657
- say(" on it — every clearance, every report, every configuration. On a laptop that is only you,");
658
- say(" because only one address can sign in. Behind a company login it is the whole domain.");
659
- say("");
660
- if (await confirm(`Grant ${domain} that, and write it down as PORTAL_STAFF_DOMAINS?`, false))
661
- return { PORTAL_LOCAL_USER: typed, PORTAL_STAFF_DOMAINS: domain };
662
- info(`nothing granted${envPath ? `, and nothing written to ${envPath}` : ""}. ${typed} would sign in `
663
- + "and every page would refuse it, because signing in is not being enrolled — so choose the "
664
- + "local-account form, or answer yes above.");
662
+ ok(`${name} — your organisation. \`clearotron start\` files it in the grants file, where it can be renamed.`);
663
+ return { PORTAL_LOCAL_USER: address, CLEAROTRON_ORGANISATION_NAME: `"${name}"` };
665
664
  }
666
665
  }
667
666
 
@@ -815,7 +814,7 @@ export function engineOptions() {
815
814
  export function readEnvFile(path, { home = homedir() } = {}) {
816
815
  if (!existsSync(path)) return {};
817
816
  const env = {};
818
- // NAMED, NOT INFERRED (tracker issue 179). This passed `repoRoot: dirname(path)` and relied on the
817
+ // NAMED, NOT INFERRED. This passed `repoRoot: dirname(path)` and relied on the
819
818
  // loader's resolution happening to land back on the same directory. It did — through the LEGACY
820
819
  // fallback, and only while the reader had no `~/.config/clearotron/.env` of their own. With one, this
821
820
  // function returned THAT file's contents for any path it was given, including a temporary fixture.
@@ -1128,7 +1127,7 @@ export async function runCheck() {
1128
1127
  warn(`could not tell whether running programs are on the current tree: ${running.detail}`);
1129
1128
  }
1130
1129
 
1131
- // ── AND THE MORE DANGEROUS ANSWER: A DIFFERENT TREE, NOT AN OLDER ONE (tracker issue 193) ──────────
1130
+ // ── AND THE MORE DANGEROUS ANSWER: A DIFFERENT TREE, NOT AN OLDER ONE ──────────────────────────────
1132
1131
  //
1133
1132
  // The block above asks whether a process predates this checkout's last move. This asks whether it is
1134
1133
  // executing a DIFFERENT checkout entirely, which is what `clearotron connect` used to cause silently
@@ -1231,17 +1230,19 @@ export async function runCheck() {
1231
1230
  home: homedir() })
1232
1231
  : null;
1233
1232
 
1234
- // ── AND THE DOOR SECTION IS THE THIRD SUCH SECTION (tracker issue 226) ──────────────────────────
1233
+ // ── AND THE DOOR SECTION IS THE THIRD SUCH SECTION ──────────────────────────────────────────────
1235
1234
  //
1236
1235
  // The reading above was hoisted here from further down for this. It used to be resolved BELOW the
1237
1236
  // door check, so the one section making the loudest claim about the running service — "NOBODY can
1238
1237
  // use this portal" — was the one section that could not consult it.
1239
1238
  //
1240
1239
  // MEASURED 2026-09-06 on a healthy packaged install: `doctor` printed that ✗ and exited 1 on a box
1241
- // where the local user signs in and `GET /portal/api/me` returns `{"role":"staff"}`. The units' file
1242
- // carries `PORTAL_STAFF_DOMAINS=localhost`, the identity is `<user>@localhost`, and the running
1243
- // service admits it as staff. This command reads the CLI's own `.env`, where neither that name nor
1244
- // `PORTAL_AUTH_MODE` appears — so the check was not ignoring the auth mode, it never saw it.
1240
+ // where the local user signs in and `GET /portal/api/me` returned `{"role":"staff"}`. The units' file
1241
+ // carried `PORTAL_STAFF_DOMAINS=localhost` (a staff-domain rule, since deleted), the identity was
1242
+ // `<user>@localhost`, and the running service admitted it as staff. This command read the CLI's own
1243
+ // `.env`, where neither that name nor `PORTAL_AUTH_MODE` appeared — so the check was not ignoring the
1244
+ // auth mode, it never saw it. What admits a person now is their entry in the grants file, and the
1245
+ // units' file is still the only place that names which grants file.
1245
1246
  //
1246
1247
  // The disclosure was already here and one severity too quiet: a `·` saying "what THIS environment
1247
1248
  // implies, not what the running service serves", directly above a `✗` phrased in the present
@@ -1460,7 +1461,13 @@ export async function runCheck() {
1460
1461
  // The auth variables ride this list for the same reason CLEAROTRON_AI does: without them the
1461
1462
  // probe bills the way the AMBIENT environment says rather than the way this .env says, and
1462
1463
  // "proven" would name a lane no run takes.
1463
- for (const k of ["CLEAROTRON_AI", ...Object.values(ENGINE_BINARIES).flatMap((s) => [s.env, s.authEnv])]) {
1464
+ //
1465
+ // THE CREDENTIALS RIDE IT TOO, and did not. This was a second copy of the list `applyEngineEnv`
1466
+ // applies, and it had dropped the API key and the headless token — so a token kept only in this
1467
+ // install's .env never reached the probe, which then reported the engine signed out while the same
1468
+ // token exported into the shell proved it. `engineEnvKeys` is the probe's own list: the copy that is
1469
+ // filled is now the copy that is applied.
1470
+ for (const k of engineEnvKeys()) {
1464
1471
  const e = effective(k);
1465
1472
  if (e) probeEnv[k] = e.v;
1466
1473
  }
@@ -1516,7 +1523,7 @@ export async function runCheck() {
1516
1523
  }
1517
1524
 
1518
1525
  say("\n .env");
1519
- // ── THE FILE MOVED, SO ASK THE SAME QUESTION THE LOADER ASKS (tracker issue 159) ─────────────────
1526
+ // ── THE FILE MOVED, SO ASK THE SAME QUESTION THE LOADER ASKS ─────────────────────────────────────
1520
1527
  //
1521
1528
  // `.env` now resolves to `~/.config/clearotron/.env`, and an install configured before that ruling has
1522
1529
  // it at the old path, where the loader still reads it. Doctor reporting "no .env" over a file every
@@ -1600,7 +1607,7 @@ export async function runCheck() {
1600
1607
  const mod = await import(`../driver/profiles.mjs?doctor=${Date.now()}`);
1601
1608
  const r = mod.profileStoreResolution();
1602
1609
  const where = `profiles resolve from ${r.store}`;
1603
- // ── ON A HOSTED BOX THE SERVICES' ANSWER COMES FIRST (tracker issue 223) ────────────────────────
1610
+ // ── ON A HOSTED BOX THE SERVICES' ANSWER COMES FIRST ────────────────────────────────────────────
1604
1611
  //
1605
1612
  // One run of this command reported the SAME variable as both set and unset, and concluded a
1606
1613
  // production box was a demo install:
@@ -1672,7 +1679,7 @@ export async function runCheck() {
1672
1679
  // The reader is told they have a customer and not told what they are running on. Both halves
1673
1680
  // wrong from one list.
1674
1681
  //
1675
- // SINCE 2026-09-08 A FRESH INSTALL RESOLVES `generic` ALONE (owner ruling): nobody should have to
1682
+ // SINCE 2026-09-08 A FRESH INSTALL RESOLVES `generic` ALONE (ruling): nobody should have to
1676
1683
  // clean demo material out of an environment they just created. So the demo branch below no longer
1677
1684
  // fires on a plain install — it fires inside the demo, which asks for its own account. It is kept
1678
1685
  // rather than deleted because it is still reachable, and a reader who meets the demo account
@@ -1727,7 +1734,7 @@ export async function runCheck() {
1727
1734
  // a named disagreement instead of as a tenancy refusal a reader will read as a permissions problem.
1728
1735
  try {
1729
1736
  const cs = await import(`../shared/customer-store.mjs?doctor=${Date.now()}`);
1730
- // ── ONE ENVIRONMENT ON BOTH SIDES, AND THE LINE SAYS WHICH (tracker issue 223) ────────────────
1737
+ // ── ONE ENVIRONMENT ON BOTH SIDES, AND THE LINE SAYS WHICH ────────────────────────────────────
1731
1738
  //
1732
1739
  // This compared a surface resolved from THIS PROCESS's environment against a roster resolved
1733
1740
  // from this process's module load — so on a hosted box it certified agreement between two
@@ -1744,7 +1751,7 @@ export async function runCheck() {
1744
1751
  + `${split.surface}, runs: ${split.roster}. A brand owner in one and not the other is refused `
1745
1752
  + `by the surface with a tenancy message, which reads as a permissions problem and is not one.`);
1746
1753
  } else {
1747
- // ── WHAT THIS ✓ CERTIFIES, AND WHAT IT CANNOT (tracker issue 223) ──────────────────────────
1754
+ // ── WHAT THIS ✓ CERTIFIES, AND WHAT IT CANNOT ──────────────────────────────────────────────
1748
1755
  //
1749
1756
  // Both sides derive from `CLEAROTRON_CUSTOMERS_DIR`, which is what 1923 settled — so this
1750
1757
  // cannot catch two environments disagreeing, and reading it as "production is configured"
@@ -1784,7 +1791,7 @@ export async function runCheck() {
1784
1791
  try {
1785
1792
  const report = overlayReport({ baseRoot: config.skillsBaseDir, overlayRoot: config.skillsOverlayDir });
1786
1793
  for (const line of renderOverlayReport(report, { indent: "" })) say(` ${line}`);
1787
- // ── AND THE SERVICES MAY HAVE ONE THIS PROCESS CANNOT SEE (tracker issue 223) ───────────────────
1794
+ // ── AND THE SERVICES MAY HAVE ONE THIS PROCESS CANNOT SEE ───────────────────────────────────────
1788
1795
  //
1789
1796
  // `config.skillsOverlayDir` resolves from this process's environment. On a production box that
1790
1797
  // printed "none configured — this install overrides nothing" while CLEAROTRON_INSTRUCTIONS_DIR was
@@ -1869,6 +1876,32 @@ export async function runCheck() {
1869
1876
  // doctor that hangs on a box with no route — so this reports ENROLMENT, and says plainly that a
1870
1877
  // source which is enrolled can still fail at run time. The owner's own report says `CONNECTION_CLOSED`
1871
1878
  // on exactly that case.
1879
+ // ── WILL A SEARCH RUN? — THE ORDER-TIME GATE'S OWN QUESTION, OF THE ENVIRONMENT A RUN WILL HAVE ──
1880
+ //
1881
+ // Every section above reads one requirement from YOUR shell and this command's .env. A search is
1882
+ // refused by a different reader: `orderTimeRefusal`, at order time, against the runner's environment —
1883
+ // a background unit's, on a box that has them. So this command could pass an install the run then
1884
+ // refused: it found `claude` on this shell's PATH while the unit, which has no such PATH, was refused
1885
+ // for an engine path it had never been given. Asking the gate's own authority of the units' environment
1886
+ // is what makes an install this command passes one that can run. `blocking`, not `problem`: an install
1887
+ // that is not configured yet is unfinished rather than broken, and the exit status keeps that contract.
1888
+ say("\n Will a search run?");
1889
+ if (!serviceKnown) {
1890
+ info("the units' environment could not be read, so what a search would be refused for is NOT checked here — a failure to look is not a clean result");
1891
+ } else {
1892
+ const tables = { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: DEFAULT_ENGINE_ID };
1893
+ // Two passes: which credentials a run needs depends on the register and the engine it names.
1894
+ const view = {};
1895
+ const fill = (names) => { for (const n of names) { const e = effectiveForService(n); if (e) view[n] = e.v; } };
1896
+ fill([REGISTER_ENV, ENGINE_ENV]);
1897
+ fill(runRequiredNames(view, tables));
1898
+ const { atOrder } = missingRequirements(view, tables);
1899
+ if (atOrder.length) {
1900
+ blocking(`a search is refused until ${atOrder.length === 1 ? "this is" : "these are"} set in ${serviceEnvLabel}: ${atOrder.map((r) => r.name).join(", ")}`);
1901
+ for (const r of atOrder) info(` ${r.name} — ${r.why}`);
1902
+ } else ok(`nothing a search is refused for at order time is missing from ${serviceEnvLabel}`);
1903
+ }
1904
+
1872
1905
  say("\n Case law and other capabilities");
1873
1906
  {
1874
1907
  const { caseLawInventory } = await import("../driver/config-inventory.mjs");
@@ -1886,7 +1919,7 @@ export async function runCheck() {
1886
1919
  // other search is unaffected — so it is a `warn`, which says it out loud and leaves the exit code
1887
1920
  // alone. Nothing here can be misconfigured: there is no variable to get wrong.
1888
1921
  //
1889
- // ── AND A PRESENT-BUT-BROKEN CREDENTIAL IS NEITHER (tracker issue 173) ─────────────────────
1922
+ // ── AND A PRESENT-BUT-BROKEN CREDENTIAL IS NEITHER ─────────────────────────────────────────
1890
1923
  //
1891
1924
  // It is a MISCONFIGURATION: the operator enrolled, the file is there, and it cannot work. That
1892
1925
  // exits non-zero under this command's contract, and it must not be filed under the absence rule
@@ -1991,7 +2024,7 @@ export async function runCheck() {
1991
2024
  blocking(`the portal bundle could not be read — ${e.message} — so whether /portal can render is unknown`);
1992
2025
  }
1993
2026
 
1994
- // ── THE TRIGGER KEY, AND THE DEADLINE NOBODY WAS COUNTING (tracker issue 161) ────────────────────
2027
+ // ── THE TRIGGER KEY, AND THE DEADLINE NOBODY WAS COUNTING ────────────────────────────────────────
1995
2028
  //
1996
2029
  // A `--background` install stores the portal's trigger key in `~/.env`, the file the units load, and
1997
2030
  // it is minted with a thirty-day life. Nothing counted it down. Thirty days after an install, on a
@@ -2034,6 +2067,60 @@ export async function runCheck() {
2034
2067
  } else {
2035
2068
  ok(`the trigger key is good for ${posture.daysLeft} more day(s), until ${posture.expiresAt}`);
2036
2069
  }
2070
+
2071
+ // ── AND WHICH COMPANIES IT CAN START ────────────────────────────────────────────────────────
2072
+ //
2073
+ // THE THIRD SURFACE, WHICH WAS NEVER WIRED. `triggerCapWarning` exists so the create command, the
2074
+ // portal's boot log and this command cannot say three different things about one gap — its own
2075
+ // comment says "three call sites" — and it had two. The command whose entire job is to report what
2076
+ // a machine is configured for said nothing at all about a cap that decides which companies can be
2077
+ // started. Three is true now, rather than aspirational.
2078
+ //
2079
+ // IT IS `info`, DELIBERATELY, AND THE SENTENCE IS WHY. The portal re-takes its engine credential at
2080
+ // the start of every call, so a stale cap refuses nothing on the ordinary path; the gap bites only
2081
+ // when it CANNOT re-mint. A `problem` here would exit non-zero on installs whose portal re-mints
2082
+ // perfectly well, which is a green install turned red for a refusal that will not happen — and it
2083
+ // would contradict the very sentence it prints. The level is worth revisiting the day the fallback
2084
+ // stops being the exception.
2085
+ //
2086
+ // THE ROSTER IS THE SERVICES', not this process's. A CLI is not started by the units'
2087
+ // EnvironmentFile, and the two answers disagreeing IS a defect this box has produced: a door
2088
+ // resolving the bundled demo roster while the configured store held one more company. Comparing
2089
+ // the units' token against a roster the units never see would report a gap that is not there, or
2090
+ // miss one that is. Which store was read is said, every time.
2091
+ if (posture.readable && posture.accountCapped) {
2092
+ const svcStore = (hosted && serviceKnown) ? (effectiveForService("CLEAROTRON_CUSTOMERS_DIR")?.v || null) : null;
2093
+ let roster = null, why = null;
2094
+ try {
2095
+ const { loadProfiles } = await import(`../driver/profiles.mjs?doctorcap=${Date.now()}`);
2096
+ const { rosterAsItStands } = await import("../driver/company-bundle.mjs");
2097
+ roster = [...rosterAsItStands(svcStore ?? undefined, loadProfiles).keys()];
2098
+ } catch (e) { why = String(e?.message ?? e); }
2099
+
2100
+ // AN EMPTY ROSTER IS NOT A COVERED ONE. `rosterAsItStands` answers an empty Map for a store with
2101
+ // no `generic.json`, and an empty roster leaves NOTHING uncovered — a tick over a could-not-look,
2102
+ // which is the one answer this command must never give.
2103
+ // ALREADY SAID ONCE, AND ONCE IS ENOUGH. A store that refuses to load is reported as a problem by
2104
+ // the roster check a few sections up, in its own words and with its own remedy. Repeating it here
2105
+ // as a second blocker put one fact on the screen three times — the problem, this line, and this
2106
+ // line again in the closing summary — which is how a reader learns to skim the section. Measured
2107
+ // by driving both, with an uncapped token as the control so the duplicate could be told from the
2108
+ // original. What this line adds is the CONSEQUENCE FOR THE CAP, which the roster check does not
2109
+ // know about, so it says that and defers.
2110
+ if (why) {
2111
+ info("whether the trigger key covers them cannot be checked until the roster above loads — not "
2112
+ + "confirmed, and not refused");
2113
+ } else if (!roster.length) {
2114
+ blocking(`the trigger key names a company cap, and no companies could be read from `
2115
+ + `${svcStore ?? "the store this process resolves"} — so nothing here says whether the cap fits`);
2116
+ } else {
2117
+ const { triggerCapGap, triggerCapWarning } = await import("../driver/trigger-cap.mjs");
2118
+ const gap = triggerCapGap({ accounts: posture.accounts, roster });
2119
+ if (gap.uncovered.length) info(triggerCapWarning(gap));
2120
+ else ok(`the trigger key covers all ${roster.length} company(ies) in `
2121
+ + `${svcStore ?? "the store this process resolves"}`);
2122
+ }
2123
+ }
2037
2124
  }
2038
2125
  }
2039
2126
 
@@ -2061,7 +2148,7 @@ export async function runCheck() {
2061
2148
  // The reading is still worth printing — it is what a hand-run process here would use, and it is
2062
2149
  // what `clearotron start` would launch from. It is stated as that, and the sentence below says
2063
2150
  // where the running answer lives.
2064
- // NAMED, NOT CAVEATED (tracker issue 226). This used to disclaim itself — "what THIS environment
2151
+ // NAMED, NOT CAVEATED. This used to disclaim itself — "what THIS environment
2065
2152
  // implies, not what the running service serves" — directly above a ✗ that asserted a lockout on the
2066
2153
  // live box. The values now come from the file the service actually loads, so the line says which
2067
2154
  // file that was and the verdicts below stand on it.
@@ -2093,68 +2180,66 @@ export async function runCheck() {
2093
2180
  //
2094
2181
  // The door is only half the question. The owner signed into the test portal and every action
2095
2182
  // refused — submitting a search, saving a custom search, loading the Generic defaults — because his
2096
- // identity was on no staff domain and in no grants row, so it held no accounts. Three symptoms,
2097
- // one cause, and the product KNEW: portal-service logs a warning at boot naming the identity, both
2098
- // conditions, both variables and the remedy. It was invisible to everyone who needed it, because
2099
- // the operator was in a browser and whoever restarted the service checked ports, not the log.
2183
+ // identity held no access in the grants file. Three symptoms, one cause, and the product KNEW:
2184
+ // portal-service logs a warning at boot naming the identity and the remedy. It was invisible to
2185
+ // everyone who needed it, because the operator was in a browser and whoever restarted the service
2186
+ // checked ports, not the log.
2100
2187
  //
2101
- // ASKED OF `makePrincipal`, the function the door itself uses, so this cannot drift from the
2102
- // refusal it predicts. A second opinion about who holds access is how a report comes to disagree
2103
- // with the thing it reports on.
2188
+ // ASKED OF `makePrincipal`, the function the door itself uses, for every address the grants file
2189
+ // names — so this cannot drift from the refusal it predicts. A person is admitted by their own entry
2190
+ // and nothing else: an entry under `people` with access to everything admits with no tenant row at
2191
+ // all, and an entry holding only switches admits nobody, so counting rows in either section would be
2192
+ // a second opinion about who holds access, which is how a report comes to disagree with the thing it
2193
+ // reports on.
2104
2194
  //
2105
- // OFFLINE, and that is what makes it worth having here: if no staff domain is set AND the grants
2106
- // file holds no rows, then NO identity can hold access — whoever signs in, whatever the door. That
2107
- // is knowable from the filesystem, so `--check` can answer it without calling anybody, which is
2108
- // this command's whole contract.
2195
+ // OFFLINE, and that is what makes it worth having here: if the grants file admits no address, then
2196
+ // NO identity can hold access — whoever signs in, whatever the door. That is knowable from the
2197
+ // filesystem, so `--check` can answer it without calling anybody, which is this command's whole
2198
+ // contract.
2109
2199
  try {
2110
2200
  const { makePrincipal } = await import("../driver/portal-access.mjs");
2111
- // ASKED OF THE SERVICE'S OWN ENVIRONMENT (tracker issue 226). Reading this command's file here is
2112
- // what produced a hard ✗ claiming nobody could use a portal that was admitting its operator as
2113
- // staff on every request.
2114
- const staffDomains = String(effectiveForService("PORTAL_STAFF_DOMAINS")?.v ?? "")
2115
- .split(",").map((d) => d.trim()).filter(Boolean);
2201
+ // ASKED OF THE SERVICE'S OWN ENVIRONMENT. Reading this command's file here is what produced a hard
2202
+ // ✗ claiming nobody could use a portal that was admitting its operator on every request.
2116
2203
  const grantsFile = effectiveForService("CLEAROTRON_ACCESS_FILE")?.v ?? "";
2117
2204
  let grants = null, unreadable = null;
2118
2205
  if (grantsFile) {
2119
2206
  try { grants = JSON.parse(readFileSync(grantsFile, "utf8")); }
2120
2207
  catch (e) { unreadable = String(e?.message ?? e).slice(0, 120); }
2121
2208
  }
2122
- const rows = Object.values(grants?.tenants ?? {})
2123
- .reduce((n, t) => n + Object.keys(t?.users ?? {}).length, 0);
2209
+ const named = new Set([...Object.keys(grants?.people ?? {}),
2210
+ ...Object.values(grants?.tenants ?? {}).flatMap((t) => Object.keys(t?.users ?? {}))]);
2211
+ const admitted = [...named].filter((address) => makePrincipal({ email: address, grants }));
2124
2212
 
2125
- // A FAILURE TO LOOK IS NOT A LOCKOUT (tracker issue 226). On a hosted box whose unit environment
2213
+ // A FAILURE TO LOOK IS NOT A LOCKOUT. On a hosted box whose unit environment
2126
2214
  // could not be read, every name above resolves empty — which is indistinguishable from a box that
2127
2215
  // has genuinely configured nothing, and would print the loudest ✗ in this command on no evidence.
2128
2216
  if (!serviceKnown) {
2129
2217
  info("who may use this portal is not judged here: the units' environment could not be read, so a "
2130
- + "staff domain or a guest list configured there would be invisible to this check");
2218
+ + "grants file configured there would be invisible to this check");
2131
2219
  } else if (unreadable) {
2132
2220
  problem(`the guest list at ${grantsFile} could not be read (${unreadable}) — the portal refuses `
2133
2221
  + `every request while that is true, and this is a failure to look rather than an empty list`);
2134
- } else if (!staffDomains.length && !rows) {
2222
+ } else if (!admitted.length) {
2135
2223
  // PRESENT-AND-WRONG vs ABSENT. A configured portal that grants nobody is wrong: every page
2136
2224
  // refuses and the symptom reads as a broken login. A box that has configured neither is a
2137
2225
  // fresh install, which is loud but not a failure — the same rule the rest of this command uses.
2138
2226
  const configured = Boolean(grantsFile) || Boolean(effectiveForService("PORTAL_AUTH_MODE")?.v);
2139
- const sentence = `NOBODY can use this portal, per ${serviceEnvFile}: no staff domain is set (PORTAL_STAFF_DOMAINS) and `
2140
- + `the guest list holds no rows${grantsFile ? ` (${grantsFile})` : " (CLEAROTRON_ACCESS_FILE is unset)"}. `
2227
+ const sentence = `NOBODY can use this portal, per ${serviceEnvFile}: the grants file gives nobody access`
2228
+ + `${grantsFile ? ` (${grantsFile})` : " (CLEAROTRON_ACCESS_FILE is unset)"}. `
2141
2229
  + "Any identity that signs in is refused at the door on every page, which reads as a broken "
2142
2230
  + `login rather than as missing access. Fix with \`${invocationPrefix()}clearotron grant add\`, `
2143
- + "or by setting a staff domain.";
2231
+ + `or give the person who installed an entry under "people" in that file, with "everything": true.`;
2144
2232
  if (configured) problem(sentence); else info(sentence);
2145
2233
  } else {
2146
- const who = [];
2147
- if (staffDomains.length) who.push(`${staffDomains.length} staff domain(s): ${staffDomains.join(", ")}`);
2148
- if (rows) who.push(`${rows} guest-list row(s)`);
2149
- ok(`somebody can use this portal — ${who.join(", ")}`);
2234
+ ok(`somebody can use this portal — ${admitted.length} address(es) in the grants file have access`);
2150
2235
  // AND THE ONE IDENTITY THIS BOX SIGNS IN, when the local door is what this environment implies.
2151
2236
  // NAMES the address because it is this operator's own, on their own box, in a report they asked
2152
2237
  // for — the same address `clearotron start` prints back at them.
2153
2238
  const localUser = effectiveForService("PORTAL_LOCAL_USER")?.v ?? "";
2154
2239
  if (door.shape === "local" && localUser) {
2155
- if (makePrincipal({ email: localUser, grants, staffDomains })) ok(` and ${localUser} is one of them`);
2156
- else problem(` but ${localUser} — the identity this box's local sign-in produces — is on no staff `
2157
- + `domain and in no guest-list row, so it signs in and is then refused on every page`);
2240
+ if (makePrincipal({ email: localUser, grants })) ok(` and ${localUser} is one of them`);
2241
+ else problem(` but ${localUser} — the identity this box's local sign-in produces — has no access in `
2242
+ + `${grantsFile}, so it signs in and is then refused on every page. Give it an entry under "people" there.`);
2158
2243
  }
2159
2244
  }
2160
2245
  } catch (e) {
@@ -2518,8 +2603,8 @@ export async function runCheck() {
2518
2603
  // rather than beside the unit, because the unit running and the address being reachable are
2519
2604
  // different facts and the second is the one a client depends on.
2520
2605
  const { clientDoorReachability } = await import(pathToFileURL(join(REPO, "shared", "client-door.mjs")).href);
2521
- // FROM THE FILE THE UNITS LOAD, NOT THE SHELL THIS COMMAND WAS TYPED IN (the tracker issue 226
2522
- // family, met again here). On a hosted box the published address lives in the units' environment,
2606
+ // FROM THE FILE THE UNITS LOAD, NOT THE SHELL THIS COMMAND WAS TYPED IN (the same family,
2607
+ // met again here). On a hosted box the published address lives in the units' environment,
2523
2608
  // and reading `process.env` reported "no client connector address is published — that is correct
2524
2609
  // for a local install" about a deployment that publishes one. Every verdict below rests on this
2525
2610
  // value, including the audience comparison, so a wrong reading here is not one wrong line.
@@ -2539,7 +2624,7 @@ export async function runCheck() {
2539
2624
  // returns null when the header is absent — a looked-and-none answer, not a did-not-look — and
2540
2625
  // the readers separate those, so a probe that omits the field reads as never-looked rather
2541
2626
  // than silently as "no challenge".
2542
- // THE REDIRECT TARGET TRAVELS WITH IT (tracker issue 241). This request is already made with
2627
+ // THE REDIRECT TARGET TRAVELS WITH IT. This request is already made with
2543
2628
  // `redirect: "manual"`, so the Location an unauthenticated caller is handed is right here —
2544
2629
  // and it is where Cloudflare Access puts the audience. Reading it from THIS response rather
2545
2630
  // than asking a second time keeps `doctor` to one request per address, and keeps both answers
@@ -2553,7 +2638,7 @@ export async function runCheck() {
2553
2638
  else if (reach.state === "fail") problem(reach.message);
2554
2639
  else info(reach.message);
2555
2640
 
2556
- // ── AND IS IT THE AUDIENCE THIS INSTALL CHECKS AGAINST? (tracker issue 241) ───────────────────
2641
+ // ── AND IS IT THE AUDIENCE THIS INSTALL CHECKS AGAINST? ───────────────────────────────────────
2557
2642
  //
2558
2643
  // The other half of the recreation trap, and the half with no symptom of its own. Deleting and
2559
2644
  // recreating an Access application changes the audience; the existing warning fires on the
@@ -2577,7 +2662,7 @@ export async function runCheck() {
2577
2662
  // unreachable; repeating it as a second failure teaches the reader that this section
2578
2663
  // double-counts. It still states what was NOT established, which is the whole job.
2579
2664
  else if (v.kind === "could-not-look") info(v.message);
2580
- // NO ACCESS IN FRONT OF THE CLIENT DOOR IS A POSTURE, NOT A FAULT (owner ruling 2026-09-08).
2665
+ // NO ACCESS IN FRONT OF THE CLIENT DOOR IS A POSTURE, NOT A FAULT (ruling 2026-09-08).
2581
2666
  //
2582
2667
  // This is the CLIENT connector's address, and how a client reaches it is the client's decision:
2583
2668
  // "client access sitting behind OAuth is totally up to a client — plenty might just run it token
@@ -2602,7 +2687,7 @@ export async function runCheck() {
2602
2687
  }
2603
2688
  }
2604
2689
 
2605
- // ── CAN AN ASSISTANT ACTUALLY SIGN IN? (tracker issue 149, opt-in) ────────────────────────────
2690
+ // ── CAN AN ASSISTANT ACTUALLY SIGN IN? (opt-in) ───────────────────────────────────────────────
2606
2691
  //
2607
2692
  // The check above reads the CHALLENGE FORM, which is the first of two provider settings that decide
2608
2693
  // this. The second — whether the vendor's own redirect address may register — has no symptom a
@@ -2799,7 +2884,7 @@ try {
2799
2884
  // is DERIVED: the engines from ENGINE_BINARIES, the search vendors from the same adapter tables the
2800
2885
  // prompts loop over, each wearing its own table row's where-to-get line. A hand-kept copy here would
2801
2886
  // be the SERPAPI defect again, one screen earlier. Everything is skippable; the list says so.
2802
- // THE BANNER — once, on entry, named (owner ruling 2026-08-31). Not on every
2887
+ // THE BANNER — once, on entry, named (ruling 2026-08-31). Not on every
2803
2888
  // command: a banner every verb prints is how a product becomes tiresome to use twice. It sits here
2804
2889
  // rather than at the top of main() so that `--check`, which writes nothing and is run repeatedly,
2805
2890
  // does not wear it either.
@@ -3052,8 +3137,8 @@ try {
3052
3137
  // the run has started. So setup will not write an engine it has not exercised — and the menu's last
3053
3138
  // row exists so that refusal always has somewhere to go.
3054
3139
  say("");
3055
- // — tracker issue 100 point 2, the owner's own words on reading the three lines this replaces:
3056
- // "wtf does this mean". They named `.env` and "stage failure" and "wearing the shape of a model
3140
+ // — the three lines this replaces were not understood by the reader they were written for.
3141
+ // They named `.env` and "stage failure" and "wearing the shape of a model
3057
3142
  // fault" at a reader who is about to answer yes or no, and every one of those is our vocabulary.
3058
3143
  //
3059
3144
  // THE CHECK IS UNCHANGED AND THE REFUSAL IS UNCHANGED. He valued both, and the issue puts them out
@@ -3162,7 +3247,7 @@ try {
3162
3247
  for (const l of spec.signup) say(` ${l}`);
3163
3248
  say("");
3164
3249
  }
3165
- // ── — INSTALL MAY FINISH WITH NO REGISTER. Owner ruling, 2026-08-26 ──────────────────────
3250
+ // ── — INSTALL MAY FINISH WITH NO REGISTER. Ruling, 2026-08-26 ──────────────────────
3166
3251
  //
3167
3252
  // Every row of PROVIDERS declares required credentials, and this prompt had no way out, so a reader
3168
3253
  // with no vendor account could not reach the closing screen at all — the menu offers no "none" row,
@@ -3248,8 +3333,8 @@ try {
3248
3333
  }
3249
3334
  }
3250
3335
  if (registerSelected && !spec.validateEuipo && !spec.uspToLocalKey) {
3251
- // — tracker issue 100 point 7. The owner asked "why not validate the register provider quickly?" and
3252
- // the issue sets the fallback where a cheap non-billing call does not exist: say in ONE LINE why it
3336
+ // — decided: validate the register provider quickly where that is cheap, and where it is not,
3337
+ // set the fallback so a check that cannot be made says in ONE LINE why it
3253
3338
  // cannot be checked AND what happens if it is wrong. The first half was already here; the second was
3254
3339
  // not, so a reader was told about our billing scruple and nothing about their own risk.
3255
3340
  //
@@ -3371,7 +3456,7 @@ try {
3371
3456
  ok(`CLEAROTRON_REPORTS_URL=${trimmed}`);
3372
3457
  }
3373
3458
 
3374
- // 7a-bis ── THE ADDRESS CLIENTS REACH THE CONNECTOR AT (owner ruling 2026-09-03, Q2)
3459
+ // 7a-bis ── THE ADDRESS CLIENTS REACH THE CONNECTOR AT (ruling 2026-09-03, Q2)
3375
3460
  //
3376
3461
  // *"The installer asks once, at install, on the box. It is a deployment setting, never a per-person
3377
3462
  // one. Changing it later is editing that one setting and restarting."*
@@ -3501,21 +3586,18 @@ try {
3501
3586
  say(" fallback — falls through by name, so an empty store is a working install. Your own customers");
3502
3587
  say(" are added here by name; the bundled demo customers never show through into your roster.");
3503
3588
 
3504
- // 7c ── WHO SIGNS IN, AND WHAT THAT ADDRESS GRANTS
3589
+ // 7c ── WHO SIGNS IN, AND WHAT THEIR ORGANISATION IS CALLED
3505
3590
  //
3506
- // Setup never asked for an address, so `clearotron start` derived one — and derived a staff-domain
3507
- // rule from everything after its `@`. The whole reasoning, and the install that granted a
3508
- // documentation domain to a reader who had never been asked, is on `askSignIn` above.
3591
+ // The address is the first person on the install and the name is its first organisation. The whole
3592
+ // reasoning, including the domain rule this step no longer writes, is on `askSignIn` above.
3509
3593
  section("Who signs in");
3510
3594
  const localAccount = (() => {
3511
3595
  try { return userInfo().username || "user"; } catch { return "user"; }
3512
3596
  })();
3513
- // ONE CALL SITE. The loop itself lives in `askSignIn` so that the branch that matters — a reader who
3514
- // pressed Enter at the grant question, and a staff rule that must therefore NOT be written — is
3515
- // reachable without a terminal. The same seam and the same reason as `offerUsptoSync`.
3516
- Object.assign(candidate, await askSignIn(
3517
- { askValue, confirm, say, ok, info, warn, problem },
3518
- { localAccount, staffLabel: `${BRAND.name} staff`, envPath: ENV_PATH }));
3597
+ // ONE CALL SITE. The loop itself lives in `askSignIn` so that the branches that matter — an address
3598
+ // that must be sent back, a name that is missing — are reachable without a terminal. The same seam
3599
+ // and the same reason as `offerUsptoSync`.
3600
+ Object.assign(candidate, await askSignIn({ askValue, ok, problem }, { localAccount }));
3519
3601
 
3520
3602
  // 8 ── the engine's own preflight over the whole candidate
3521
3603
  //
@@ -3548,7 +3630,7 @@ try {
3548
3630
  // 9 ── write, atomically
3549
3631
  say("\n Writing configuration");
3550
3632
  // THE DIRECTORY MAY NOT EXIST, and on a fresh machine it does not. `.env` now lives under
3551
- // `~/.config/clearotron/` (tracker issue 140), which nothing else creates — and the failure without
3633
+ // `~/.config/clearotron/`, which nothing else creates — and the failure without
3552
3634
  // this line lands on the temporary file below, so it reads as a permissions problem writing `.env`
3553
3635
  // rather than a missing folder. Mode 700: the file inside is 600 and holds credentials, so a
3554
3636
  // world-readable directory around it advertises that it is there.
@@ -3577,7 +3659,7 @@ try {
3577
3659
  //
3578
3660
  // The owner was given a command, typed it from his home directory, and got npm's error rather than
3579
3661
  // ours: `npx` resolves a local package by walking UP from the current directory, so every command
3580
- // this product prints worked only where the reader happened to be standing. Owner ruling 2026-08-26:
3662
+ // this product prints worked only where the reader happened to be standing. Ruling 2026-08-26:
3581
3663
  // put the verb on PATH, as a per-user shim — `npm link` wants write access to `/usr` and refuses
3582
3664
  // without root, which is why stopped at teaching the product to print `npx`.
3583
3665
  //
@@ -3657,7 +3739,7 @@ try {
3657
3739
  say(` ${style.dim(`\`${invocationPrefix()}clearotron run --job examples/job.euipo.json\` runs a first real clearance on the EU register.`)}`);
3658
3740
  say(` ${style.dim("Each still works the old way too — `npm start`, `npm run example`, `node driver/pipeline.mjs`.")}\n`);
3659
3741
 
3660
- // WHY THOSE LINES LOOK THE WAY THEY DO, when they are not the bare verb (Refs tracker issue 1916).
3742
+ // WHY THOSE LINES LOOK THE WAY THEY DO, when they are not the bare verb.
3661
3743
  //
3662
3744
  // A login profile adds `~/.local/bin` to PATH only if the directory existed when the shell started,
3663
3745
  // so the shim written seconds ago is usually absent from THIS terminal's PATH and arrives at the next