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/.env.example CHANGED
@@ -356,7 +356,7 @@ SIGNA_FIXTURES_DIR= # canned signa payloads — the $0 seam for that
356
356
  # sets one. This name has no default — unset, the lane refuses. Moving it there would spend that
357
357
  # header's credibility, which every other row in that file depends on, to buy a tidier taxonomy.
358
358
  # `.env.example` promises "what you decide at install", and a must-set variable fits deciding-at-install
359
- # better than it fits never-set-one. Owner ruling, 2026-08-30.
359
+ # better than it fits never-set-one. Ruling, 2026-08-30.
360
360
  #
361
361
  # GIVING THE LANE A DEFAULT WAS CONSIDERED AND REFUSED: refuse-by-name-when-unset is the honest design —
362
362
  # the same family as the pool root, where an unset value THROWS rather than quietly resolving somewhere
@@ -525,7 +525,7 @@ CLEAROTRON_WHATSAPP_OPERATOR_COPY=
525
525
  CLEAROTRON_TEST_FIXTURE_PROFILES=
526
526
 
527
527
  # `1` makes the profile loader return the BUNDLED demo account (demo-brand-owner, marked `demoData`).
528
- # A fresh install resolves `generic` alone — owner ruling 2026-09-08, so nobody has to clean demo
528
+ # A fresh install resolves `generic` alone — ruling 2026-09-08, so nobody has to clean demo
529
529
  # material out of an environment they just made — and the demo brings its own account when it runs.
530
530
  # Set by `clearotron demo`, by `clearotron start --demo`, and by scripts/test-run.mjs.
531
531
  #
@@ -535,3 +535,14 @@ CLEAROTRON_TEST_FIXTURE_PROFILES=
535
535
  # Read by driver/profiles.mjs.
536
536
  # effect: harness
537
537
  CLEAROTRON_DEMO_PROFILES=
538
+
539
+ # ── The organisation setup names ──────────────────────────────────────────────────────────────────
540
+ #
541
+ # Your organisation's name. `npm run setup` asks for it directly after the sign-in address and writes it
542
+ # here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
543
+ # organisation in the grants file (CLEAROTRON_ACCESS_FILE) when that file holds none; from then on the
544
+ # grants file holds the name, renaming is an edit there, and this is not read. Unset or empty, no
545
+ # organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a
546
+ # demo never reads it. Read by bin/start.mjs.
547
+ # effect: deployment
548
+ CLEAROTRON_ORGANISATION_NAME=
package/CONTRIBUTING.md CHANGED
@@ -80,7 +80,7 @@ product and will not be published. Its absence weakens nothing you can run — t
80
80
  the machinery, and the scenario suite covers the answers. The validation ladder, from the $0 offline
81
81
  suite to a paid live run, is [docs/E2E.md](docs/E2E.md).
82
82
 
83
- **Some comments cite issues you cannot open** — `(tracker issue 324)` and the like, against the private tracker this
83
+ **Some comments cite issues you cannot open** — `` and the like, against the private tracker this
84
84
  repository was cut from. They are being removed rather than preserved.
85
85
  [ADR-0005](docs/decisions/0005-comments-carry-reasoning.md) is the ruling and states what a new comment
86
86
  has to meet; read it there rather than from a summary here. New work cites this repository's issues
package/INSTALL.md CHANGED
@@ -168,7 +168,7 @@ you want the second one** — the first assumes access to the repository, which
168
168
 
169
169
  **This is the development tree, and it is for contributors.** If you want to run a clearance, take one
170
170
  of the two routes above instead. A clone is not a smaller version of the package — it carries the
171
- fixtures and sample brand owners the project develops against, and those load as real records. They
171
+ fixtures and sample companies the project develops against, and those load as real records. They
172
172
  are not yours and they are not in the package, which excludes them deliberately.
173
173
 
