clearotron 0.2.2 → 0.2.4

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 (161) hide show
  1. package/.env.example +64 -37
  2. package/.nvmrc +1 -1
  3. package/CONTRIBUTING.md +8 -4
  4. package/INSTALL.md +36 -14
  5. package/README.md +18 -22
  6. package/THIRD-PARTY-NOTICES.md +1 -1
  7. package/bin/clearotron.mjs +22 -0
  8. package/bin/example.mjs +10 -5
  9. package/bin/onboard.mjs +370 -28
  10. package/bin/start.mjs +220 -12
  11. package/build-info.json +2 -2
  12. package/demo/README.md +1 -1
  13. package/demo/full-country-search/run/_driver/framework.json +1 -1
  14. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  15. package/demo/knockout-search/run/_driver/framework.json +1 -1
  16. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  17. package/docs/E2E.md +5 -5
  18. package/docs/GLOSSARY.md +85 -0
  19. package/docs/PORTAL.md +2 -2
  20. package/docs/README.md +6 -4
  21. package/docs/SECURITY.md +17 -0
  22. package/docs/architecture/01-product-overview.md +21 -9
  23. package/docs/architecture/04-configuration-reference.md +2 -0
  24. package/docs/architecture/05-config-governance.md +19 -0
  25. package/docs/architecture/05-customer-profiles.md +2 -2
  26. package/docs/configuration.md +4 -3
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  28. package/driver/CHANGELOG.md +90 -0
  29. package/driver/README.md +25 -6
  30. package/driver/common-law-receipts.mjs +1 -1
  31. package/driver/config-staleness.mjs +1 -1
  32. package/driver/connotation-search.mjs +1 -1
  33. package/driver/contract-audit.mjs +5 -1
  34. package/driver/contract-e3-baseline.json +11 -11
  35. package/driver/dev-portal.mjs +2 -2
  36. package/driver/doubt-selection.mjs +1 -1
  37. package/driver/drainer-identity.mjs +1 -1
  38. package/driver/driver.config.mjs +1 -1
  39. package/driver/effort-model.mjs +2 -2
  40. package/driver/engine/cli-version.mjs +103 -0
  41. package/driver/engine/probe.mjs +45 -5
  42. package/driver/enqueue-schema.mjs +8 -8
  43. package/driver/feedback-store.mjs +1 -1
  44. package/driver/findings-model.mjs +52 -2
  45. package/driver/flag-snapshot.mjs +20 -0
  46. package/driver/framework.mjs +1 -1
  47. package/driver/gateway.mjs +20 -3
  48. package/driver/outbox-backoff.mjs +1 -1
  49. package/driver/package.json +3 -3
  50. package/driver/party-facts.mjs +2 -2
  51. package/driver/phase0.mjs +1 -1
  52. package/driver/pipeline.mjs +211 -106
  53. package/driver/plain-register.mjs +134 -0
  54. package/driver/portal-access.mjs +1 -1
  55. package/driver/portal-config-view.mjs +70 -2
  56. package/driver/portal-families.mjs +1 -1
  57. package/driver/portal-local-auth.mjs +5 -1
  58. package/driver/portal-report.mjs +1 -1
  59. package/driver/portal-service.mjs +79 -9
  60. package/driver/portal-upstream.mjs +2 -2
  61. package/driver/predelivery-lint.mjs +151 -2
  62. package/driver/profile-page.html +10 -2
  63. package/driver/profile-service.mjs +4 -4
  64. package/driver/profiles/README.md +3 -3
  65. package/driver/profiles.mjs +144 -12
  66. package/driver/publish/index.mjs +2 -2
  67. package/driver/publish/knockout.mjs +7 -1
  68. package/driver/publish/render-knockout.mjs +453 -118
  69. package/driver/publish/render.mjs +111 -16
  70. package/driver/reference-strip-signatures.mjs +20 -0
  71. package/driver/search-policy.mjs +3 -3
  72. package/driver/skills/blind-frame/SKILL.md +1 -1
  73. package/driver/skills/knockout-assess/SKILL.md +84 -0
  74. package/driver/skills/matter-frame/SKILL.md +6 -6
  75. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  76. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  77. package/driver/skills/prelim-search/SKILL.md +1 -1
  78. package/driver/skills/prelim-search/report-prose.md +82 -0
  79. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  80. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  81. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  82. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  83. package/driver/skills/prelim-variants/SKILL.md +3 -3
  84. package/driver/stage-context.mjs +13 -0
  85. package/driver/stages.mjs +54 -7
  86. package/driver/suite-census.json +220 -58
  87. package/driver/systemd/clearotron-worker.service +3 -3
  88. package/driver/tokens.mjs +1 -1
  89. package/driver/unit-inventory.mjs +214 -27
  90. package/driver/verify.mjs +1 -1
  91. package/driver/whatif-memo-run.mjs +26 -4
  92. package/driver/whatif-memo.mjs +19 -1
  93. package/examples/README.md +2 -2
  94. package/examples/grants.example.json +6 -7
  95. package/mcp-server/CHANGELOG.md +4 -0
  96. package/mcp-server/lib/scrub.mjs +1 -1
  97. package/mcp-server/lib/whatif.mjs +2 -2
  98. package/mcp-server/package.json +1 -1
  99. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  100. package/mcp-server/server.mjs +1 -1
  101. package/package.json +31 -19
  102. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  103. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
  104. package/portal-ui/dist/index.html +2 -2
  105. package/portal-ui/package.json +1 -1
  106. package/providers/_shared/term-shape.mjs +1 -1
  107. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  108. package/providers/oauth-mcp-bridge/package.json +1 -1
  109. package/providers/uspto-local/src/index-store.js +2 -2
  110. package/scripts/added-reference-check.mjs +45 -89
  111. package/scripts/citation-line-check.mjs +121 -2
  112. package/scripts/e2e.mjs +2 -2
  113. package/scripts/env-classify.mjs +67 -3
  114. package/scripts/generated-files-are-current.mjs +125 -0
  115. package/scripts/live-surface-check.mjs +38 -1
  116. package/scripts/mint-names-in-force.mjs +4 -2
  117. package/scripts/mint-public-residue.mjs +74 -0
  118. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  119. package/scripts/mint-suite-census.mjs +5 -2
  120. package/scripts/nightly-notice.mjs +0 -2
  121. package/scripts/preinstall-node-check.mjs +24 -0
  122. package/scripts/render-check.mjs +13 -2
  123. package/scripts/report-frame-check.mjs +8 -1
  124. package/scripts/report-screenshot.mjs +6 -3
  125. package/scripts/revisit-render-check.mjs +6 -3
  126. package/scripts/score.mjs +1 -1
  127. package/scripts/strip-tracker-citations.mjs +38 -7
  128. package/scripts/test-full.mjs +226 -0
  129. package/scripts/test-run.mjs +16 -0
  130. package/shared/README.md +2 -1
  131. package/shared/anon-overlay.mjs +3 -3
  132. package/shared/browser-temp-root.mjs +142 -0
  133. package/shared/checkout-move.mjs +1 -1
  134. package/shared/client-door.mjs +4 -3
  135. package/shared/customer-store.mjs +1 -1
  136. package/shared/identifier-scan.mjs +1 -1
  137. package/shared/install-auth.mjs +35 -0
  138. package/shared/invocation.mjs +33 -1
  139. package/shared/listen.mjs +55 -3
  140. package/shared/names-in-force.mjs +6 -1
  141. package/shared/node-floor.mjs +97 -0
  142. package/shared/os-advice.mjs +91 -0
  143. package/shared/reap-on-exit.mjs +42 -0
  144. package/shared/reference-guard-classes.mjs +351 -0
  145. package/shared/staff-domain.mjs +154 -0
  146. package/shared/store-in-repo.mjs +23 -2
  147. package/shared/suite-census.mjs +31 -3
  148. package/shared/withheld-paths-access.mjs +36 -18
  149. package/skills/clearotron-ops/SKILL.md +1 -1
  150. package/driver/recipes/README.md +0 -49
  151. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  152. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  153. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  154. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  155. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  156. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  157. package/scripts/ai-page-render-check.mjs +0 -532
  158. package/scripts/clearances-render-check.mjs +0 -480
  159. package/scripts/composer-render-check.mjs +0 -1027
  160. package/scripts/home-render-check.mjs +0 -526
  161. package/scripts/portal-lifecycle-check.mjs +0 -639
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // THE TWO-REGISTER RULE, AS THE REVIEWER READS IT — tracker issue 333.
5
+ //
6
+ // The report goes to a lawyer who layers advice on top, and that lawyer's client reads the same page.
7
+ // The band, the summary, the basis line and the one-liners are the whole product for the second reader,
8
+ // and they were the hardest lines on it: single sentences of seventy-odd words in the lawyer's
9
+ // vocabulary. The owner's ruling is that default-visible text carries no legal or engine vocabulary at
10
+ // all, and that inside a fold the lawyer's words are allowed where a plain one would lose precision.
11
+ //
12
+ // THIS IS ADVISORY AND MUST STAY ADVISORY. A hit is a rewrite of that line — never a disclosure to the
13
+ // client, never a run failure. The rule is presentation: it changes no band, no evidence and nothing
14
+ // that is searched.
15
+ //
16
+ // — AND IT MUST NOT FIRE ON THE MARK IT IS CLEARING.
17
+ //
18
+ // This is the defect `coverage-form.mjs` records one level in: a refusal that cannot tell a mark from
19
+ // engine vocabulary blocked a clearance on the mark SLICE, and a render-time substitution turned "AXIS
20
+ // Bank filed in class 36" into "group Bank filed in class 36" on a report clearing AXIS. Half the words
21
+ // here are ordinary English and several are plausible marks — PREVAIL, SENIOR, SPECIFICATION. A check
22
+ // that flagged the mark under clearance would put noise on exactly the report that matters most, so
23
+ // every term the run is about is excluded before the text is read.
24
+
25
+ /**
26
+ * The lawyer's vocabulary, as WORKED EXAMPLES with the plain form beside each. Not a ban list: the
27
+ * issue rejects "a list of forbidden words as the mechanism" in terms, and this is what the reviewer
28
+ * offers a seat as the rewrite, which is a different thing from a gate that refuses.
29
+ *
30
+ * Each entry is [what a lawyer writes, what the reader needs]. The second half is the load-bearing one —
31
+ * a flag naming a word teaches nothing, and the seat has to produce a sentence.
32
+ */
33
+ export const PLAIN_FORMS = Object.freeze([
34
+ ["proprietor", "owner"],
35
+ ["subsisting", "live"],
36
+ ["specification", "goods list"],
37
+ ["citable", "earlier marks the office can raise against you"],
38
+ ["prevail", "win"],
39
+ ["formative", "names built on"],
40
+ ["belt-and-braces", "extra"],
41
+ ["non-use attack", "could be cancelled for not being used"],
42
+ ["on the record as it stands", "on what we found"],
43
+ ["marks-and-goods comparison", "same name, same goods"],
44
+ ["dispatch", "the request"],
45
+ ["instructed", "what was asked"],
46
+ ["chunk", ""],
47
+ ]);
48
+
49
+ /** The longest visible sentence a reader should meet. The issue's number, not a derived one. */
50
+ export const SENTENCE_WORD_LIMIT = 25;
51
+
52
+ /**
53
+ * How a term in `PLAIN_FORMS` is looked for in prose — ONE definition, because two of them drift.
54
+ *
55
+ * THE INFLECTIONS ARE THE POINT, and they were the reason a second copy of this rule survived. The
56
+ * pre-delivery lint carried its own hand-tuned patterns — `\bproprietors?\b`, `\bprevails?\b|\bprevailing\b`
57
+ * — while this file built `\bproprietor\b` and matched neither plural. So the pinned source was the
58
+ * WEAKER of the two, and reading terms from it without this would have quietly narrowed what the live
59
+ * check catches: a consolidation that loses coverage is a regression wearing a tidy-up's clothes.
60
+ *
61
+ * A trailing `s`, `es`, `ed` or `ing` after the term, and a hyphen matching a space, which is how the
62
+ * same phrase is written in two documents by two people.
63
+ */
64
+ export const termMatcher = (term) => new RegExp(
65
+ `\\b${term.replace(/[-]/g, "[- ]").replace(/\s+/g, "\\s+")}(?:e?s|ed|ing)?\\b`, "i");
66
+
67
+ /** Everything the run is ABOUT — the mark, its variants, the owners named. Never flagged. */
68
+ const ownTerms = (about = {}) => {
69
+ const out = [];
70
+ for (const v of [about.mark, ...(about.marks ?? []), ...(about.owners ?? []), ...(about.terms ?? [])]) {
71
+ const s = String(v ?? "").trim();
72
+ if (s) out.push(s.toLowerCase());
73
+ }
74
+ return out;
75
+ };
76
+
77
+ /** Sentences, split on terminators that end one. Crude on purpose — this counts words, not grammar. */
78
+ export const sentencesOf = (text) =>
79
+ String(text ?? "").split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
80
+
81
+ export const wordsIn = (sentence) => String(sentence ?? "").trim().split(/\s+/).filter(Boolean).length;
82
+
83
+ /**
84
+ * What is wrong with one default-visible line, as rewrite advice. `[]` means nothing to say.
85
+ *
86
+ * `about` carries the run's own marks and owners so they are never reported — see the header. A term
87
+ * that IS the thing being cleared is not the lawyer's vocabulary, it is the subject.
88
+ */
89
+ export function plainRegisterFlags(text, about = {}) {
90
+ const raw = String(text ?? "");
91
+ if (!raw.trim()) return [];
92
+ const mine = ownTerms(about);
93
+ // Blank the run's own terms before reading, rather than filtering hits afterwards: a mark can contain
94
+ // one of these words ("PREVAIL"), and a hit inside it is not a hit at all.
95
+ let scan = raw;
96
+ for (const t of mine) {
97
+ if (!t) continue;
98
+ scan = scan.replace(new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), " ");
99
+ }
100
+
101
+ const flags = [];
102
+ for (const [term, plain] of PLAIN_FORMS) {
103
+ if (!termMatcher(term).test(scan)) continue;
104
+ flags.push({
105
+ kind: "vocabulary",
106
+ term,
107
+ say: plain
108
+ ? `"${term}" is the lawyer's word — the reader needs "${plain}". Rewrite the sentence, do not swap the word.`
109
+ : `"${term}" is an engine word and has no place on a page a client reads. Rewrite the sentence.`,
110
+ });
111
+ }
112
+
113
+ for (const s of sentencesOf(raw)) {
114
+ const n = wordsIn(s);
115
+ if (n > SENTENCE_WORD_LIMIT) {
116
+ flags.push({
117
+ kind: "length",
118
+ words: n,
119
+ say: `${n} words in one sentence, and a visible line takes ${SENTENCE_WORD_LIMIT}. Split it — one idea per sentence, `
120
+ + "the conclusion first. Do not shorten it by dropping the reason.",
121
+ });
122
+ }
123
+ }
124
+ return flags;
125
+ }
126
+
127
+ /**
128
+ * The fields a reader meets before opening anything. Named here rather than at each call site so the
129
+ * two products answer to one list — the knockout and the clearance drifted apart once already.
130
+ */
131
+ export const DEFAULT_VISIBLE_FIELDS = Object.freeze({
132
+ knockout: ["summary", "batchOpener", "basis", "net", "factors", "counterFactors", "mitigation", "standardCaveats", "reviewerNotes"],
133
+ clearance: ["summary", "oneLiner", "registrability", "thirdPartyRights", "ownRights", "freedomToOperate", "reviewerNotes", "coverage"],
134
+ });
@@ -17,7 +17,7 @@ const domainOf = (email) => { const e = String(email ?? "").toLowerCase(); const
17
17
  /**
18
18
  * makePrincipal({ email, grants, staffDomains }) →
19
19
  * { role: "staff", email, accounts: "*" } — firm identity: everything, acting-for allowed
20
- * | { role: "client", email, accounts: ["aurora", …] } — enrolled client: exactly the granted accounts
20
+ * | { role: "client", email, accounts: ["foxglade", …] } — enrolled client: exactly the granted accounts
21
21
  * | null — unknown identity: no portal (the door 403s)
22
22
  * Staff wins over an (accidental) grants row; a client row with a tenant-wide "*" grant is honored
23
23
  * but the role stays client (no staff surfaces).
@@ -49,6 +49,11 @@ import { readFlagSnapshot, engineFor, providersFor, postureDisagreement } from "
49
49
  // reading is, because the question it was standing in for — does this still describe the box — now has
50
50
  // a direct answer in `lastRun.disagrees`.
51
51
  import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVED at read time, never stored
52
+ // THE ENGINE TABLE, READ FOR TWO WORDS. A row saying an engine cannot run has to name the program it
53
+ // could not find and the command that installs it, or the reader is told they have a problem and not
54
+ // what to do about it — and this table is already where the wizard and the run-door preflight read
55
+ // both of those, so naming them here adds no second description of an engine.
56
+ import { ENGINE_BINARIES } from "./driver.config.mjs";
52
57
 
53
58
  /**
54
59
  * The flag view.
@@ -60,6 +65,30 @@ import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVE
60
65
  // One projection, used for whichever posture is the answer. Extracted when the live posture became that
61
66
  // answer, so the LIVE reading and the LAST-RUN capture cannot be shaped differently and quietly invite a
62
67
  // reader to compare two things that were built by two rules.
68
+ /**
69
+ * The engine block, plus the two words a reader needs when it cannot run.
70
+ *
71
+ * DERIVED FROM THE ID AT READ TIME, never stored in the capture. A capture written by an older build
72
+ * carries neither field, and a page that read them out of the capture would go quiet about the engine
73
+ * on exactly the deployments most likely to be misconfigured. The id is in every capture there has
74
+ * ever been, and the table is in this build.
75
+ *
76
+ * NULL FOR AN ENGINE THIS BUILD DOES NOT SHIP, which the row already has its own sentence for. Naming
77
+ * a program for an engine that does not exist here would be an invented fact.
78
+ */
79
+ function withProgram(engine) {
80
+ if (!engine) return engine;
81
+ const spec = ENGINE_BINARIES[engine.id] ?? null;
82
+ return {
83
+ ...engine,
84
+ // A BARE NAME, NEVER A RESOLVED PATH. `fallback` is what the table calls the program when nothing
85
+ // overrides it — "claude" — and it is what a reader types. The resolved path is this machine's
86
+ // layout and is deliberately kept out of anything a browser renders.
87
+ program: spec?.fallback ?? null,
88
+ install: spec?.install ?? null,
89
+ };
90
+ }
91
+
63
92
  function postureView(snap) {
64
93
  return {
65
94
  flags: Object.entries(snap.flags ?? {}).map(([name, f]) => ({
@@ -72,7 +101,7 @@ function postureView(snap) {
72
101
  killSwitch: (snap.killSwitches ?? []).includes(name),
73
102
  })),
74
103
  built: snap.built ?? null,
75
- engine: engineFor(snap),
104
+ engine: withProgram(engineFor(snap)),
76
105
  engineMode: engineFor(snap) ? engineMode(engineFor(snap)) : null,
77
106
  providers: providersFor(snap),
78
107
  };
@@ -239,6 +268,41 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
239
268
  };
240
269
  }
241
270
 
271
+ /**
272
+ * Where the staff-domain rule was written, so a reader can go and undo it.
273
+ *
274
+ * ── WHY A PAGE THAT NAMES A RULE MUST ALSO NAME ITS ADDRESS ─────────────────────────────────────────
275
+ *
276
+ * The People & access screen renders the rule — "Anyone at <domain> — a rule, not a person" — and said
277
+ * nothing about where it came from. A reader who does not recognise the domain therefore learns that
278
+ * strangers may hold an administrator's view of their instance and has no next step at all: the value
279
+ * is in an environment variable, in one of two files depending on how the instance is run, and neither
280
+ * is named anywhere on the screen. The one outside reader who met this reported it as a back door,
281
+ * twice, which is the correct thing to do with an access rule you cannot trace.
282
+ *
283
+ * PURE, and it answers "could not tell" as itself. `envLoad` is `shared/env-local.mjs`'s own report of
284
+ * what this process read, so the answer describes the process actually serving the page rather than
285
+ * being composed from a path that some other process would have read — the distinction that module
286
+ * exists for. A service started by systemd took its configuration from an EnvironmentFile; a child of
287
+ * `clearotron start` was handed an explicit environment and read no file at all; a hand-run CLI read
288
+ * the CLI's file. Each gets its own sentence, because the remedy is a different file in each.
289
+ */
290
+ export function staffRuleSource({ name = "PORTAL_STAFF_DOMAINS", value = "", envLoad = null,
291
+ unitEnvFile = null, cliEnvFile = null } = {}) {
292
+ if (!String(value ?? "").trim()) return null;
293
+ const reason = envLoad?.reason ?? null;
294
+ const applied = Array.isArray(envLoad?.applied) ? envLoad.applied : [];
295
+ if (reason === "read" && applied.includes(name))
296
+ return { name, where: `read from ${envLoad.path}` };
297
+ if (reason === "service-managed")
298
+ return { name, where: unitEnvFile ? `set in this service's environment file, ${unitEnvFile}` : "set in this service's environment" };
299
+ if (reason === "opted-out")
300
+ return { name, where: cliEnvFile
301
+ ? `handed to this service by the command that started it, which takes it from ${cliEnvFile} or derives it from the sign-in address`
302
+ : "handed to this service by the command that started it" };
303
+ return { name, where: cliEnvFile ? `set in this service's environment (the file it would otherwise read is ${cliEnvFile})` : "set in this service's environment" };
304
+ }
305
+
242
306
  /**
243
307
  * The enrolment view: who is granted what, and where an enrolment is half done.
244
308
  *
@@ -246,7 +310,7 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
246
310
  * they are reported separately — a staff member absent from the grants file is normal, not a fault,
247
311
  * and listing them as "unenrolled" would bury the real problems.
248
312
  */
249
- export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null }) {
313
+ export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null, staffRule = null }) {
250
314
  const tenants = grants?.tenants ?? {};
251
315
  const known = new Set(knownAccounts);
252
316
  const people = [];
@@ -277,6 +341,10 @@ export function accessView({ grants, staffDomains = [], knownAccounts = [], gran
277
341
  return {
278
342
  people: people.sort((a, b) => a.email.localeCompare(b.email)),
279
343
  staffDomains: [...staffDomains],
344
+ // An ADDITIONAL field rather than a reshape of `staffDomains`: that array is parsed by the browser
345
+ // contract and read by three screens' worth of arms, and a rule nobody can trace is a copy problem,
346
+ // not a data-shape problem. Null when there is no rule, or when the source could not be told.
347
+ staffRule,
280
348
  // Accounts named in grants that no profile matches — the other typo direction.
281
349
  unknownAccounts: [...unknownAccounts].sort(),
282
350
  // Where to go to change any of this — a filename and a date, so "I want to add someone" has a
@@ -18,7 +18,7 @@
18
18
  // ── the shape ────────────────────────────────────────────────────────────────────────────────────────
19
19
  //
20
20
  // { "schema": 1,
21
- // "families": { "hydra-range": { "name": "Hydra range", "account": "aurora" } },
21
+ // "families": { "hydra-range": { "name": "Hydra range", "account": "foxglade" } },
22
22
  // "of": { "<runId>": "hydra-range" } }
23
23
  //
24
24
  // Keyed by RUN rather than by mark. A mark is not a thing the pool stores — it is a grouping the browser
@@ -47,6 +47,7 @@ import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypt
47
47
  import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
48
48
  import { dirname, join } from "node:path";
49
49
  import { homedir } from "node:os";
50
+ import { envPrefix } from "../shared/os-advice.mjs";
50
51
 
51
52
  // ── the credential record ────────────────────────────────────────────────────────────────────────
52
53
  //
@@ -164,7 +165,10 @@ export function passphraseResetCommand({ prefix = "", credentialPath = null, env
164
165
  const base = `${prefix}clearotron passphrase --reset`;
165
166
  const path = credentialPath ?? env.PORTAL_LOCAL_CREDENTIAL ?? null;
166
167
  if (!path || path === credentialPathFor({}, home)) return base;
167
- return `PORTAL_LOCAL_CREDENTIAL=${path} ${base}`;
168
+ // `VAR=value cmd` IS POSIX-ONLY. PowerShell has no such juxtaposition — the assignment is its own
169
+ // statement there — so this line told a Windows reader their variable name was not a cmdlet, naming
170
+ // the wrong half of the command as the fault. Reported from a real run.
171
+ return `${envPrefix("PORTAL_LOCAL_CREDENTIAL", path)}${base}`;
168
172
  }
169
173
 
170
174
  /**
@@ -21,7 +21,7 @@ import { SUMMARY_BLOCK_LINE } from "../shared/summary-blocks.mjs";
21
21
  // a brand lockup, links to the pool index, status, profiles, and a "Clients" dropdown listing
22
22
  // EVERY customer by key:
23
23
  //
24
- // <a href="../customer/zephyr/">… <a href="../customer/aurora/">…
24
+ // <a href="../customer/coastline/">… <a href="../customer/foxglade/">…
25
25
  //
26
26
  // That is correct for a file a lawyer opens from the archive, and it is a disclosure of the client list
27
27
  // to anybody else. It has to come out before the document is embedded, and not because the nav is ugly:
@@ -38,7 +38,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
38
38
  import { storeInRepo, storeOutsideRepoMessage, makeCommittableAudit, resolveStoreRepoRoot, makeStoreCommit } from "../shared/store-in-repo.mjs"; //,
39
39
  import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mjs"; // — one store for the surface and the runs
40
40
  import { clientFailureNote } from "../shared/client-failure-note.mjs"; // — one sentence, three surfaces
41
- import { bareInvocation, invocationPrefix } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
41
+ import { bareInvocation, invocationPrefix, installRoute } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
42
42
  import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
43
43
  import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
44
44
  // — the portal became an ISSUANCE PATH here, deliberately and by owner ruling.
@@ -92,7 +92,7 @@ import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor
92
92
  import { isDemo, demoPostureLine } from "./demo-posture.mjs";
93
93
  import { triggerCapGap, triggerCapWarning } from "./trigger-cap.mjs"; // F51 — one answer, three surfaces
94
94
  import { makeUpstream } from "./portal-upstream.mjs";
95
- import { flagView, accessView, observedView, authView } from "./portal-config-view.mjs";
95
+ import { flagView, accessView, observedView, authView, staffRuleSource } from "./portal-config-view.mjs";
96
96
  import { livePosture } from "./flag-snapshot.mjs"; // — for the capture-vs-box comparison only, never for a value
97
97
  import { familiesView, groupRuns, ungroupRuns } from "./portal-families.mjs";
98
98
  import { validateJob } from "./enqueue-schema.mjs";
@@ -1029,6 +1029,11 @@ function outcomeRow({ event = "request-refused", method, path, email = null, sta
1029
1029
  export function makePortalService({
1030
1030
  poolRoot, workspaceRoot, recipesDir = undefined, secret,
1031
1031
  staffDomains = [], grants = null,
1032
+ // Where the staff-domain rule is written, for the People & access page to name. INJECTED, because
1033
+ // the answer is a fact about the PROCESS — which file, if any, it took its configuration from — and
1034
+ // this constructor is deliberately pure over its inputs. Null means "no rule, or could not tell",
1035
+ // and the page then says nothing rather than guessing at a path.
1036
+ staffRule = null,
1032
1037
  // The queue directories the RUNNER drains — the same list it hands checkRunCaps. The allowance counter
1033
1038
  // and the quota pre-check read their ledger beside these, so they count what the wall counts (:
1034
1039
  // they used to reconstruct a workspace-relative path that resolved to nothing once the queue moved out
@@ -1328,6 +1333,48 @@ export function makePortalService({
1328
1333
  };
1329
1334
  const selectorOf = (body) => (body.recipeKey ? `recipe:${body.recipeKey}` : `product:${body.product || "the account's default"}`);
1330
1335
 
1336
+ // ── The engine program, read once, for every surface that reports it ───────────────────────────
1337
+ //
1338
+ // WHY /me NEEDS THIS AT ALL. The settings page can ask `/portal/admin/config`, which already computes
1339
+ // the live-versus-capture comparison. New clearance cannot: that route is staff-only, and the reader
1340
+ // who gets stuck here is as often a client. A screen that asked it would get a 404 and fall back to
1341
+ // the generic advice — which is the exact defect this exists to end, delivered to the exact reader
1342
+ // who reported it.
1343
+ //
1344
+ // WHAT IT COSTS ON A NORMAL PAGE VIEW: NOTHING, and that is structural rather than a promise. The
1345
+ // question only has a wrong answer in one state — the capture says there is no engine program — so
1346
+ // the live reading is taken only in that state. An install with a working engine never reaches the
1347
+ // probe, and /me is the hottest endpoint in the portal.
1348
+ //
1349
+ // AND IT IS THE SAME MECHANISM THE SETTINGS PAGE USES, deliberately: `flagView` with a live posture,
1350
+ // and the same `engine program` row out of `lastRun.disagrees`. Two surfaces answering this question
1351
+ // by two routes is how they came to contradict each other in the first place.
1352
+ //
1353
+ // NOT CACHED, AND THAT IS THE POINT OF THE CHANGE. A held reading was written first and removed: it
1354
+ // would have meant a reader who restarted the engine service went on being told to restart it for
1355
+ // as long as the window lasted — the staleness this whole change exists to end, in miniature, in
1356
+ // the one place a reader is standing when they act on it. Measured in testing, 2026-09-08: 0.28ms
1357
+ // for a reading taken cold with the imports warm, and a 0.29ms median over 50 requests in the state
1358
+ // that takes one on every request, against 0.04ms in the state that takes none. It is taken only
1359
+ // where a search is already refusing, so there is nothing here worth trading a wrong answer for.
1360
+ async function engineProgramDisputed(mode) {
1361
+ // NOT DEMO, NOTHING TO DISPUTE. The capture already sees the program, so the only disagreement
1362
+ // left is the mirror — this box cannot see what the engine could — and that one does not change
1363
+ // what this screen says: the screen is not refusing a search in that state.
1364
+ if (mode !== "demo") return false;
1365
+ try {
1366
+ const live = await livePosture();
1367
+ // `disagrees` is [] on agreement, rows on disagreement, and null when there is no capture to
1368
+ // compare against — three facts, and only the middle one is this. A null must not read as false.
1369
+ const rows = flagView(poolRoot, { live }).lastRun?.disagrees;
1370
+ return Array.isArray(rows) ? rows.some((d) => d.what === "engine program") : null;
1371
+ } catch {
1372
+ // COULD NOT LOOK, AND IT SAYS SO. Null travels to the screen as "unknown" and the screen prints
1373
+ // the general advice, which is what it printed before this existed.
1374
+ return null;
1375
+ }
1376
+ }
1377
+
1331
1378
  async function route(method, path, identity, body = {}, query = {}) {
1332
1379
  const principal = makePrincipal({ email: identity?.email, grants: grantsNow(), staffDomains });
1333
1380
  const parts = path.replace(/\/+$/, "").split("/").filter(Boolean); // ["portal", ...]
@@ -1338,7 +1385,7 @@ export function makePortalService({
1338
1385
  assertPrincipal(principal, { door: true }); // door check only — a multi-account client enters and gets the picker list
1339
1386
  // `accountNames` — the DISPLAY name of each account this identity holds, and nothing else.
1340
1387
  //
1341
- // Every profile carries a name ("Vantor Labs", "Aurora Interactive"); the account KEY is a
1388
+ // Every profile carries a name ("Vantor Labs", "Foxglade Interactive"); the account KEY is a
1342
1389
  // slug ("vantor"). Staff read names because the account picker fetches the staff-only
1343
1390
  // roster; a client had no name source at all, so the identical screen printed the slug at them.
1344
1391
  // The same brand owner therefore read two different ways depending on who signed in, which is
@@ -1384,8 +1431,23 @@ export function makePortalService({
1384
1431
  // writes at boot and derives the mode from `binaryPresent`, which that snapshot already
1385
1432
  // carries. NULL when there is no snapshot to read, and null means THIS CANNOT ANSWER — the UI
1386
1433
  // must leave the button alone rather than infer demo from an absent file.
1434
+ // READ ONCE. The payload names it and the program reading below is gated on it; two calls to
1435
+ // `flagView` here would be two reads of the same file that could disagree with each other.
1436
+ const meEngineMode = flagView(poolRoot).engineMode;
1387
1437
  return { status: 200, json: { role: principal.role, email: principal.email, accounts: principal.accounts, accountNames,
1388
- concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode: flagView(poolRoot).engineMode,
1438
+ concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode: meEngineMode,
1439
+ // WHETHER THE PROGRAM IS ON THIS BOX WHILE THE ENGINE CANNOT SEE IT — true, false, or null
1440
+ // for "this could not be checked". The screen above renders one of three remedies from it,
1441
+ // and they are different remedies: install the CLI, restart the service that cannot see it,
1442
+ // or the general advice when nothing could be read. Only `engineMode: "demo"` can make this
1443
+ // anything but false; see `engineProgramDisputed`.
1444
+ engineProgramDisputed: await engineProgramDisputed(meEngineMode),
1445
+ // HOW THIS INSTALL ARRIVED, so a screen can name the setup command the reader can actually
1446
+ // type. `npm run setup` and `npx clearotron install` are the same wizard and each one is
1447
+ // unrunnable on the other route; the no-engine notice named one of them and was wrong for
1448
+ // half its readers. A WORD, never a command line and never a prefix: `invocationForm` can
1449
+ // answer with this machine's absolute path, and this value is rendered in a browser.
1450
+ setupRoute: installRoute(),
1389
1451
  // — a button that always fails must not render as available. The reason is
1390
1452
  // operator-shaped and staff-only; a client reads the generic sentence the button carries.
1391
1453
  controls: { stop: { available: stopControl.available !== false,
@@ -2131,7 +2193,7 @@ export function makePortalService({
2131
2193
  // command is a true fact about THIS INSTALL'S OWN DISK, useful to anyone with a shell on the
2132
2194
  // box and useless to a hosted client who has no checkout. On a local install the reader IS the
2133
2195
  // operator, which is why the split that already exists does the work an "is this deployment
2134
- // local" inference would have done badly. Agreed with overwatch before building, because it
2196
+ // local" inference would have done badly. Agreed before building, because it
2135
2197
  // changes what a signed-in staff user is shown.
2136
2198
  //
2137
2199
  // COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
@@ -2822,7 +2884,7 @@ export function makePortalService({
2822
2884
  const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
2823
2885
  if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
2824
2886
  } catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
2825
- return { status: 200, json: accessView({ grants: grantsNow(), staffDomains, knownAccounts, grantsFile }) };
2887
+ return { status: 200, json: accessView({ grants: grantsNow(), staffDomains, knownAccounts, grantsFile, staffRule }) };
2826
2888
  }
2827
2889
  // /portal/admin/observed — who has actually USED this instance lately, from the audit log.
2828
2890
  //
@@ -3506,7 +3568,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
3506
3568
  // local-auth block, so calling it from here threw ReferenceError and this route answered 500 —
3507
3569
  // worse than the dead link it replaced, because a dead link looks like a dead link and a 500
3508
3570
  // looks like the server broke. Found by `npm run lint:driver`, which says it in one line
3509
- // (`'redirect' is not defined`), and by role-dev/Grogu driving the route rather than reading it.
3571
+ // (`'redirect' is not defined`), and by driving the route rather than reading it (review, 2026-08).
3510
3572
  // Hoisting that helper would move it away from the comment explaining its own no-`?next=` rule,
3511
3573
  // so the four lines live here instead.
3512
3574
  if (!localAuth && url.pathname === "/portal/sign-out") {
@@ -3915,6 +3977,14 @@ const PORT = PORT_CHOICE.port;
3915
3977
  const grants = () => loadGrants({});
3916
3978
  const staffDomains = (process.env.PORTAL_STAFF_DOMAINS || "").split(",").map((s) => s.trim()).filter(Boolean);
3917
3979
  if (!staffDomains.length && !grants()) { log(`FATAL: neither PORTAL_STAFF_DOMAINS nor CLEAROTRON_ACCESS_FILE configured — nobody could ever sign in (fail-closed).`); process.exit(1); }
3980
+ // WHERE THAT RULE IS WRITTEN, resolved once at boot and handed to the service. `loaded` is this
3981
+ // process's own report of which file configured it — never a path composed here, which would answer
3982
+ // for a process that read nothing (see `envFileRead`'s note in shared/env-local.mjs).
3983
+ const { loaded, unitEnvPath, envLocalPath } = await import("../shared/env-local.mjs");
3984
+ const staffRule = staffRuleSource({
3985
+ value: process.env.PORTAL_STAFF_DOMAINS, envLoad: loaded,
3986
+ unitEnvFile: unitEnvPath(), cliEnvFile: envLocalPath(),
3987
+ });
3918
3988
 
3919
3989
  const { config } = await import("./driver.config.mjs");
3920
3990
  const { appendFileSync: append } = await import("node:fs");
@@ -4188,7 +4258,7 @@ const PORT = PORT_CHOICE.port;
4188
4258
  // The result was not a failure. It was worse. With the overlay unset, resolveSkillPath falls back
4189
4259
  // to the PRODUCT REPO's own driver/skills — where the customer frameworks either do not exist
4190
4260
  // (a config-store-only customer → the page's fail-loud "could not be read" card) or exist as the SYNTHETIC
4191
- // DEMO fixtures the sellable codebase ships (Aurora, Zephyr "Aurora Interactive risk framework
4261
+ // DEMO fixtures the repository carries for the suite (a framework titled " risk framework
4192
4262
  // (synthetic demo)", source_deck "content invented"). The second case renders with a title, a band
4193
4263
  // ladder and band meanings, and is indistinguishable on screen from the client's real framework.
4194
4264
  // A lawyer read invented risk definitions as their client's own for as long as this was live.
@@ -4356,7 +4426,7 @@ const PORT = PORT_CHOICE.port;
4356
4426
  const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
4357
4427
  // Re-read per request (a getter that rescans), so a workspace created after boot is counted.
4358
4428
  queueDirs: () => config.queueDirs,
4359
- secret, staffDomains, grants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4429
+ secret, staffDomains, staffRule, grants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4360
4430
  // — the ONLY place the environment is read for this. `bin/start.mjs` is the
4361
4431
  // only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
4362
4432
  // environment through, so a stray `.env` can neither put a live install into demo mode nor take a
@@ -96,7 +96,7 @@ export const PATH_FIELDS = ["frameworkPath", "workedExamplesPath"];
96
96
  /**
97
97
  * The code-owned values, READ-ONLY, for display. The page shows them badged; it cannot send them.
98
98
  *
99
- * Two of them are PATHS INSIDE THE ENGINE — `skills/prelim-search/risk-framework-zephyr.md` — and they
99
+ * Two of them are PATHS INSIDE THE ENGINE — `skills/prelim-search/risk-framework-<customer>.md` — and they
100
100
  * are withheld from a client here, on the server, where the role is already in hand. The React page has
101
101
  * filtered them out of its own render since the rebuild, but a filter in the browser is a display
102
102
  * convenience and not a wall: the value still crossed the wire and was one devtools tab away.
@@ -129,7 +129,7 @@ export function readOnlyFields(profile, { staff = false } = {}) {
129
129
  *
130
130
  * house "…IP Risk Assessment Framework.pptx (Privileged & Confidential), transcribed 2026-07-05
131
131
  * (doc 50); supersedes the client-transposed neutral default"
132
- * aurora "Synthetic demo transposition of a customer risk deck (structure faithful, content
132
+ * demo "Synthetic demo transposition of a customer risk deck (structure faithful, content
133
133
  * invented), doc 50 shape"
134
134
  * triage "Ported from the interactive knockout-searches skill's 5-tier rating system (SKILL.md
135
135
  * rating table + calibration rules), doc 50 shape"