clearotron 0.2.2 → 0.2.3

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 (118) hide show
  1. package/.env.example +28 -0
  2. package/.nvmrc +1 -1
  3. package/INSTALL.md +13 -11
  4. package/README.md +11 -16
  5. package/THIRD-PARTY-NOTICES.md +1 -1
  6. package/bin/clearotron.mjs +22 -0
  7. package/bin/example.mjs +4 -0
  8. package/bin/onboard.mjs +195 -19
  9. package/bin/start.mjs +154 -8
  10. package/build-info.json +2 -2
  11. package/demo/full-country-search/run/_driver/framework.json +1 -1
  12. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  13. package/demo/knockout-search/run/_driver/framework.json +1 -1
  14. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  15. package/docs/E2E.md +5 -5
  16. package/docs/PORTAL.md +2 -2
  17. package/docs/README.md +5 -4
  18. package/docs/SECURITY.md +17 -0
  19. package/docs/architecture/04-configuration-reference.md +2 -0
  20. package/docs/architecture/05-config-governance.md +14 -0
  21. package/docs/architecture/05-customer-profiles.md +2 -2
  22. package/docs/configuration.md +4 -3
  23. package/driver/CHANGELOG.md +56 -0
  24. package/driver/common-law-receipts.mjs +1 -1
  25. package/driver/config-staleness.mjs +1 -1
  26. package/driver/dev-portal.mjs +2 -2
  27. package/driver/driver.config.mjs +1 -1
  28. package/driver/engine/cli-version.mjs +103 -0
  29. package/driver/enqueue-schema.mjs +8 -8
  30. package/driver/feedback-store.mjs +1 -1
  31. package/driver/findings-model.mjs +52 -2
  32. package/driver/flag-snapshot.mjs +20 -0
  33. package/driver/framework.mjs +1 -1
  34. package/driver/gateway.mjs +18 -1
  35. package/driver/package.json +3 -3
  36. package/driver/party-facts.mjs +2 -2
  37. package/driver/phase0.mjs +1 -1
  38. package/driver/pipeline.mjs +5 -5
  39. package/driver/plain-register.mjs +120 -0
  40. package/driver/portal-access.mjs +1 -1
  41. package/driver/portal-config-view.mjs +40 -1
  42. package/driver/portal-families.mjs +1 -1
  43. package/driver/portal-report.mjs +1 -1
  44. package/driver/portal-service.mjs +26 -7
  45. package/driver/portal-upstream.mjs +2 -2
  46. package/driver/predelivery-lint.mjs +121 -1
  47. package/driver/profile-page.html +10 -2
  48. package/driver/profile-service.mjs +4 -4
  49. package/driver/profiles/README.md +3 -3
  50. package/driver/profiles.mjs +144 -12
  51. package/driver/publish/index.mjs +2 -2
  52. package/driver/publish/knockout.mjs +7 -1
  53. package/driver/publish/render-knockout.mjs +453 -118
  54. package/driver/publish/render.mjs +2 -2
  55. package/driver/reference-strip-signatures.mjs +20 -0
  56. package/driver/search-policy.mjs +2 -2
  57. package/driver/skills/blind-frame/SKILL.md +1 -1
  58. package/driver/skills/knockout-assess/SKILL.md +84 -0
  59. package/driver/skills/matter-frame/SKILL.md +6 -6
  60. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  61. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  62. package/driver/skills/prelim-search/SKILL.md +1 -1
  63. package/driver/skills/prelim-search/report-prose.md +82 -0
  64. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  65. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  66. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  67. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  68. package/driver/skills/prelim-variants/SKILL.md +3 -3
  69. package/driver/stages.mjs +4 -4
  70. package/driver/suite-census.json +134 -38
  71. package/driver/unit-inventory.mjs +184 -27
  72. package/driver/verify.mjs +1 -1
  73. package/driver/whatif-memo-run.mjs +26 -4
  74. package/driver/whatif-memo.mjs +19 -1
  75. package/examples/README.md +2 -2
  76. package/examples/grants.example.json +6 -7
  77. package/mcp-server/CHANGELOG.md +2 -0
  78. package/mcp-server/lib/scrub.mjs +1 -1
  79. package/mcp-server/lib/whatif.mjs +2 -2
  80. package/mcp-server/package.json +1 -1
  81. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  82. package/mcp-server/server.mjs +1 -1
  83. package/package.json +28 -10
  84. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  85. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-CEYvXdqH.js} +226 -49
  86. package/portal-ui/dist/index.html +2 -2
  87. package/portal-ui/package.json +1 -1
  88. package/providers/_shared/term-shape.mjs +1 -1
  89. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  90. package/providers/oauth-mcp-bridge/package.json +1 -1
  91. package/providers/uspto-local/src/index-store.js +2 -2
  92. package/scripts/citation-line-check.mjs +121 -2
  93. package/scripts/env-classify.mjs +67 -3
  94. package/scripts/live-surface-check.mjs +38 -1
  95. package/scripts/nightly-notice.mjs +0 -2
  96. package/scripts/preinstall-node-check.mjs +24 -0
  97. package/scripts/strip-tracker-citations.mjs +29 -6
  98. package/scripts/test-run.mjs +16 -0
  99. package/shared/anon-overlay.mjs +3 -3
  100. package/shared/customer-store.mjs +1 -1
  101. package/shared/invocation.mjs +28 -0
  102. package/shared/names-in-force.mjs +2 -0
  103. package/shared/node-floor.mjs +97 -0
  104. package/shared/staff-domain.mjs +154 -0
  105. package/shared/store-in-repo.mjs +23 -2
  106. package/skills/clearotron-ops/SKILL.md +1 -1
  107. package/driver/recipes/README.md +0 -49
  108. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  109. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  110. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  111. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  112. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  113. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  114. package/scripts/ai-page-render-check.mjs +0 -532
  115. package/scripts/clearances-render-check.mjs +0 -480
  116. package/scripts/composer-render-check.mjs +0 -1027
  117. package/scripts/home-render-check.mjs +0 -526
  118. package/scripts/portal-lifecycle-check.mjs +0 -639