174
174
  ```
@@ -381,7 +381,7 @@ CLARIVATE_API_KEY=... # the credential for the provider named
381
381
  # EUIPO_CLIENT_ID=... / EUIPO_CLIENT_SECRET=...
382
382
  EUIPO_ENVIRONMENT=production # REQUIRED with euipo — sandbox | production are SEPARATE
383
383
  # deployments over different corpora. There is no default:
384
- # unset, the adapter refuses by name (tracker issue 1149 item 2).
384
+ # unset, the adapter refuses by name.
385
385
 
386
386
  # ── Web research — REQUIRED for all four products (see §1) ─────────────
387
387
  PERPLEXITY_API_KEY=...
@@ -445,33 +445,36 @@ staleness thresholds that decide when an index is too old to trust are in
445
445
 
446
446
  ### The four things, and what contains what
447
447
 
448
- Read this before the rest of the section. The product uses three words for overlapping ideas, and
449
- nothing until now said how they nest — the owner of this product reached for a fourth word, "org",
450
- which names nothing here at all. That confusion is real and it belongs to the documentation, not to
451
- the reader.
448
+ Read this before the rest of the section. Everything is one tree, and a person is given access to
449
+ points on it.
452
450
 
453
- | What it is | The word the product uses | Where it lives | What creates it |
451
+ | What it is | What the product calls it | Where it lives | What creates it |
454
452
  |---|---|---|---|
455
- | The deployment's own boundary — one installation's whole world | **tenant** | a key in `grants.json` | nothing yet; you write the key by hand |
456
- | A brand owner you do clearances for | **account**, and the CLI calls it **brand owner** | a bundle in the customer store, keyed by an account key | `npx clearotron brandowner add <key>` |
457
- | One engagement under that brand owner — its classes, jurisdictions, platforms | **project** | inside that account's bundle | `npx clearotron project add` |
458
- | A person who may see some of it | **user** | `grants.json`, under the tenant | `npx clearotron grant add`, then `npx clearotron key issue` |
459
-
460
- Nesting, in one line: **a tenant contains accounts; an account contains projects; a user is enrolled in
461
- a tenant and reaches a named subset of that tenant's accounts.**
462
-
463
- Two consequences worth stating, because both surprised the person who commissioned the product:
464
-
465
- - **An account does not span tenants.** `grants.json` maps each tenant to its own account keys, so the
466
- same brand owner reached from two tenants is two grants, not one shared object.
453
+ | A group of people and the companies they clear for a firm, a brand team, one customer of a hosted install | **organisation** (`tenant` in `grants.json`) | a key under `tenants` in `grants.json`, with its `name` | setup creates the first; after that, a key you add to `grants.json` |
454
+ | A company you do clearances for | **company** (`account` in `grants.json` and on the wire; the CLI calls it **brand owner**) | a bundle in the customer store, keyed by an account key, and listed under exactly one organisation | the portal's `+ New company`, or `npx clearotron brandowner add <key>` |
455
+ | One engagement under that company — its classes, jurisdictions, platforms | **project** | inside that company's bundle | `npx clearotron project add` |
456
+ | Someone who may see some of it | **person** | `grants.json`: their access under each organisation's `users`, their two switches under `people` | the portal's People page, or `npx clearotron grant add` |
457
+
458
+ Nesting, in one line: **an organisation contains companies; a company contains projects; a person is
459
+ given access to points on that tree the whole install, an organisation, or one company — and sees
460
+ everything below them.**
461
+
462
+ Three consequences worth stating, because each has surprised someone:
463
+
464
+ - **A company belongs to exactly one organisation.** The guest list is refused at load if a company is
465
+ listed under two; another organisation's people are given access to it where it lives.
466
+ - **A person holds two switches, and nothing else is a permission.** **Run clearances** starts and
467
+ stops them; **Manage** adds people, adds companies and changes settings. Viewing is not a permission:
468
+ access is viewing. A person with no entry under `people` sees what their access covers and starts
469
+ nothing.
467
470
  - **A key grants no reach of its own.** `npx clearotron key issue` mints the identity a person's assistant
468
- presents; what that identity may see is decided by their `grant`. Enrol first, issue second a key
469
- without a grant reaches nothing, and is not an error anywhere.
471
+ presents; what that identity may see and do is decided by the guest list at the moment of each call.
472
+ Enrol first, issue second — a key for someone with no access reaches nothing, and is not an error
473
+ anywhere.
470
474
 
471
- **⚠ The words are not yet aligned across the surfaces.** `grants.json` says *tenant*, the CLI verb is
472
- *brandowner*, and the portal and `grant`'s own output say *account*. This table states the containment
473
- so a reader can act today; choosing ONE customer-facing word and moving the file, the CLI and the UI
474
- onto it is a product decision that has not been taken.
475
+ **The file keeps its words.** The screens say organisation, company and person; `grants.json`, the wire
476
+ and the command line keep `tenant` and `account`, and the CLI verb stays `brandowner`. Moving them would
477
+ break every file and script written against them.
475
478
 
476
479
 
477
480
  A clearance run is shaped by a **customer profile** — a small JSON file that declares that customer's
@@ -741,10 +744,12 @@ You sign in as `<your-username>@localhost` unless you say otherwise:
741
744
  npx clearotron start --user you@example.com
742
745
  ```
743
746
 
744
- The address is written to `.env`, so it is asked for once. It is also the staff identity for this
745
- install: in this mode the portal admits exactly one address, so it sees everything, and enrolling
746
- clients is the grants file (`CLEAROTRON_ACCESS_FILE`, §8) exactly as on a
747
- hosted instance.
747
+ The address is written to `.env`, so it is asked for once. It is also the first person on this
748
+ install: the first start writes it into the grants file (`CLEAROTRON_ACCESS_FILE`, §8) with access to
749
+ everything and both permissions, Run clearances and Manage. Setup asks for your organisation's name
750
+ directly after the address, and the same start files it there as your first organisation. The address
751
+ admits nobody else at its domain; enrolling anyone else is that same file, exactly as on a hosted
752
+ instance.
748
753
 
749
754
  **No authentication is switched off to make this work, and none can be.** Both doors prove who the
750
755
  caller is — the portal by passphrase and a signed session cookie, the engine door by a mandatory
@@ -888,13 +893,13 @@ set:
888
893
 
889
894
  ### The client connector's ingress is not integrator-supplied
890
895
 
891
- The client connector is the door a brand owner's assistant talks to, and it
896
+ The client connector is the door a company's assistant talks to, and it
892
897
  is **part of the product** rather than something a deployment invents. A reader who reaches the
893
898
  Use-your-AI page and finds Connect buttons that do nothing has been failed by the install, not by
894
899
  their own integration work.
895
900
 
896
- **Two shapes, and the line between them is what your assistant can do — not where it runs.** Owner
897
- ruling 2026-09-03, on the vendor's own documented behaviour:
901
+ **Two shapes, and the line between them is what your assistant can do — not where it runs.** Ruling
902
+ 2026-09-03, on the vendor's own documented behaviour:
898
903
 
899
904
  1. **An assistant that can launch a local process** — Claude Code, Claude Desktop's local config,
900
905
  Codex CLI, an agent that runs commands. It spawns the server over stdio. No address, no key, no
@@ -1005,11 +1010,23 @@ infer it from an install step. The operational side — issuing and rotating gra
1005
1010
  What belongs here is only what you set at install time.
1006
1011
 
1007
1012
  **The guest list.** `CLEAROTRON_ACCESS_FILE` turns account scoping on for **every face at once** — the
1008
- portal, the MCP read face, and the client connector. `npx clearotron start` (§6) writes an empty
1009
- roster (`{"tenants": {}}`) into its state directory, which is your own staff access and no clients yet.
1013
+ portal, the MCP read face, and the client connector. `npx clearotron start` (§6) writes one into its
1014
+ state directory the first time it runs: you, with access to everything, your organisation if setup was
1015
+ told its name, and nobody else yet.
1010
1016
  [examples/grants.example.json](examples/grants.example.json) is a runnable guest list over the demo
1011
1017
  clients.
1012
1018
 
1019
+ **Giving someone access.** `npx clearotron grant add` writes the same file the portal's People page
1020
+ writes:
1021
+
1022
+ ```
1023
+ npx clearotron grant add <email> --tenant <organisation> --accounts <key,key|*> [--run] [--manage]
1024
+ ```
1025
+
1026
+ `--accounts '*'` is the whole organisation, including companies filed under it later. `--run` lets the
1027
+ person start and stop clearances; `--manage` lets them add people and companies and change settings.
1028
+ With neither, they can see what their access covers and start nothing.
1029
+
1013
1030
  **Keys for people.** `npx clearotron grant` enrols someone; it decides what they may see and issues
1014
1031
  nothing. The key their assistant actually presents comes from a different verb:
1015
1032
 