@@ -0,0 +1,103 @@
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 VERSION OF THE BINARY THAT SERVED A RUN, captured at dispatch.
5
+ //
6
+ // The record already says whether the model id the provider reported names a pinned build or an alias it
7
+ // may repoint. That answers "did the model move". It cannot answer "did the TOOL move", and the two are
8
+ // different questions with the same symptom: a run whose judgment differs from last week's.
9
+ //
10
+ // Nothing recorded it. Three archived runs were walked for every spelling of a version field and carried
11
+ // none — so what served them is not recoverable, and the box could have answered at any time.
12
+ //
13
+ // ── UNREADABLE IS A VALUE, NOT AN OMISSION ──────────────────────────────────────────────────────────
14
+ //
15
+ // The whole point of the field is telling three states apart, and only one of them is "we know":
16
+ //
17
+ // { version: "2.1.241", probe: "ok" } the binary answered
18
+ // { version: null, probe: "unreadable", why } it was asked and could not say
19
+ // (no field at all) this record predates the gauge
20
+ //
21
+ // An omission on failure collapses the middle into the third, and a reader comparing two runs cannot
22
+ // tell a tool that would not answer from a record written before anybody asked. That distinction is the
23
+ // reason for the field, so failing to write it is failing at the thing rather than at the edge of it.
24
+ //
25
+ // ── ONE SPAWN PER BUILD, NOT PER PROCESS ────────────────────────────────────────────────────────────
26
+ //
27
+ // A dispatch is many stages and every stage would otherwise pay. But the cache lives as long as the
28
+ // process, and the process is NOT one run: the drainer's watch loop calls the pipeline for job after job
29
+ // without exiting. A first version cached there would be reported as fact for every later run in that
30
+ // process — including runs served by a binary somebody upgraded in place underneath it.
31
+ //
32
+ // That is the exact silence this field exists to end, reintroduced by the cache meant to make it cheap,
33
+ // and it would have been invisible: the record would carry a version, confidently, and be wrong.
34
+ //
35
+ // So the key is the path AND what the filesystem says about the file — an in-place upgrade changes the
36
+ // modification time and the size, so it misses the cache and is probed again. The path alone is not
37
+ // enough (two engines can point at one binary, an engine can be repointed) and the engine id is not
38
+ // enough for the same reason.
39
+ //
40
+ // A file the filesystem cannot describe is NOT CACHED at all. Caching an unreadable probe under a key
41
+ // derived from a failed stat would pin the failure for the life of the process, so a binary that
42
+ // appeared a moment later would keep reading as absent.
43
+ // ── WHAT THIS DEPENDS ON, WHICH IS NOT ENFORCEABLE FROM HERE ────────────────────────────────────────
44
+ //
45
+ // A probe that can change what it probes is not a probe. This one spawns the engine binary, so it rests
46
+ // on `--version` being side-effect-free — true of every real CLI and not something this module can make
47
+ // true. It bit immediately: the suite's engine stand-ins fell through to their stage path, and one of
48
+ // them counts invocations to decide when to fail, so the probe consumed the failure a retry test was
49
+ // measuring and the retry never happened. The symptom was an attempt count off by one, three files away
50
+ // from the cause.
51
+ //
52
+ // The stand-ins now answer `--version` and exit, which is what the binaries they stand in for do. A new
53
+ // one that forgets will produce the same off-by-one, so `driver/test/a-run-records-the-tool-that-served-it`
54
+ // asserts every engine stand-in answers — the cheap ratchet under a condition that cannot be checked at
55
+ // the call site.
56
+ import { execFileSync } from "node:child_process";
57
+ import { statSync } from "node:fs";
58
+
59
+ /** Live for the process, keyed by resolved path. A run is one process; a probe is one spawn. */
60
+ const CACHE = new Map();
61
+
62
+ /** The first version-shaped token in the output, or the whole first line when nothing matches. */
63
+ export function parseVersion(out) {
64
+ const line = String(out ?? "").split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
65
+ if (!line) return null;
66
+ // These CLIs answer `2.1.241`, `codex-cli 0.5.0`, `claude 2.1.241 (Claude Code)`. Take the first
67
+ // dotted number and keep it; a build that answers in prose is recorded verbatim rather than dropped,
68
+ // because a string somebody can compare beats a null.
69
+ return (line.match(/\b\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?\b/) ?? [line])[0];
70
+ }
71
+
72
+ /**
73
+ * Probe one binary. PURE apart from the spawn, which is injected so an arm can drive both branches
74
+ * without a binary on the box — the unreadable branch is the one that matters and it cannot be produced
75
+ * on demand from a real install.
76
+ *
77
+ * Never throws. A probe that could take down a dispatch would be a worse defect than the gap it closes.
78
+ */
79
+ export function probeCliVersion(bin, { run = null, timeoutMs = 5000, cache = CACHE, stat = statSync } = {}) {
80
+ if (!bin) return { version: null, probe: "unreadable", why: "no engine binary was resolved" };
81
+ let key = null;
82
+ try { const st = stat(bin); key = `${bin}\u0000${st.mtimeMs}:${st.size}`; } catch { /* not cacheable */ }
83
+ if (key && cache.has(key)) return cache.get(key);
84
+ const spawn = run ?? ((b) => execFileSync(b, ["--version"], {
85
+ encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"],
86
+ }));
87
+ let result;
88
+ try {
89
+ const version = parseVersion(spawn(bin));
90
+ result = version
91
+ ? { version, probe: "ok" }
92
+ // It ran and said nothing a version could be read from. That is not the same as failing to run,
93
+ // and a reader chasing a tool change needs to know which happened.
94
+ : { version: null, probe: "unreadable", why: "the binary answered with no version-shaped token" };
95
+ } catch (e) {
96
+ result = { version: null, probe: "unreadable", why: String(e?.message ?? e).slice(0, 160) };
97
+ }
98
+ if (key) cache.set(key, result);
99
+ return result;
100
+ }
101
+
102
+ /** Drop the cache. For arms, and for a caller that has just repointed an engine deliberately. */
103
+ export function forgetCliVersions(cache = CACHE) { cache.clear(); }
@@ -237,7 +237,7 @@ function niceClassErrors(list, label) {
237
237
  // and reaches the report body, where it REVERSES THE DISPLAY of everything after it. The report is a
238
238
  // legal deliverable that goes to a client.
239
239
  //
240
- // A zero-width joiner makes "AUR\u200DORA" render as AURORA and compare unequal to it, so
240
+ // A zero-width joiner makes "IRON\u200DWHISK" render as IRONWHISK and compare unequal to it, so
241
241
  // `selfExclusionOwners` and dedup both miss a mark a human reads as the account's own.
242
242
  //
243
243
  // NFD "SIRÈNE" !== NFC "SIRÈNE", so one mark submitted two ways is two matters, two searches, two bills.
@@ -285,7 +285,7 @@ const markNameSites = (job) => {
285
285
  for (const f of ["markName", "name"]) if (job?.[f] != null) sites.push([f, () => job[f], (v) => { job[f] = v; }]);
286
286
  if (Array.isArray(job?.marks)) job.marks.forEach((m, i) => {
287
287
  // A BARE STRING IS A MARK NAME, and this shape reaches validateJob unconverted. `assembleFromFlags`
288
- // turns `marks: ["AURORA"]` into `[{ name: "AURORA" }]`, so every door that assembles is already
288
+ // turns `marks: ["IRONWHISK"]` into `[{ name: "IRONWHISK" }]`, so every door that assembles is already
289
289
  // covered — but the runner's wall calls validateJob({ atClaim: true }) on the MANIFEST AS IT SITS ON
290
290
  // DISK, which nothing re-assembles. The kebab-collision check below reads the same shape for exactly
291
291
  // that reason. Walking only `m.name` would leave this guard covering every door except the one it
@@ -778,16 +778,16 @@ export function validateJob(job, { atClaim = false } = {}) {
778
778
  // that would drop the customer's platforms, self-exclusion seed and the framework that RATES the matter".
779
779
  // A MISSING key returns generic in silence. Both produce the same wrong deliverable, and only one says so.
780
780
  //
781
- // It has already happened: a paid Zephyr Beverages clearance was rated on the Generic default scale because the
782
- // request named the customer in prose and left the account field empty. Nothing objected. A person
783
- // noticed hours later and re-ran it.
781
+ // It has already happened: a paid clearance for a test account was rated on the Generic default scale
782
+ // because the request named the customer in prose and left the account field empty. Nothing objected.
783
+ // A person noticed hours later and re-ran it.
784
784
  //
785
785
  // WHY THIS IS NOT SIMPLY "customer present, key absent". That shape is byte-identical to a LEGITIMATE
786
786
  // third-party search — a firm asking us to clear a mark for their own client, who is not our account —
787
787
  // and the applicant deliberately never selects a profile, because a third-party search must never inherit
788
788
  // a customer's exclusions. Clarifying on that shape alone would bounce real work every day.
789
789
  //
790
- // The discriminator is the ROSTER. The Zephyr Beverages request differed in one way that matters: we hold an
790
+ // The discriminator is the ROSTER. That request differed in one way that matters: we hold an
791
791
  // account by that name. A named applicant we have no account for is a third-party search and runs
792
792
  // untouched; a named applicant who IS one of our customers, with no key, is ambiguous in a way only a
793
793
  // person can settle — did intake forget to tag it, or is this genuinely a search against a name that
@@ -965,7 +965,7 @@ export const EXAMPLE_JOB = {
965
965
  rawRequest: "<the verbatim forwarded email text, untouched — archived as inbound-request.txt (§A5)>",
966
966
  brief: "<the confirmation brief exactly as sent to the requester — archived as confirmation-brief.md>",
967
967
  deadline: "2026-06-20T17:00:00Z", // optional; drives the §A3 deadline-envelope arithmetic
968
- profileKey: "aurora", // D4.1: the customer ACCOUNT the intake AI resolved → selects the
968
+ profileKey: "demo-brand-owner", // D4.1: the customer ACCOUNT the intake AI resolved → selects the
969
969
  // profile (marketplaces/classes/delivery/appetite); omit ⇒ generic
970
970
  product: "multi-country-focus-search",// WHICH OF THE FOUR (OPTIONAL): one of products.mjs PRODUCT_IDS.
971
971
  // Omit ⇒ the project/customer defaultProduct, else the product
@@ -987,7 +987,7 @@ export const EXAMPLE_JOB = {
987
987
  projectKey: "console-ecosystem", // spec 62 (OPTIONAL): the PROJECT/engagement under the customer whose
988
988
  // overlay (its own marketplaces/classes/sector/posture) rates this
989
989
  // matter; omit ⇒ runs on the customer profile. Unknown key ⇒ clarify.
990
- customer: "Aurora Interactive", // applicant/owner → affiliate self-exclusion set (§B3.2)
990
+ customer: "Demo Brand Owner", // applicant/owner → affiliate self-exclusion set (§B3.2)
991
991
  customerUnknown: false, // B5: true when the applicant is neither stated nor forwarder-implied —
992
992
  // arms candidate-self classification + the late-bind watch (NEVER inferred from the mark)
993
993
  // caseLaw — NOT A FIELD. The case-law and opposition reading is what a Full country search IS
@@ -19,7 +19,7 @@
19
19
  // come from report-data.json; the disposition comes from findings.json beside it in the same run dir,
20
20
  // because report-data.json is the CLIENT cut and stopped serving the engine's placement key. A
21
21
  // reader who finds the ordinal now points at `KURENA / Manageable / rebuttable` when the flag says
22
- // `PETCARY / Medium / conceded` knows the run was republished, which is exactly the thing a bare id
22
+ // `BURROWELL / Medium / conceded` knows the run was republished, which is exactly the thing a bare id
23
23
  // would have hidden.
24
24
 
25
25
  import { randomUUID } from "node:crypto";
@@ -168,8 +168,8 @@ export const ACTION_KINDS = [...CONDITION_KINDS, ...ADVISORY_KINDS];
168
168
  export const CLIENT_TIER_BY_COMPOSITE = { 1: "LOW", 2: "MANAGEABLE", 3: "MEDIUM", 4: "HIGH", 5: "VERY HIGH" };
169
169
 
170
170
  // ── doc 50 — band mode (schema_version 4): the framework in force rates the matter ────────────────────
171
- // A v4 finding carries `band` — one of the frozen framework manifest's ordered band words (zephyr says
172
- // "Medium", the Generic default says "Moderate", aurora has a "Low") — and NO composite/level/
171
+ // A v4 finding carries `band` — one of the frozen framework manifest's ordered band words (one ladder
172
+ // says "Medium", the Generic default says "Moderate", another has a "Low") — and NO composite/level/
173
173
  // dispute_type (one rating authority; the retired scale is FORBIDDEN, not just optional). Presentation
174
174
  // joins on the band's TONE (a closed enum in the manifest) so 4-band and 5-band ladders both land on the
175
175
  // existing badge/gauge ramps without any per-framework code.
@@ -2441,6 +2441,56 @@ export function knockoutFindingViews(mark, { manifest = null } = {}) {
2441
2441
  * Derived through knockoutFindingViews rather than re-deriving that fallback here — one projection
2442
2442
  * decides what a finding's number is, and this cell cannot disagree with the sheet it points into.
2443
2443
  */
2444
+ // ── THE REVIEWER'S NOTES: WHAT EACH ONE IS ABOUT (tracker issues 331 A.4, 333 rule 5) ───────────────
2445
+ //
2446
+ // The knockout page prints a note about the REQUEST at the top, above the conflicts, and a note about
2447
+ // the NAME under that name's cards. A note saying the screen may have been scoped to the wrong market
2448
+ // is the most consequential line on the page and it used to sit under roughly 1,900 words.
2449
+ //
2450
+ // THE READER LIVES HERE BECAUSE TWO MODULES NEED THE SAME ANSWER. The renderer sorts the notes; the
2451
+ // predelivery lint warns a writer whose note will sort the way they did not intend. Two copies of this
2452
+ // regex would drift, and the drift would be silent — the page would file a note one way while the
2453
+ // reviewer told the writer it went the other.
2454
+ //
2455
+ // IT SORTS ON WHAT THE NOTE TALKS ABOUT, and the doctrine is written to match: a note about the request
2456
+ // NAMES the request. The word set spans both vocabularies deliberately — "dispatch" and "instructed"
2457
+ // are what runs written before tracker issue 333 say, "the request" and "was asked" are what runs
2458
+ // written after it say — so one reader serves the archive and the new doctrine at once.
2459
+ export const REQUEST_NOTE_WORDS = /\b(?:dispatch|the request|the requester|instructed|was asked)\b/i;
2460
+
2461
+ // Subjects that belong to the ASKING rather than to the name. A note on one of these that never names
2462
+ // the request will be filed under the name, which is why the lint flags it rather than the page
2463
+ // guessing: the fix is one clause in the note, and only its writer can add it.
2464
+ export const REQUEST_SUBJECT_WORDS =
2465
+ /\b(?:customer industry|client(?:'s)? (?:own )?(?:prior|earlier) use|prior use|intended goods|the goods we|wrong market|described as an? )/i;
2466
+
2467
+ /** One note, in either shape, as { text, about }. `about` is null when nothing has classified it. */
2468
+ export function knockoutNoteView(n) {
2469
+ if (n && typeof n === "object" && !Array.isArray(n)) {
2470
+ const about = String(n.about ?? "").trim().toLowerCase();
2471
+ return { text: String(n.text ?? n.note ?? "").trim(), about: about === "request" || about === "name" ? about : null };
2472
+ }
2473
+ return { text: String(n ?? "").trim(), about: null };
2474
+ }
2475
+
2476
+ /**
2477
+ * A mark's notes, split into the two places the page prints them.
2478
+ *
2479
+ * The rater's own `about` wins outright where one is given; only an unclassified note meets the word
2480
+ * reader. That precedence is the whole design: it means a run written under a doctrine that no longer
2481
+ * uses these words is answered without them.
2482
+ */
2483
+ export function splitKnockoutNotes(mark) {
2484
+ const all = (Array.isArray(mark?.purpleNotes) ? mark.purpleNotes : []).map(knockoutNoteView).filter((v) => v.text);
2485
+ const request = [];
2486
+ const name = [];
2487
+ for (const v of all) {
2488
+ const isRequest = v.about ? v.about === "request" : REQUEST_NOTE_WORDS.test(v.text);
2489
+ (isRequest ? request : name).push(v.text);
2490
+ }
2491
+ return { request, name, all: all.map((v) => v.text) };
2492
+ }
2493
+
2444
2494
  export function knockoutFindingRange(mark) {
2445
2495
  const ords = knockoutFindingViews(mark).map((v) => v.ordinal).sort((a, b) => a - b);
2446
2496
  const markName = String(mark?.name ?? "").trim();
@@ -318,6 +318,26 @@ export function postureDisagreement(snapshot, live) {
318
318
  "silent-output-change — a different engine answers differently and nobody is told");
319
319
  differ("billing mode", snapshot.engine?.billing?.mode ?? null, live.engine?.billing?.mode ?? null,
320
320
  "what a run costs, and who it is billed to");
321
+ // THE FIELD THAT DECIDES WHETHER A SEARCH CAN START, and the one this comparison could not see.
322
+ //
323
+ // The configuration page reads the LIVE posture and the New clearance screen reads this capture, so a
324
+ // box where they differ on this one boolean draws a green Engine row on one screen while the other
325
+ // replaces its start button with "no search engine is attached". That is not a hypothetical: it is
326
+ // what an outside user photographed, and he gave up on the product because the greener screen looked
327
+ // more authoritative. Both readings were correct about their own question and nothing compared them.
328
+ //
329
+ // Worse than silence, before this line: `disagrees` came back `[]` — which this page renders as "the
330
+ // last run ran under this same configuration". The one field they actually disagreed on was not in the
331
+ // comparison, so the page positively affirmed agreement while the two surfaces contradicted each other.
332
+ // STRINGS, NOT THE BOOLEANS THEMSELVES. Every other row here compares names and modes, so the browser
333
+ // contract parses `capture` and `live` with `asString` — handed `false` it yields null, and the row
334
+ // would reach the page with its two values blank and only the effect sentence left. The words are also
335
+ // the better answer for a reader: "found" against "not found" says it without a legend.
336
+ const found = (v) => (v === true ? "found" : v === false ? "not found" : null);
337
+ differ("engine program", found(snapshot.engine?.binaryPresent), found(live.engine?.binaryPresent),
338
+ "whether a NEW search can start — the engine that last ran and this deployment do not agree that the "
339
+ + "engine program can be found, so one screen offers a search the other refuses. Restart the engine "
340
+ + "service so it re-reads its PATH, or install the CLI where the service can see it");
321
341
 
322
342
  // Flags: compare only names BOTH sides declare, for the same reason `differ` skips absent values —
323
343
  // a build that adds a flag must not read as every older capture disagreeing with it.
@@ -102,7 +102,7 @@ export function parseFrameworkManifest(raw) {
102
102
  // `rootDir` may be a string (legacy: join against it) OR a resolver function taking the manifest's
103
103
  // skills-relative path and returning an absolute one — the layered overlay-over-base lookup
104
104
  // (driver.config.resolveSkillPath). The driver MUST resolve a framework exactly as the agent does, or the
105
- // two read different files: that divergence is what killed the first Aurora Interactive run (framework_manifest_missing
105
+ // two read different files: that divergence is what killed a test account's first run (framework_manifest_missing
106
106
  // against the bundled tree while the agent was pointed at the config store).
107
107
  export function loadFrameworkManifest(rootDir, fwPath) {
108
108
  const rel = manifestPathFor(fwPath);
@@ -13,7 +13,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
13
13
  import { join, dirname, basename } from "node:path";
14
14
  import { driverDir } from "../shared/driver-dir.mjs"; //
15
15
  import { tmpdir } from "node:os";
16
- import { config, resolveModel, modelFamily, modelSnapshotKind, envOn, envGateOn } from "./driver.config.mjs";
16
+ import { config, resolveModel, modelFamily, modelSnapshotKind, envOn, envGateOn, preflightEngineBinary } from "./driver.config.mjs";
17
+ import { probeCliVersion } from "./engine/cli-version.mjs";
17
18
  import { stageLog, runLog, note, outputMeta } from "./log.mjs";
18
19
  // — the closed disposition set has ONE author; this file dictates it and must not retype it.
19
20
  import { DISPOSITIONS, POSITION_REQUIRED_DISPOSITIONS } from "./findings-model.mjs";
@@ -1147,6 +1148,20 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1147
1148
  // may repoint, and recorded beside a dated one they read identically. null when there is nothing to
1148
1149
  // judge, never collapsed into "alias".
1149
1150
  const modelSnapshot = modelSnapshotKind(modelActual);
1151
+ // WHICH BUILD OF THE TOOL SERVED THIS TURN. `modelSnapshot` above answers "did the model move";
1152
+ // this answers "did the tool move", and the two have one symptom — a run whose judgment differs from
1153
+ // last week's. Captured at dispatch rather than read back afterwards, because the binary a reader
1154
+ // could probe later is not necessarily the one that ran.
1155
+ //
1156
+ // Written as an object with its own `probe` field, so an unreadable version is a RECORDED state
1157
+ // rather than a missing one. Omitting it on failure would collapse "asked and could not say" into
1158
+ // "written before anybody asked", which is the distinction the field exists for. One spawn per
1159
+ // binary per process; a probe never throws, because taking down a dispatch to record a version
1160
+ // would be a worse defect than the gap it closes.
1161
+ const cli = (() => {
1162
+ try { return probeCliVersion(preflightEngineBinary(process.env)?.resolved ?? null); }
1163
+ catch (e) { return { version: null, probe: "unreadable", why: String(e?.message ?? e).slice(0, 160) }; }
1164
+ })();
1150
1165
  if (modelActual) lastModelWire = modelActual; // — never overwritten with null
1151
1166
  // The comparison is by FAMILY (driver.config modelFamily), because `--model haiku` legitimately comes
1152
1167
  // back as `claude-haiku-4-5-20251001`. THREE-VALUED: null when either side names no family this
@@ -1576,6 +1591,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1576
1591
  // Written even on the rows where they are null, so "this engine cannot report" stays visibly
1577
1592
  // different from "this record predates the gauge".
1578
1593
  modelActual, modelBasis, modelSnapshot, modelMismatch,
1594
+ cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}),
1579
1595
  // W3 billing telemetry: which engine ran + the RESOLVED billing mode (subscription vs api-key). This
1580
1596
  // records INTENT (the mode the engine was configured to bill under), not independent billing evidence
1581
1597
  // — the actual proof is the provider console (claude's stream also reports apiKeySource; codex does
@@ -1711,6 +1727,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1711
1727
  //: the spine carries the same pair as the per-stage log, or the two disagree about what
1712
1728
  // ran. `model` stays the requested resolution (its existing readers); `modelActual` is the wire.
1713
1729
  model: modelRequested, modelActual, modelBasis, modelSnapshot, modelMismatch,
1730
+ cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}),
1714
1731
  wrote, warm: warm || undefined, warmEscalated: attempt === warmEscalatedAt || undefined,
1715
1732
  rescued: rescued ?? undefined, killed: killed || undefined,
1716
1733
  quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
@@ -2,11 +2,11 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.2.2",
5
+ "version": "0.2.3",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
9
- "node": ">=22.19.0"
9
+ "node": ">=22.13.0"
10
10
  },
11
11
  "scripts": {
12
12
  "test": "node ../scripts/test-run.mjs node ../scripts/test-fast.mjs test/*.test.mjs",
@@ -14,6 +14,6 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "exceljs": "^4.4.0",
17
- "undici": "^8.10.1"
17
+ "undici": "^7.29.1"
18
18
  }
19
19
  }
@@ -226,8 +226,8 @@ const corpusHasKind = (set, kind) => {
226
226
  /**
227
227
  * The form of a party's name to match ENTRIES against.
228
228
  *
229
- * Register owners carry legal forms ("Aurora Therapeutics SA"); web candidate titles carry whatever the
230
- * web uses ("Aurora Therapeutics"). That asymmetry is systematic — one corpus is the register, the other
229
+ * Register owners carry legal forms ("Foxglade Therapeutics SA"); web candidate titles carry whatever the
230
+ * web uses ("Foxglade Therapeutics"). That asymmetry is systematic — one corpus is the register, the other
231
231
  * is search results — so requiring the full string admits SOME entries about a party and excludes others
232
232
  * purely by name form. A half-scoped corpus is the failure this scoping was built to avoid, inverted: it
233
233
  * still reports a contradiction, now against a source the run plainly holds.
package/driver/phase0.mjs CHANGED
@@ -87,7 +87,7 @@ export function todayISO(now = new Date()) {
87
87
  }
88
88
 
89
89
  // The resolved customer = the profile key (profiles.mjs resolveProfile, forwarder-domain only). This was
90
- // a aurora-interactive.example-vs-generic HARDCODE (D4.1); the engine is already profile-driven, so resolveProfile is
90
+ // a customer-vs-generic HARDCODE (D4.1); the engine is already profile-driven, so resolveProfile is
91
91
  // the single source of truth. ctx.customer is telemetry-only (the run-start log line); per-customer
92
92
  // DELIVERY reads the FROZEN sidecar ctx.profile.profileKey (which wins on resume) — never read
93
93
  // ctx.customer for a delivery decision, or a profiles/ edit between run and resume would diverge.
@@ -888,7 +888,7 @@ function deriveGridSpec(ctx) {
888
888
  // Deterministic grid contract (robust fix, 2026-06-14): write the EXACT terms × platforms the grid
889
889
  // will run as a code-owned spec. The perplexity plugin reads this, runs only these cells, and writes
890
890
  // common-law-grid.json ITSELF from the API response — the large grid JSON never round-trips through
891
- // the model's bounded turn-output (no truncation: Zephyr/marble-causeway) and the model never re-types
891
+ // the model's bounded turn-output (no truncation: measured on a dense beverages run) and the model never re-types
892
892
  // the keys or cells (no drops/mis-keys: NOVA PULSE/marble-anvil). The receipts gate joins against THIS
893
893
  // same file, so it can never demand a key the grid did not run. No profile (no dictated platforms) ⇒
894
894
  // legacy model-authored path (back-compat; the gate falls back to the prose manifest).
@@ -918,7 +918,7 @@ function deriveGridSpec(ctx) {
918
918
  const gridSpecPath = P.gridSpec;
919
919
  // #5 — required channels: a NAMED profile's curated platforms are authoritative. The GENERIC fallback
920
920
  // derives the channels from the MATTER FRAME's industry/goods reasoning (its "Search channels:" line) so
921
- // a regulated/B2B matter (petcary vet-pharma) searches its real channels (EMA/FDA registers) instead of
921
+ // a regulated/B2B matter (veterinary pharma, say) searches its real channels (EMA/FDA registers) instead of
922
922
  // being forced onto — and invalidated by — consumer storefronts. No static class→channel table: the
923
923
  // frame names them the way a lawyer reasons about the vertical, so it generalises to ANY industry. The
924
924
  // deterministic grid runs grid-spec.platforms and the receipts gate joins the SAME file, so this is the
@@ -1465,7 +1465,7 @@ export function freezeProfile(p, project = null) {
1465
1465
  demoData: p.demoData === true,
1466
1466
  // BUGFIX 2026-06-19: per-customer reasoning-skill SELECTION must be frozen too. It was missing here, so
1467
1467
  // frameworkFor()/workedExamplesFor() (stages.mjs) read the frozen ctx.profile, found no path, and silently
1468
- // fell back to the firm-neutral DEFAULT for EVERY run — aurora.json/zephyr.json's configured frameworks
1468
+ // fell back to the firm-neutral DEFAULT for EVERY run — the profiles' configured frameworks
1469
1469
  // were never applied. RAW value (NOT `?? ""`): absent ⇒ undefined ⇒ JSON drops it ⇒ frameworkFor falls back
1470
1470
  // to DEFAULT; an empty string would NOT trigger the `?? DEFAULT` and would break the read.
1471
1471
  frameworkPath: p.frameworkPath,
@@ -1490,7 +1490,7 @@ export function freezeProfile(p, project = null) {
1490
1490
  };
1491
1491
  // spec 62 — when the run is under a PROJECT, record which project rated it and the per-field origin map,
1492
1492
  // added AFTER the 14-field base so a NO-PROJECT freeze stays byte-identical to a pre-62 sidecar (the
1493
- // aurora regression anchor and every existing run's profileSha are preserved; profileShaOf sorts keys,
1493
+ // regression anchor and every existing run's profileSha are preserved; profileShaOf sorts keys,
1494
1494
  // so these hash in only when present, making "which project rated this run" verifiable, never asserted).
1495
1495
  if (project?.projectKey) {
1496
1496
  frozen.projectKey = project.projectKey;
@@ -14633,7 +14633,7 @@ async function pipelineInner(job, opts = {}) {
14633
14633
  // ratings (joinFindingToBlock), never the summary's own words.
14634
14634
  try { emailVerdictOpts.findings = parseFindingsJsonLenient(readFileSync(P.findings, "utf8"))?.findings ?? undefined; } catch { /* no findings — table falls back to the summary words */ }
14635
14635
  // doc 50 — the run's band ladder: the email table colours + sorts by the framework in force's own
14636
- // vocabulary (zephyr "Medium", house "Moderate", aurora's five bands) instead of the fixed table.
14636
+ // vocabulary (one framework's "Medium", the house's "Moderate", another's five bands) instead of the fixed table.
14637
14637
  if (ctx.framework) emailVerdictOpts.bands = ctx.framework.bands.map((b) => ({ label: b.label, tone: b.tone }));
14638
14638
  // ONE report (spec 2026-07-30 §6): the cover note is a cover note — verdict, one-line summary, link.
14639
14639
  // A10 (addendum, 2026-07-30) NARROWS the old "machine-QC never on the email" rule rather than
@@ -0,0 +1,120 @@
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
+ /** Everything the run is ABOUT — the mark, its variants, the owners named. Never flagged. */
53
+ const ownTerms = (about = {}) => {
54
+ const out = [];
55
+ for (const v of [about.mark, ...(about.marks ?? []), ...(about.owners ?? []), ...(about.terms ?? [])]) {
56
+ const s = String(v ?? "").trim();
57
+ if (s) out.push(s.toLowerCase());
58
+ }
59
+ return out;
60
+ };
61
+
62
+ /** Sentences, split on terminators that end one. Crude on purpose — this counts words, not grammar. */
63
+ export const sentencesOf = (text) =>
64
+ String(text ?? "").split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
65
+
66
+ export const wordsIn = (sentence) => String(sentence ?? "").trim().split(/\s+/).filter(Boolean).length;
67
+
68
+ /**
69
+ * What is wrong with one default-visible line, as rewrite advice. `[]` means nothing to say.
70
+ *
71
+ * `about` carries the run's own marks and owners so they are never reported — see the header. A term
72
+ * that IS the thing being cleared is not the lawyer's vocabulary, it is the subject.
73
+ */
74
+ export function plainRegisterFlags(text, about = {}) {
75
+ const raw = String(text ?? "");
76
+ if (!raw.trim()) return [];
77
+ const mine = ownTerms(about);
78
+ // Blank the run's own terms before reading, rather than filtering hits afterwards: a mark can contain
79
+ // one of these words ("PREVAIL"), and a hit inside it is not a hit at all.
80
+ let scan = raw;
81
+ for (const t of mine) {
82
+ if (!t) continue;
83
+ scan = scan.replace(new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), " ");
84
+ }
85
+
86
+ const flags = [];
87
+ for (const [term, plain] of PLAIN_FORMS) {
88
+ const re = new RegExp(`\\b${term.replace(/[-]/g, "[- ]").replace(/\s+/g, "\\s+")}\\b`, "i");
89
+ if (!re.test(scan)) continue;
90
+ flags.push({
91
+ kind: "vocabulary",
92
+ term,
93
+ say: plain
94
+ ? `"${term}" is the lawyer's word — the reader needs "${plain}". Rewrite the sentence, do not swap the word.`
95
+ : `"${term}" is an engine word and has no place on a page a client reads. Rewrite the sentence.`,
96
+ });
97
+ }
98
+
99
+ for (const s of sentencesOf(raw)) {
100
+ const n = wordsIn(s);
101
+ if (n > SENTENCE_WORD_LIMIT) {
102
+ flags.push({
103
+ kind: "length",
104
+ words: n,
105
+ say: `${n} words in one sentence, and a visible line takes ${SENTENCE_WORD_LIMIT}. Split it — one idea per sentence, `
106
+ + "the conclusion first. Do not shorten it by dropping the reason.",
107
+ });
108
+ }
109
+ }
110
+ return flags;
111
+ }
112
+
113
+ /**
114
+ * The fields a reader meets before opening anything. Named here rather than at each call site so the
115
+ * two products answer to one list — the knockout and the clearance drifted apart once already.
116
+ */
117
+ export const DEFAULT_VISIBLE_FIELDS = Object.freeze({
118
+ knockout: ["summary", "batchOpener", "basis", "net", "factors", "counterFactors", "mitigation", "standardCaveats", "reviewerNotes"],
119
+ clearance: ["summary", "oneLiner", "registrability", "thirdPartyRights", "ownRights", "freedomToOperate", "reviewerNotes", "coverage"],
120
+ });
@@ -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).
@@ -239,6 +239,41 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
239
239
  };
240
240
  }
241
241
 
242
+ /**
243
+ * Where the staff-domain rule was written, so a reader can go and undo it.
244
+ *
245
+ * ── WHY A PAGE THAT NAMES A RULE MUST ALSO NAME ITS ADDRESS ─────────────────────────────────────────
246
+ *
247
+ * The People & access screen renders the rule — "Anyone at <domain> — a rule, not a person" — and said
248
+ * nothing about where it came from. A reader who does not recognise the domain therefore learns that
249
+ * strangers may hold an administrator's view of their instance and has no next step at all: the value
250
+ * is in an environment variable, in one of two files depending on how the instance is run, and neither
251
+ * is named anywhere on the screen. The one outside reader who met this reported it as a back door,
252
+ * twice, which is the correct thing to do with an access rule you cannot trace.
253
+ *
254
+ * PURE, and it answers "could not tell" as itself. `envLoad` is `shared/env-local.mjs`'s own report of
255
+ * what this process read, so the answer describes the process actually serving the page rather than
256
+ * being composed from a path that some other process would have read — the distinction that module
257
+ * exists for. A service started by systemd took its configuration from an EnvironmentFile; a child of
258
+ * `clearotron start` was handed an explicit environment and read no file at all; a hand-run CLI read
259
+ * the CLI's file. Each gets its own sentence, because the remedy is a different file in each.
260
+ */
261
+ export function staffRuleSource({ name = "PORTAL_STAFF_DOMAINS", value = "", envLoad = null,
262
+ unitEnvFile = null, cliEnvFile = null } = {}) {
263
+ if (!String(value ?? "").trim()) return null;
264
+ const reason = envLoad?.reason ?? null;
265
+ const applied = Array.isArray(envLoad?.applied) ? envLoad.applied : [];
266
+ if (reason === "read" && applied.includes(name))
267
+ return { name, where: `read from ${envLoad.path}` };
268
+ if (reason === "service-managed")
269
+ return { name, where: unitEnvFile ? `set in this service's environment file, ${unitEnvFile}` : "set in this service's environment" };
270
+ if (reason === "opted-out")
271
+ return { name, where: cliEnvFile
272
+ ? `handed to this service by the command that started it, which takes it from ${cliEnvFile} or derives it from the sign-in address`
273
+ : "handed to this service by the command that started it" };
274
+ return { name, where: cliEnvFile ? `set in this service's environment (the file it would otherwise read is ${cliEnvFile})` : "set in this service's environment" };
275
+ }
276
+
242
277
  /**
243
278
  * The enrolment view: who is granted what, and where an enrolment is half done.
244
279
  *
@@ -246,7 +281,7 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
246
281
  * they are reported separately — a staff member absent from the grants file is normal, not a fault,
247
282
  * and listing them as "unenrolled" would bury the real problems.
248
283
  */
249
- export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null }) {
284
+ export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null, staffRule = null }) {
250
285
  const tenants = grants?.tenants ?? {};
251
286
  const known = new Set(knownAccounts);
252
287
  const people = [];
@@ -277,6 +312,10 @@ export function accessView({ grants, staffDomains = [], knownAccounts = [], gran
277
312
  return {
278
313
  people: people.sort((a, b) => a.email.localeCompare(b.email)),
279
314
  staffDomains: [...staffDomains],
315
+ // An ADDITIONAL field rather than a reshape of `staffDomains`: that array is parsed by the browser
316
+ // contract and read by three screens' worth of arms, and a rule nobody can trace is a copy problem,
317
+ // not a data-shape problem. Null when there is no rule, or when the source could not be told.
318
+ staffRule,
280
319
  // Accounts named in grants that no profile matches — the other typo direction.
281
320
  unknownAccounts: [...unknownAccounts].sort(),
282
321
  // 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