@@ -1032,10 +1049,17 @@ Said plainly rather than dressed as a verb, because the distinction costs real t
1032
1049
  key issue` mints ACCOUNT keys only**, and `npx clearotron grant` mints nothing at all. This page
1033
1050
  previously sent readers to `grant` for an ops token, which is why the sentence is now this long.
1034
1051
 
1035
- **When you have to re-mint one.** The portal's own trigger lane runs on a pinned ops token, and its
1036
- account list is frozen at the moment it was minted. Add a brand owner afterwards and clearances for that
1037
- account are refused `your grant [...] does not include account "..."` until the token is re-minted
1038
- with the new list and the environment updated. Nothing re-mints it for you.
1052
+ **When you have to re-mint one.** Not on the ordinary path any more, and this paragraph used to say
1053
+ otherwise. The portal takes its ops credential afresh at the start of each call, capped to the company
1054
+ roster as it stands, so a company created after the portal started can run a clearance and stop one
1055
+ without anybody re-minting anything.
1056
+
1057
+ The pinned credential minted at startup is still the fallback, and it is where this matters. If the
1058
+ signing secret cannot be read or the company store cannot be listed, the portal uses that one rather than
1059
+ widening the cap to get the call through — so its account list is frozen at the moment it was minted, and
1060
+ a company created since is refused at the door with `your grant [...] does not include account "..."`.
1061
+ The portal says so in its log when it happens. That is when you re-mint by hand, with the new list, and
1062
+ update the environment.
1039
1063
 
1040
1064
  ### Putting a surface behind your identity provider
1041
1065
 
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // ── THE FRAMEWORK IS SET HERE, AND THAT NEEDED A NEW DOOR ─────────────────────────────────────────
14
14
  //
15
- // Owner ruling, 2026-08-29: the risk framework stays MANDATORY at onboarding, with a DEFAULT BACKUP so
15
+ // Ruling, 2026-08-29: the risk framework stays MANDATORY at onboarding, with a DEFAULT BACKUP so
16
16
  // onboarding is never blocked. This sets `frameworkPath` ALWAYS — the client's own when supplied, the
17
17
  // Generic default otherwise — and says out loud which one it used.
18
18
  //
@@ -59,180 +59,22 @@ import { assertProfileKey, profileStoreResolution, CONTEXT_PACK_FILE, validatePr
59
59
  import { DEFAULT_FRAMEWORK, loadFrameworkManifest } from "../driver/framework.mjs";
60
60
  import { defaultWriteProfile } from "../driver/profile-service.mjs";
61
61
  import { config } from "../driver/driver.config.mjs";
62
+ import { preflightFramework, formatPreflight } from "../driver/framework-preflight.mjs";
62
63
  import { makeCommittableAudit, commitWithAuditRow, makeStoreCommit, resolveStoreRepoRoot } from "../shared/store-in-repo.mjs";
63
64
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
64
65
  // Shared with `project add`, which asks an identical question of the same variable — see the
65
66
  // module header for why this is not two copies.
66
67
  import { Refusal, storeForAdd } from "../shared/onboarding-store.mjs";
67
68
 
68
- // ── the framework, which is the point of the setup ─────────────────────────────────────────────────
69
- /**
70
- * Resolve the framework this brand owner will be rated under, as the ruling words it: the client's own
71
- * when supplied, the Generic default otherwise, and always stated.
72
- *
73
- * ABSENT AND BROKEN ARE NOT THE SAME EVENT and the ruling separates them deliberately. Absent means the
74
- * client has not given us their framework yet — onboarding proceeds under the Generic default, named.
75
- * Broken means someone TRIED to select one and it does not resolve, and falling back there would rate a
76
- * client's matters under a framework nobody chose while the output said everything was fine.
77
- *
78
- * The profile validator checks the SHAPE of this string only (`skills/prelim-search/<file>.md`, no
79
- * escape) and never whether the file is there — so a shape-valid path to a document that does not exist
80
- * validates cleanly and fails at rating time, which is the wrong place to find out.
81
- */
82
- export function resolveFramework(requested, {
83
- resolveSkill = (rel) => config.resolveSkillPath(rel),
84
- loadManifest = loadFrameworkManifest,
85
- } = {}) {
86
- if (requested == null || String(requested).trim() === "")
87
- return { path: DEFAULT_FRAMEWORK, source: "default" };
88
-
89
- const path = String(requested).trim();
90
- // Shape first, and by the profile validator's own rule rather than a second copy of it: a path that
91
- // would be refused at load must be refused here, in the same words, before anything is written.
92
- if (!/^skills\/prelim-search\/[^/]+\.md$/.test(path) || path.includes(".."))
93
- throw new Refusal(
94
- `--framework must name a document of the form "skills/prelim-search/<file>.md" (got ${JSON.stringify(path)}). `
95
- + `A profile selects a SHIPPED framework, never an arbitrary path.`);
96
-
97
- // RESOLVED THE WAY THE RATING STAGE RESOLVES IT, never by joining the repo root. `skills/...` paths
98
- // are relative to the DRIVER's skills directory, and a deployment may serve them from a doctrine
99
- // overlay (CLEAROTRON_INSTRUCTIONS_DIR) instead. Joining the repo root would have looked in a
100
- // directory that does not exist, refused every valid framework on every install, and — worse in the
101
- // other direction — been blind to the overlay a deployment actually reads.
102
- const deck = resolveSkill(path);
103
- if (!existsSync(deck))
104
- throw new Refusal(
105
- `--framework names ${path}, and there is no such document on this install (resolved to ${deck}). `
106
- + `Refusing rather than rating this brand owner's matters under the Generic default: a framework `
107
- + `somebody chose and that does not resolve is a mistake, not an absence.`);
108
-
109
- // The manifest is DERIVED from the deck path, never a separate knob — and it is what the validators,
110
- // the renderer and the profile page read to know the framework's band vocabulary. A deck whose
111
- // manifest will not load produces a customer whose page cannot state their own ladder.
112
- //
113
- // ASKED, NOT RE-CHECKED. An `existsSync` on the manifest here would be a second opinion about the
114
- // same fact: `loadFrameworkManifest` already refuses a missing sidecar by name
115
- // (`framework_manifest_missing:<path>`) and is the exact read the rating stage makes. Two checks
116
- // means two messages to keep in step, and the one this file could write would be the one that goes
117
- // stale.
118
- let manifest;
119
- try {
120
- manifest = loadManifest(resolveSkill, path);
121
- } catch (e) {
122
- throw new Refusal(
123
- `--framework names ${path}, and the framework will not load: ${String(e?.message ?? e)}. `
124
- + `Refusing rather than falling back: a framework somebody chose and that does not resolve is a `
125
- + `mistake, not an absence.`);
126
- }
127
- return { path, source: "supplied", manifest };
128
- }
129
-
130
- // ── the profile this writes ────────────────────────────────────────────────────────────────────────
131
- /**
132
- * Which marketplaces this brand owner's searches cover — supplied, or the Generic default, SAID OUT LOUD.
133
- *
134
- * A customer bundle is a COMPLETE document in this design, not an overlay on generic: every shipped
135
- * profile carries its own `platforms`, and the loader requires a non-empty array on every file. The
136
- * command had no way to supply one and set none, so every bundle it wrote failed to load on this field
137
- * as well as on the `key` field above — two independent invalidities, and onboarding could not produce
138
- * a loadable brand owner at all.
139
- *
140
- * Defaulting rather than refusing, and naming it, is this command's own established idiom: the same
141
- * ruling governs the framework one function down. Which marketplaces a client's clearance searches is
142
- * not a detail to decide silently, so an operator who supplies nothing is TOLD what they got and can
143
- * refine it in the portal.
144
- */
145
- export function resolvePlatforms(supplied, roster) {
146
- if (supplied?.length) return { platforms: supplied, source: "supplied" };
147
- const house = roster?.get?.("generic")?.platforms ?? [];
148
- if (!house.length)
149
- throw new Refusal(
150
- "no --platforms was given and the Generic default carries none, so there is nothing to onboard this "
151
- + "brand owner with. Pass --platforms, or repair the generic profile in the store.");
152
- return { platforms: [...house], source: "house default" };
153
- }
69
+ // ── what a valid company bundle is ─────────────────────────────────────────────────────────────────
70
+ // MOVED TO driver/company-bundle.mjs when the browser became the second door. Re-exported here because
71
+ // this file was the only home they ever had and callers import them from it; the definition is now in
72
+ // one place and both doors ask it, rather than two doors agreeing by inspection.
73
+ import {
74
+ resolveFramework, resolvePlatforms, buildProfile, rosterAsItStands, assertRosterAccepts,
75
+ } from "../driver/company-bundle.mjs";
154
76
 
155
- export function buildProfile({ key, name, domains, platforms, framework, industry }) {
156
- // NO `key` IN THE DOCUMENT. The loader derives it from the FILENAME and injects it — readProfilesLayer
157
- // composes `{ key, ...p }` — so a `key` written here is redundant on the way in and fatal on the way
158
- // out: it is not in KNOWN_PROFILE_KEYS, and the deny-unknown-key gate hard-fails the whole roster over
159
- // it. Every bundle this command wrote carried one, so the first successful onboarding made the store
160
- // unloadable and the next command to read profiles threw. The parameter stays — the filename and the
161
- // roster checks are addressed by key — it simply does not travel into the file.
162
- const profile = { name, platforms };
163
- if (domains?.length) profile.matchDomains = domains;
164
- if (industry) profile.industry = industry;
165
- // ALWAYS SET, per the ruling. `frameworkFor` would fall back to the same value if this were absent —
166
- // but "the tool sets it" is the point of the setup, and an explicit selection is what makes the
167
- // receipt below mean anything.
168
- profile.frameworkPath = framework.path;
169
- return profile;
170
- }
171
-
172
- // ── the proposed roster, validated whole ───────────────────────────────────────────────────────────
173
- /**
174
- * Validate the candidate the way the deployment will read it: as one member of the roster, not alone.
175
- *
176
- * `loadProfiles` composes every profile in the store and throws on the FIRST cross-profile conflict it
177
- * finds — a domain claimed twice, most of all. That throw is not scoped to the offending file: it stops
178
- * the roster loading at all. So the only honest check is over the proposed state, which is what
179
- * bin/grant.mjs does with `accessView` for exactly the same reason.
180
- */
181
- /**
182
- * The roster as it stands, where AN EMPTY STORE IS AN EMPTY ROSTER — F42.
183
- *
184
- * `loadProfiles` with an explicit `dir` reads that directory ALONE and refuses a store with no
185
- * `generic.json`, which is right for what that refusal is for: `generic` is the universal fallback every
186
- * unprofiled job resolves to, and a RUN against a store without it would silently reprofile a client.
187
- * The explicit-dir form deliberately has no fall-through, because the fixtures that build a roster
188
- * assert on precisely that roster and layering the bundled set underneath would widen three set-level
189
- * guards until none still tested its own name.
190
- *
191
- * BUT THIS COMMAND IS NOT RUNNING A CLEARANCE. It reads the store to answer one question — does this key
192
- * or one of its domains already exist — and on a fresh install the honest answer is "no, there is
193
- * nothing here yet". Instead it stack-traced on the FIRST day-one command a new operator types, with a
194
- * refusal about a file they had never heard of and did not need: the runtime resolves `generic` from the
195
- * product's bundled set through the OVERLAY path, so a deployment store never needs its own copy.
196
- *
197
- * So the missing-generic refusal is caught BY NAME and answered as the empty roster it describes. Caught
198
- * by name rather than broadly, because every other thing that loader throws — an overlapping domain, an
199
- * unknown key, an unreadable store — is a real refusal this command must still relay.
200
- */
201
- export function rosterAsItStands(store, loadProfiles) {
202
- try { return loadProfiles({ dir: store, force: true }); }
203
- catch (e) {
204
- if (/generic\.json is REQUIRED/.test(String(e?.message ?? ""))) return new Map();
205
- throw e;
206
- }
207
- }
208
-
209
- export function assertRosterAccepts({ store, key, profile, loadProfiles }) {
210
- // THE LOADER'S OWN VALIDATOR, over the CANDIDATE — the discipline `project add` already applies to an
211
- // overlay, in the same words, for the same reason: whatever the tree would refuse at load is refused
212
- // here, before anything is written. Everything below this line reads the store AS IT STANDS, which is
213
- // why none of it could ever see a bad field in the file about to be added: the candidate never met the
214
- // loader until the next command did, and by then the write had landed.
215
- const v = validateProfileEdit(key, profile);
216
- if (!v.ok)
217
- throw new Refusal(`the brand owner bundle is not valid, so nothing was written:\n ${v.errors.join("\n ")}`);
218
-
219
- const existing = rosterAsItStands(store, loadProfiles);
220
- if (existing.has(key))
221
- throw new Refusal(
222
- `a brand owner "${key}" already exists in ${store}. This command creates; it does not overwrite an `
223
- + `existing bundle. Edit it in the portal, or remove the file deliberately first.`);
224
-
225
- for (const d of profile.matchDomains ?? []) {
226
- const dl = String(d).toLowerCase();
227
- for (const [otherKey, other] of existing) {
228
- if ((other.matchDomains ?? []).some((o) => String(o).toLowerCase() === dl))
229
- throw new Refusal(
230
- `domain "${dl}" is already claimed by the brand owner "${otherKey}". Two owners claiming one `
231
- + `domain makes the WHOLE roster refuse to load on the next start — not just this bundle — so `
232
- + `nothing has been written.`);
233
- }
234
- }
235
- }
77
+ export { resolveFramework, resolvePlatforms, buildProfile, rosterAsItStands, assertRosterAccepts };
236
78
 
237
79
 
238
80
  // ── argument parsing ───────────────────────────────────────────────────────────────────────────────
@@ -338,6 +180,13 @@ export async function add(argv, {
338
180
  if (contextPack) out(`would create ${join(store, CONTEXT_PACK_FILE(args.key))}`);
339
181
  out(frameworkLine);
340
182
  out(platformsLine);
183
+ // THE SAME REPORT `clearotron framework` PRINTS, from the same module. A dry run already proved the
184
+ // framework LOADS; that is a smaller question than the one the operator has, because a deck whose
185
+ // manifest parses can still name bands the deck never defines, and the profile screen answers that by
186
+ // quietly omitting the box. Two checks of one property drift; this is one check with two doors.
187
+ out("");
188
+ out(formatPreflight(preflightFramework(framework.path)));
189
+ out("");
341
190
  out(`nothing was written (--dry-run)`);
342
191
  return { written: false, store, profile, framework };
343
192
  }
@@ -407,7 +256,7 @@ export async function add(argv, {
407
256
  if (gap.uncovered.length) {
408
257
  out("");
409
258
  out(` ⚠ NOT YET STARTABLE — ${triggerCapWarning(gap)}`);
410
- out(` The portal will offer a clearance for ${args.key} and the engine door will refuse it until the trigger token is re-minted.`);
259
+ out(` The portal will still start a clearance for ${args.key}: it re-takes its credential on each call. This matters when it cannot — then it uses the token above and the door refuses.`);
411
260
  }
412
261
  }
413
262
  } catch (e) {
@@ -58,6 +58,7 @@ export const VERBS = {
58
58
  grant: ["bin/grant.mjs"],
59
59
  key: ["bin/key.mjs"],
60
60
  brandowner: ["bin/brandowner.mjs"],
61
+ framework: ["bin/framework-preflight.mjs"],
61
62
  project: ["bin/project.mjs"],
62
63
  passphrase: ["bin/passphrase.mjs"],
63
64
  sync: ["bin/uspto-sync.mjs"],
@@ -79,6 +80,7 @@ export const SUMMARY = {
79
80
  grant: "enrol a client, or list who may see what",
80
81
  key: "issue the key a person's own assistant presents — after you have enrolled them with `grant`",
81
82
  brandowner: "onboard a brand owner — create its bundle and set the risk framework its matters are rated under",
83
+ framework: "check a risk framework deck and its manifest before a matter is rated under it. Writes nothing",
82
84
  project: "add an engagement under a brand owner — the classes, jurisdictions and platforms it searches",
83
85
  passphrase: "report or RESET the portal's local sign-in — the recovery for a lost passphrase",
84
86
  sync: "build or update the free US register index (a large download, and hours of ingest)",
@@ -202,7 +204,7 @@ const [verb, ...rest] = process.argv.slice(2);
202
204
  process.exit(code ?? 0);
203
205
  });
204
206
 
205
- // ── A SIGNAL MUST REACH THE CHILD, BECAUSE THIS IS THE ONLY PID A READER CAN SEE (tracker issue 176) ─
207
+ // ── A SIGNAL MUST REACH THE CHILD, BECAUSE THIS IS THE ONLY PID A READER CAN SEE ─────────────────────
206
208
  //
207
209
  // The header above says this file "spawns and forwards, and does not look at what it is forwarding".
208
210
  // That was written about ARGUMENTS and read as though it covered signals, which it did not.
package/bin/connect.mjs CHANGED
@@ -48,13 +48,13 @@ import { createServer } from "node:net";
48
48
  import { CONNECT_CLIENTS, clientById, whatItNeeds } from "../shared/connect-clients.mjs";
49
49
  import { stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs";
50
50
  import { defaultDenylistPath, clientDoorAddress, clientDoorPort, clientDoorState, enablePlan, applyEnablePlan, describeChange, recordConnectKey, CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
51
- import { mintToken, tokenId, accountsForEmail, loadGrants } from "../shared/scope.mjs";
51
+ import { mintToken, tokenId, resolvePerson, loadGrants } from "../shared/scope.mjs";
52
52
  import { envFrom } from "../shared/env-aliases.mjs";
53
53
  import { atomicWrite } from "../driver/progress.mjs";
54
54
  // — F40. SERVER_INSTALL_SET is what `bin/start.mjs` re-exports as
55
55
  // BACKGROUND_UNITS; taken from shared/ so this verb does not reach into another bin/ entry point.
56
56
  import { SERVER_INSTALL_SET, unitHealthVerdict } from "../shared/server-units.mjs";
57
- import { checkoutMove, movePosture, describeMove, describeConflict } from "../shared/checkout-move.mjs"; // tracker issue 193
57
+ import { checkoutMove, movePosture, describeMove, describeConflict } from "../shared/checkout-move.mjs";
58
58
  import { unitEnvironment, unitValue, couldNotDetermine } from "../driver/unit-environment.mjs";
59
59
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
60
60
  import { looksLikeBusFailure, systemdSaid, userBusEnv, CAPTURE_STDERR,
@@ -62,13 +62,13 @@ import { looksLikeBusFailure, systemdSaid, userBusEnv, CAPTURE_STDERR,
62
62
 
63
63
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
64
64
  // `userBusEnv` and `busRemedy` MOVED TO shared/systemd-failure.mjs, unchanged, because
65
- // `start --background` needs the same answers and had none (tracker issue 203). Their reasoning went
65
+ // `start --background` needs the same answers and had none. Their reasoning went
66
66
  // with them; this file imports them and passes `userBusEnv()` at every `systemctl` call it makes,
67
67
  // `showUnit` included — which is the property that makes deriving the bus safe here and is why `start`
68
68
  // does not do it at two of its five.
69
69
 
70
70
  /**
71
- * What a half-finished connect has ALREADY written by the time `step` failed — tracker issue 121.
71
+ * What a half-finished connect has ALREADY written by the time `step` failed.
72
72
  *
73
73
  * The reader's problem is not only that a step failed; it is not knowing whether to run `connect` again,
74
74
  * run `disconnect` first, or leave it alone. A message that names a command and stops leaves a
@@ -95,7 +95,7 @@ function alreadyApplied(step) {
95
95
  /**
96
96
  * A systemd failure, said in the reader's terms — the REASON first, then the remedy that fits it.
97
97
  *
98
- * TWO DEFECTS, ONE SITE (tracker issue 121). The calls ran with `stdio: "ignore"`, so systemd's own
98
+ * TWO DEFECTS, ONE SITE. The calls ran with `stdio: "ignore"`, so systemd's own
99
99
  * explanation was thrown away before anyone could read it and the whole output was `connect: Command
100
100
  * failed: systemctl --user daemon-reload`. And the bus remedy was appended to EVERY failure, so a unit
101
101
  * that would not start — a bound port, a bad ExecStart — told the reader to export XDG_RUNTIME_DIR: a
@@ -108,7 +108,7 @@ function alreadyApplied(step) {
108
108
  /**
109
109
  * Does this failure say the SESSION BUS is missing, rather than anything about the unit?
110
110
  *
111
- * ONE AUTHORITY, because two readers now ask it (tracker issue 130, criterion 3). The failure text
111
+ * ONE AUTHORITY, because two readers now ask it (criterion 3). The failure text
112
112
  * below offers the bus remedy on a yes, and the health reader refuses to translate a yes into "the door
113
113
  * is not open" — that mistranslation is the defect, and a second copy of this test is how the two would
114
114
  * come to disagree about which failures are bus failures.
@@ -172,7 +172,7 @@ function runningEnv() {
172
172
  }
173
173
 
174
174
  /**
175
- * Which tree each installed unit's LIVE process is executing (tracker issue 193).
175
+ * Which tree each installed unit's LIVE process is executing.
176
176
  *
177
177
  * NOT the units' env file, which is the file this verb is about to write — after the write the two
178
178
  * agree and the drift is invisible. The running processes are the only witnesses, and they are
@@ -282,11 +282,11 @@ function portOwnerOf(port, bound) {
282
282
  /**
283
283
  * Read properties off a user unit — the ONE place this file asks systemd anything read-only.
284
284
  *
285
- * ── WHY IT EXISTS (tracker issue 130, criterion 3) ───────────────────────────────────────────────────
285
+ * ── WHY IT EXISTS (criterion 3) ──────────────────────────────────────────────────────────────────────
286
286
  *
287
287
  * Two readers here called `systemctl --user show` directly and neither did what the WRITERS in this
288
- * same file already do: neither passed `userBusEnv()`, so both failed in exactly the shell tracker issue
289
- * 121 was filed about, and neither captured stderr, so systemd's own "Failed to connect to bus" went
288
+ * same file already do: neither passed `userBusEnv()`, so both failed in exactly the shell the
289
+ * refusal report was filed about, and neither captured stderr, so systemd's own "Failed to connect to bus" went
290
290
  * straight past the reader with no remedy beside it.
291
291
  *
292
292
  * The writers were fixed and these were missed, which made the product WORSE on this path rather than
@@ -370,7 +370,7 @@ export function unitIsHealthy(name, { show = showUnit, pause = settle } = {}) {
370
370
  pause(3000);
371
371
  const { fields: f, error } = show(name, ["ActiveState", "SubState", "NRestarts"]);
372
372
  if (error) {
373
- // ── A BUS THAT COULD NOT BE REACHED IS NOT A DOOR THAT IS DOWN (tracker issue 130) ────────────
373
+ // ── A BUS THAT COULD NOT BE REACHED IS NOT A DOOR THAT IS DOWN ────────────────────────────────
374
374
  //
375
375
  // Returning false here is what made the operator worse off than before that fix.
376
376
  // The caller renders false as "the door is not open, so no key was issued" — a confident, wrong,
@@ -396,11 +396,20 @@ function enableTheDoor({ have, identity, client = null, dryRun, portFree, portOw
396
396
  const reach = { publicAddress: have.publicAddress, reachabilityKnown: envKnown };
397
397
  // Resolved HERE because it reads a file, and handed to the pure planner as a fact. A door this verb
398
398
  // opens for an identity that is granted nothing is a credential with no reach.
399
+ //
400
+ // ENROLLED MEANS A PERSON WITH ACCESS SOMEWHERE, asked of the resolver the door itself reads. The
401
+ // flat company list could not say it: a person whose only reach is their organisation's Generic holds
402
+ // no company, and read as enrolled in nothing. No grants file answers "*", as it always has.
399
403
  let granted;
400
- try { granted = accountsForEmail(identity, loadGrants()); } catch { granted = undefined; }
404
+ try {
405
+ const grants = loadGrants();
406
+ const person = grants ? resolvePerson(identity, grants) : null;
407
+ granted = !grants ? "*" : !person ? [] : person.accounts === "*" ? "*"
408
+ : [...person.accounts, ...(person.genericOrgs.length ? ["generic"] : [])];
409
+ } catch { granted = undefined; }
401
410
  // The env file the UNIT reads, not this shell — found by the drive: a shell-exported secret passed
402
411
  // every plan check while the door died at birth reading an env file that lacked it.
403
- // ── THE PLAN CHECKED THE FILE AND THE MINT READ THE SHELL (tracker issue 130) ─────────────────────
412
+ // ── THE PLAN CHECKED THE FILE AND THE MINT READ THE SHELL ─────────────────────────────────────────
404
413
  //
405
414
  // `unitEnvHasSecret` below asks the UNIT'S ENV FILE, which is the right question for the DOOR: a
406
415
  // process reads its environment at start, so a secret that is only in this shell is one the door
@@ -425,7 +434,7 @@ function enableTheDoor({ have, identity, client = null, dryRun, portFree, portOw
425
434
  portOwner: (p) => (p === have.port ? (portOwner ?? (portFree ? "free" : "stranger")) : "stranger") });
426
435
  if (!plan.possible) return { ok: false, blockers: plan.blockers, fix: plan.fix };
427
436
 
428
- // ── A MACHINE-WIDE SETTING IS NOT THIS VERB'S TO MOVE IN SILENCE (tracker issue 193) ───────────────
437
+ // ── A MACHINE-WIDE SETTING IS NOT THIS VERB'S TO MOVE IN SILENCE ───────────────────────────────────
429
438
  //
430
439
  // `plan.settings` carries CLEAROTRON_CHECKOUT_DIR set to THIS checkout, and `setEnvValue` replaces
431
440
  // rather than preserves — so running this from a worktree repoints every unit's ExecStart and the
@@ -500,7 +509,7 @@ function enableTheDoor({ have, identity, client = null, dryRun, portFree, portOw
500
509
  recordNote = "This key's id could not be read, so it was NOT recorded — `clearotron disconnect` cannot revoke it; it dies only at its own expiry.";
501
510
  } else {
502
511
  try {
503
- // ── THE TRUE CAUSE, NOT WHATEVER THREW (tracker issue 130) ─────────────────────────────────
512
+ // ── THE TRUE CAUSE, NOT WHATEVER THREW ─────────────────────────────────────────────────────
504
513
  //
505
514
  // With no grants file configured this reached `atomicWrite(undefined, …)` and the reader got a
506
515
  // raw Node type error inside a client-facing sentence — a live key, outside `disconnect`'s
@@ -561,7 +570,7 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
561
570
  return 0;
562
571
  }
563
572
 
564
- // ── AN ADDRESS ROUTE ALWAYS MINTS NOW (owner ruling 2026-09-03,) ─────────────
573
+ // ── AN ADDRESS ROUTE ALWAYS MINTS NOW (ruling 2026-09-03,) ─────────────
565
574
  //
566
575
  // This used to be `if (offer.enables)` — mint only when the row said the door still had to be turned
567
576
  // on. Under settled point 2 the door auto-starts, so no row asks for that any more and every offer
@@ -586,7 +595,7 @@ async function render(offer, have, { dryRun, running, allowMove = false }) {
586
595
  const bound = await portIsFree(have.port);
587
596
  const r = enableTheDoor({ have, identity, client: offer.client.id, dryRun, env: running.env,
588
597
  envKnown: running.known, portFree: bound, portOwner: portOwnerOf(have.port, bound), allowMove });
589
- // SAID BEFORE THE OUTCOME, whichever way it goes (tracker issue 193): a reader who is about to be
598
+ // SAID BEFORE THE OUTCOME, whichever way it goes: a reader who is about to be
590
599
  // told the door is open needs to have already read that the deployment moved trees to open it.
591
600
  for (const line of r.moveSays ?? []) say(line);
592
601
  if (!r.ok) {
@@ -708,12 +717,12 @@ async function main() {
708
717
  return await render(whatItNeeds(chosen, have), have, { dryRun, running, allowMove: argv.includes("--allow-checkout-move") });
709
718
  }
710
719
 
711
- // THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND (tracker issue 121). Without the guard, importing
720
+ // THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND. Without the guard, importing
712
721
  // this module to drive one of its message helpers RUNS THE WHOLE VERB — the arm for the half-applied
713
722
  // failure path opened an interactive prompt and hung the suite. `bin/clearotron.mjs` carries the same
714
723
  // guard for the same reason, written up there: "importing it to read the verb table would DISPATCH".
715
724
  //
716
- // Ten other files under bin/ still lack it. That is a class, filed as tracker issue 183 rather than
725
+ // Ten other files under bin/ still lack it. That is a class, filed separately rather than
717
726
  // swept in here — each one needs verifying that it still runs as a command.
718
727
  if (isEntrypoint(import.meta.url)) {
719
728
  main().then((code) => process.exit(code ?? 0), (e) => { console.error(`connect: ${e.message}`); process.exit(2); });