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
@@ -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
@@ -1338,7 +1343,7 @@ export function makePortalService({
1338
1343
  assertPrincipal(principal, { door: true }); // door check only — a multi-account client enters and gets the picker list
1339
1344
  // `accountNames` — the DISPLAY name of each account this identity holds, and nothing else.
1340
1345
  //
1341
- // Every profile carries a name ("Vantor Labs", "Aurora Interactive"); the account KEY is a
1346
+ // Every profile carries a name ("Vantor Labs", "Foxglade Interactive"); the account KEY is a
1342
1347
  // slug ("vantor"). Staff read names because the account picker fetches the staff-only
1343
1348
  // roster; a client had no name source at all, so the identical screen printed the slug at them.
1344
1349
  // The same brand owner therefore read two different ways depending on who signed in, which is
@@ -1386,6 +1391,12 @@ export function makePortalService({
1386
1391
  // must leave the button alone rather than infer demo from an absent file.
1387
1392
  return { status: 200, json: { role: principal.role, email: principal.email, accounts: principal.accounts, accountNames,
1388
1393
  concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode: flagView(poolRoot).engineMode,
1394
+ // HOW THIS INSTALL ARRIVED, so a screen can name the setup command the reader can actually
1395
+ // type. `npm run setup` and `npx clearotron install` are the same wizard and each one is
1396
+ // unrunnable on the other route; the no-engine notice named one of them and was wrong for
1397
+ // half its readers. A WORD, never a command line and never a prefix: `invocationForm` can
1398
+ // answer with this machine's absolute path, and this value is rendered in a browser.
1399
+ setupRoute: installRoute(),
1389
1400
  // — a button that always fails must not render as available. The reason is
1390
1401
  // operator-shaped and staff-only; a client reads the generic sentence the button carries.
1391
1402
  controls: { stop: { available: stopControl.available !== false,
@@ -2822,7 +2833,7 @@ export function makePortalService({
2822
2833
  const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
2823
2834
  if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
2824
2835
  } 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 }) };
2836
+ return { status: 200, json: accessView({ grants: grantsNow(), staffDomains, knownAccounts, grantsFile, staffRule }) };
2826
2837
  }
2827
2838
  // /portal/admin/observed — who has actually USED this instance lately, from the audit log.
2828
2839
  //
@@ -3506,7 +3517,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
3506
3517
  // local-auth block, so calling it from here threw ReferenceError and this route answered 500 —
3507
3518
  // worse than the dead link it replaced, because a dead link looks like a dead link and a 500
3508
3519
  // 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.
3520
+ // (`'redirect' is not defined`), and by driving the route rather than reading it (review, 2026-08).
3510
3521
  // Hoisting that helper would move it away from the comment explaining its own no-`?next=` rule,
3511
3522
  // so the four lines live here instead.
3512
3523
  if (!localAuth && url.pathname === "/portal/sign-out") {
@@ -3915,6 +3926,14 @@ const PORT = PORT_CHOICE.port;
3915
3926
  const grants = () => loadGrants({});
3916
3927
  const staffDomains = (process.env.PORTAL_STAFF_DOMAINS || "").split(",").map((s) => s.trim()).filter(Boolean);
3917
3928
  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); }
3929
+ // WHERE THAT RULE IS WRITTEN, resolved once at boot and handed to the service. `loaded` is this
3930
+ // process's own report of which file configured it — never a path composed here, which would answer
3931
+ // for a process that read nothing (see `envFileRead`'s note in shared/env-local.mjs).
3932
+ const { loaded, unitEnvPath, envLocalPath } = await import("../shared/env-local.mjs");
3933
+ const staffRule = staffRuleSource({
3934
+ value: process.env.PORTAL_STAFF_DOMAINS, envLoad: loaded,
3935
+ unitEnvFile: unitEnvPath(), cliEnvFile: envLocalPath(),
3936
+ });
3918
3937
 
3919
3938
  const { config } = await import("./driver.config.mjs");
3920
3939
  const { appendFileSync: append } = await import("node:fs");
@@ -4188,7 +4207,7 @@ const PORT = PORT_CHOICE.port;
4188
4207
  // The result was not a failure. It was worse. With the overlay unset, resolveSkillPath falls back
4189
4208
  // to the PRODUCT REPO's own driver/skills — where the customer frameworks either do not exist
4190
4209
  // (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
4210
+ // DEMO fixtures the repository carries for the suite (a framework titled " risk framework
4192
4211
  // (synthetic demo)", source_deck "content invented"). The second case renders with a title, a band
4193
4212
  // ladder and band meanings, and is indistinguishable on screen from the client's real framework.
4194
4213
  // A lawyer read invented risk definitions as their client's own for as long as this was live.
@@ -4356,7 +4375,7 @@ const PORT = PORT_CHOICE.port;
4356
4375
  const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
4357
4376
  // Re-read per request (a getter that rescans), so a workspace created after boot is counted.
4358
4377
  queueDirs: () => config.queueDirs,
4359
- secret, staffDomains, grants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4378
+ secret, staffDomains, staffRule, grants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4360
4379
  // — the ONLY place the environment is read for this. `bin/start.mjs` is the
4361
4380
  // only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
4362
4381
  // 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"
@@ -31,6 +31,7 @@ import { writeUpViolations, writeUpMessage } from "./narrative-write-ups.mjs";
31
31
  import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } from "./registry-fidelity.mjs";
32
32
  import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
33
33
  import { normalizeBand } from "./framework.mjs";
34
+ import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
34
35
 
35
36
  // V4-3: diacritics FOLD (NFD strip) instead of being deleted — "Televisión" must normalize to
36
37
  // "television" (deletion made it "televisin", so a diacritic mention never matched its introduction).
@@ -60,7 +61,7 @@ const REF_ALLOWLIST = new Set([
60
61
  // marketplace-platform vocabulary (the common-law sweep's standing surfaces, not entities to introduce).
61
62
  // LOAD-BEARING — do NOT delete to "de-gaming" the lint: the per-run profile-platform union (runLint
62
63
  // `extraVocab`) is DOMAIN-derived, so a single-label domain yields the bare brand ("walmart.com" →
63
- // "walmart", so Zephyr Beverages' Walmart/Target/GNC are already covered with no static) but a MULTI-part domain
64
+ // "walmart", so a drinks account's Walmart/Target/GNC are already covered with no static) but a MULTI-part domain
64
65
  // does NOT ("store.steampowered.com" → "store steampowered", never "steam"), and "xbox"/"playstation"/
65
66
  // "nintendo" appear in NO platform domain at all. Removing these would fire false orphan flags on every
66
67
  // gaming report. The structural de-gaming fix is per-customer marketplace ALIASES in the bundle data
@@ -2352,6 +2353,122 @@ const knockoutSurfaces = (findings) => {
2352
2353
  return { report: report.filter(Boolean).join("\n\n"), working: working.filter(Boolean).join("\n\n") };
2353
2354
  };
2354
2355
 
2356
+ // ── PLAIN LANGUAGE ON WHAT A READER SEES FIRST (tracker issue 333) ─────────────────────────────────
2357
+ //
2358
+ // The report goes to a lawyer who layers advice on top, and that lawyer's client reads the same page.
2359
+ // The band, the summary, the basis line and the one-liners are the whole product for the second reader,
2360
+ // and they were the hardest lines on it: "a prior owner is likely to prevail on the marks-and-goods
2361
+ // comparison", "subsisting European rights", "the confusion comparison meets on every limb".
2362
+ //
2363
+ // THE PARTITION IS DEFAULT-VISIBLE vs FOLDED, WHICH IS NOT THE report/working SPLIT ABOVE. Since the
2364
+ // page folds each card's argument (tracker issue 331 A.3), a finding's `basis` is now behind a click,
2365
+ // and 333 rule 2 allows the lawyer's vocabulary there where a plain word would lose precision. The same
2366
+ // is true of the long `assessment`. So this check reads exactly the fields the renderer DRAWS without
2367
+ // a click, and reads nothing else — a check whose population is "the report" would flag the very
2368
+ // paragraphs the design deliberately moved out of the reader's way.
2369
+ //
2370
+ // THE WORD LIST IS A TRIPWIRE, NOT THE RULE. 333 rejects a banned-word list as the mechanism, and it is
2371
+ // right to: the rule is "does the reader already own this word?", which no list bounds. The list is what
2372
+ // a machine can check, seeded from the terms actually found on the two delivered pages. The teaching is
2373
+ // in the skill, where a writer reads it.
2374
+ //
2375
+ // FLAG-ONLY, AND NEVER PROJECTED. 333: "A hit is a rewrite of that line, never a disclosure and never a
2376
+ // run failure." These carry surface "findings", which runKnockoutLint's caller does not project onto
2377
+ // the cover note or the workbook — so a flag reaches whoever is fixing the run and nobody else.
2378
+ const LAWYER_VOCAB = [
2379
+ ["proprietor", /\bproprietors?\b/i],
2380
+ ["subsisting", /\bsubsisting\b/i],
2381
+ ["senior right", /\bsenior (?:right|mark|position)/i],
2382
+ ["specification", /\bspecifications?\b/i],
2383
+ ["formative", /\bformatives?\b/i],
2384
+ ["prevail", /\bprevails?\b|\bprevailing\b/i],
2385
+ ["citable", /\bcitable\b/i],
2386
+ ["limb", /\bon every limb\b|\ball limbs\b/i],
2387
+ ["non-use attack", /\bnon-?use attack\b/i],
2388
+ ["belt-and-braces", /\bbelt-?and-?braces\b/i],
2389
+ ["dispatch", /\bdispatch(?:ed|es)?\b/i],
2390
+ ["lane", /\blanes?\b/i],
2391
+ ["on the record as it stands", /\bon the record as it stands\b/i],
2392
+ ["the marks-and-goods comparison", /\bmarks-and-goods comparison\b/i],
2393
+ ["chunk", /\bchunks?\b/i],
2394
+ ];
2395
+
2396
+ /** The longest a default-visible sentence may run before it stops being one idea (333 rule 1). */
2397
+ const PLAIN_SENTENCE_WORDS = 25;
2398
+
2399
+ /**
2400
+ * The fields a reader of the knockout meets before opening anything, named one by one rather than
2401
+ * derived, so adding a field to the page is a deliberate addition here too.
2402
+ */
2403
+ function knockoutVisibleProse(findings) {
2404
+ const out = [];
2405
+ const add = (where, v) => { const t = String(v ?? "").trim(); if (t) out.push({ where, text: t }); };
2406
+ add("the batch summary", findings?.batch?.executiveSummary);
2407
+ for (const c of findings?.batch?.standardCaveats ?? []) add("a standing caveat", c);
2408
+ for (const m of findings?.marks ?? []) {
2409
+ const n = String(m?.name ?? "a mark");
2410
+ add(`${n}'s basis line`, m?.basis);
2411
+ for (const f of m?.factors ?? []) add(`${n}'s "why this band" list`, f);
2412
+ for (const f of m?.counterFactors ?? []) add(`${n}'s "why not the next band" list`, f);
2413
+ add(`${n}'s mitigation line`, m?.mitigation);
2414
+ for (const p of m?.purpleNotes ?? []) add(`${n}'s note to the reviewing lawyer`, p?.text ?? p);
2415
+ for (const r of m?.registerReads ?? []) add(`${n}'s read of a filing`, r?.read);
2416
+ // The finding's ONE sentence. Its `basis` is folded and is deliberately not read here.
2417
+ for (const f of m?.findings ?? []) add(`${n} conflict ${f?.ordinal ?? ""}`.trim(), f?.net);
2418
+ }
2419
+ return out;
2420
+ }
2421
+
2422
+ /**
2423
+ * plainLanguageChecks — two flags over the default-visible prose: the lawyer's vocabulary, and a
2424
+ * sentence carrying more than one idea. Each names the field, so a hit is a line somebody can rewrite
2425
+ * rather than a score.
2426
+ */
2427
+ export function plainLanguageChecks({ findings, surface = "findings" } = {}) {
2428
+ const fields = knockoutVisibleProse(findings);
2429
+ const vocab = [];
2430
+ const longSentences = [];
2431
+ for (const { where, text } of fields) {
2432
+ for (const [name, re] of LAWYER_VOCAB) if (re.test(text)) vocab.push(`${where}: "${name}"`);
2433
+ for (const sentence of text.split(/(?<=[.!?])\s+|\n+/)) {
2434
+ const n = sentence.trim().split(/\s+/).filter(Boolean).length;
2435
+ if (n > PLAIN_SENTENCE_WORDS) longSentences.push(`${where}: ${n} words`);
2436
+ }
2437
+ }
2438
+ // ── A NOTE THAT WILL PRINT IN THE PLACE ITS WRITER DID NOT MEAN ────────────────────────────────────
2439
+ //
2440
+ // The page files a reviewer's note by what it TALKS ABOUT: name the request and it prints at the top,
2441
+ // above the conflicts; otherwise it prints under that name's cards. That is what lets an archived run
2442
+ // put its mis-scoping flag where the owner asked for it without a new field.
2443
+ //
2444
+ // THE COST OF THAT CHOICE IS EXACTLY THIS FLAG. A note plainly about the asking — the client's stated
2445
+ // industry, its own prior use, whether the goods are the right ones — that never says "the request"
2446
+ // sorts as a note about the NAME and prints at the bottom, which is the defect the move exists to fix.
2447
+ // Nothing about the page would look wrong; the line would simply be in the last place read.
2448
+ //
2449
+ // So the reviewer says so, to the one person who can fix it, in the one place a fix belongs: the note.
2450
+ // The page never guesses. Adding a clause naming the request is the whole remedy.
2451
+ const misfiled = [];
2452
+ for (const m of findings?.marks ?? []) {
2453
+ for (const raw of m?.purpleNotes ?? []) {
2454
+ const v = knockoutNoteView(raw);
2455
+ if (!v.text || v.about) continue; // the rater said which; nothing is inferred
2456
+ if (REQUEST_NOTE_WORDS.test(v.text)) continue; // it names the request and will print at the top
2457
+ if (!REQUEST_SUBJECT_WORDS.test(v.text)) continue;
2458
+ misfiled.push(`${m?.name ?? "a mark"}: "${v.text.trim().replace(/\s+/g, " ").slice(0, 70)}…"`);
2459
+ }
2460
+ }
2461
+ const say = (hits) => `${hits.slice(0, 5).join("; ")}${hits.length > 5 ? `; +${hits.length - 5} more` : ""}`;
2462
+ return [
2463
+ check("reviewer-note-subject", "voice", surface, misfiled.length === 0,
2464
+ misfiled.length ? `a note about what was asked that never names the request — it will print under this name's conflicts rather than at the top of the page, where a question about the request belongs. Name the request in the note: ${say(misfiled)}` : ""),
2465
+ check("plain-language-vocabulary", "voice", surface, vocab.length === 0,
2466
+ vocab.length ? `the lawyer's vocabulary on lines a reader meets before opening anything — rewrite the line in the words the reader already owns (the skill carries the swaps): ${say(vocab)}` : ""),
2467
+ check("plain-language-sentence-length", "voice", surface, longSentences.length === 0,
2468
+ longSentences.length ? `a default-visible sentence carrying more than one idea (over ${PLAIN_SENTENCE_WORDS} words) — split it, conclusion first: ${say(longSentences)}` : ""),
2469
+ ];
2470
+ }
2471
+
2355
2472
  /**
2356
2473
  * KNOCKOUT_ABSENT_BY_DESIGN — the clearance checks this lane does NOT run, each with the reason its
2357
2474
  * input does not exist here. This is the third state (/'s rule, in the lint's own vocabulary):
@@ -2423,6 +2540,9 @@ export function runKnockoutLint({ findings }) {
2423
2540
  // (publish/report-registry.mjs re-renders archived findings without re-running the merged validator)
2424
2541
  // this scan is the only permission-prose coverage there is.
2425
2542
  if (working.trim()) checks.push(...permissionProseChecks({ text: working, surface: "findings", idSuffix: ":knockout-working", structural: true, cards: false }));
2543
+ // tracker issue 333 — the plain-language reviewer, over the fields a reader meets before opening a
2544
+ // fold. Internal by surface, so a hit reaches whoever is fixing the run and never a delivery surface.
2545
+ checks.push(...plainLanguageChecks({ findings }));
2426
2546
  const failures = checks.filter((c) => !c.pass);
2427
2547
  return { checks, failures, notApplicable: KNOCKOUT_ABSENT_BY_DESIGN };
2428
2548
  }
@@ -35,7 +35,7 @@ ul.roster{list-style:none;margin:0;padding:0}
35
35
  ul.roster li{padding:8px 10px;border-radius:8px;cursor:pointer;font-size:14px;border:1px solid transparent}
36
36
  ul.roster li:hover{background:var(--bg)}
37
37
  ul.roster li.sel{background:var(--bg);border-color:var(--line);font-weight:700}
38
- /* the sub-line truncates (2-line clamp) — a long industry string (aurora's is ~110 chars) must never
38
+ /* the sub-line truncates (2-line clamp) — a long industry string (one profile's is ~110 chars) must never
39
39
  stretch the 240px roster column into an overflowing wall of text */
40
40
  ul.roster li .ind{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:11.5px;color:var(--muted);font-weight:400}
41
41
  ul.roster li .ind code.slug{font-size:10.5px;letter-spacing:.02em}
@@ -136,7 +136,15 @@ const csv = (s) => String(s||"").split(",").map(x=>x.trim()).filter(Boolean);
136
136
  const fwName = (path) => { const m = String(path||"").match(/risk-framework-([a-z0-9-]+)\.md/i); return m ? (m[1].charAt(0).toUpperCase() + m[1].slice(1) + " framework") : "Generic default framework"; };
137
137
  // doc 50 — the band ladder preview, from the manifest the service serves (tones map to the page palette).
138
138
  const TONE_BG = {severe:"#b3261e", high:"#c75b13", medium:"#9a8400", low:"#4e7a31", minimal:"#2f55a4"};
139
- const bandLadder = (m) => m && m.bands ? `<div style="margin:6px 0 2px">${m.bands.map(b=>`<span style="display:inline-block;margin:0 6px 4px 0;padding:2px 9px;border-radius:999px;background:${TONE_BG[b.tone]||"#666"};color:#fff;font-size:11px;font-weight:700">${esc(b.label)}</span>`).join("")}</div>` : "";
139
+ // LABELLED, for the same reason as the portal's copy of this row. Four coloured pills with nothing
140
+ // saying what they are, sitting under a heading that reads "Risk framework in force", were read by an
141
+ // outside user as four frameworks: "how do I have FOUR risk frameworks live at the same time?" He had
142
+ // one. The label names the framework's ownership of the scale — "its ratings" — because the sentence
143
+ // immediately above names the framework and the only ambiguity is whether these belong to it.
144
+ //
145
+ // The two editors say the same words on purpose. This one is the staff page and the portal has its own;
146
+ // a reader who sees both should not have to work out whether they are describing the same thing.
147
+ const bandLadder = (m) => m && m.bands ? `<div style="font-size:12px;color:var(--slate);margin:10px 0 0">Its ratings, strongest concern first:</div><div style="margin:6px 0 2px">${m.bands.map(b=>`<span style="display:inline-block;margin:0 6px 4px 0;padding:2px 9px;border-radius:999px;background:${TONE_BG[b.tone]||"#666"};color:#fff;font-size:11px;font-weight:700">${esc(b.label)}</span>`).join("")}</div>` : "";
140
148
  // doc 50 — "What the bands mean": deck-native prose the service lifts from the framework .md at view time
141
149
  // (display-only; omitted whenever extraction missed). The decks are Privileged & Confidential, so the prose
142
150
  // is wrapped in data-anon="mark" — the demo privacy blur must cover it.
@@ -75,7 +75,7 @@ function manifestFor(fwPath) {
75
75
  // shipped decks, so a deck edit that breaks extraction fails CI instead of silently blanking the box.
76
76
  const stripMd = (s) => String(s ?? "").replace(/\*+/g, "").trim();
77
77
 
78
- // matrix-shaped decks (aurora): the deck's "Band meanings" table is the only table whose FIRST cell is
78
+ // matrix-shaped decks: the deck's "Band meanings" table is the only table whose FIRST cell is
79
79
  // exactly the band label (the matrix table suffixes its labels with the deck's internal indices, e.g.
80
80
  // "**Very High** *(5)*") — take that row's cells as { band, meaning, response }.
81
81
  function matrixBandMeanings(deck, manifest) {
@@ -97,7 +97,7 @@ function matrixBandMeanings(deck, manifest) {
97
97
  return out;
98
98
  }
99
99
 
100
- // bands-shaped decks (house, zephyr): each band lives under its own heading ("## VERY HIGH RISK") — find
100
+ // bands-shaped decks (the house default among them): each band lives under its own heading ("## VERY HIGH RISK") — find
101
101
  // the section whose heading STARTS WITH the band label ("High" must never steal "VERY HIGH RISK") and lift
102
102
  // EVERY rung the band states, in the deck's own order.
103
103
  //
@@ -172,8 +172,8 @@ function bandMeaningsFor(fwPath, manifest) {
172
172
 
173
173
  // CODE-OWNED fields — the framework SELECTION. The editor page has no form fields for these, so a save
174
174
  // assembled from the form omits them, and defaultWriteProfile rewrites the whole file: the 2026-07-04/05 UI
175
- // saves (587324ab, cea0ca2f, c59030a1) silently stripped frameworkPath/workedExamplesPath from zephyr and
176
- // aurora, flipping both customers to the house-default framework. The on-disk value ALWAYS wins here — a
175
+ // saves (587324ab, cea0ca2f, c59030a1) silently stripped frameworkPath/workedExamplesPath from two
176
+ // profiles, flipping both customers to the house-default framework. The on-disk value ALWAYS wins here — a
177
177
  // client body can neither drop, change, nor introduce a framework selection (git + review gated, per the
178
178
  // header rules). Applied to validate AND save so the dry run judges exactly what a save would write.
179
179
  // Search-depth spine: allowedRecipes/jxPolicy/runCaps have NO form fields on the editor page — without
@@ -21,8 +21,8 @@ With no key, `resolveProfile(job)` matches `job.forwarderDomain` against each
21
21
  profile's `matchDomains[]` (exact host or dot-suffix — the original `selectCustomer` semantics),
22
22
  else `generic.json`. The applicant named in the request (`job.customer`) NEVER selects a profile —
23
23
  it only activates `selfExclusionOwners[]` when it matches the profile's `name`
24
- (`applicantMatchesProfile`: word-boundary containment, so an aurora-interactive.example-forwarded
25
- search for a third-party applicant never buries genuine Aurora Interactive-owned conflicts as
24
+ (`applicantMatchesProfile`: word-boundary containment, so a search forwarded from a customer's own
25
+ domain but asking about a third-party applicant never buries that customer's own conflicts as
26
26
  "own rights").
27
27
 
28
28
  Resolution happens ONCE at run start and is frozen into the run's sidecar
@@ -52,7 +52,7 @@ consumes it; a unit test greps each symbol, so a field with no live consumer fai
52
52
  | `selfExclusionOwners[]` | structural | the customer's own/affiliate names — own-rights classification seed, applicant-gated as above |
53
53
  | `delivery` | delivery | `{ email: "summary", privileged: bool }` — the "Privileged & Confidential" header. Two further optional sub-keys are accepted: `style` (a prose string, guarded by the same anti-rule check as a context pack, dictated into the `report-overview` and `report-card` stages as presentation tone and never to `synthesis`, the rating stage) and `template` (a report-template name; `"standard"` is the only one that exists, and it is the default). **Absent ⇒ neutral default** (`{email:"summary"}` — deliberately SILENT on `privileged`, which is three-state on every surface that reads it (`confPosture`): `true` extends the marking to "Attorney Work Product", `false` is a deliberate OFF, and absent is no opinion, which gets the plain "Privileged & Confidential" every legal deliverable carries. Saying `false` in the neutral overlay read as an instruction to strip the marking, and a Generic-default clearance shipped with no line at all). `email` no longer selects anything: every run's mail is a COVER NOTE pointing at the one report. The old `"table"` value — a full review table inlined into the mail body — is **retired**; it is still accepted at load so stored profiles keep validating, and folded to `"summary"` by `normalizeDelivery`. A customer who wants their own house format gets it drafted by the assistant from the run's `report-data.json`, where a person reads it before it goes |
54
54
  | `riskAppetite` | context | a PROSE-POSTURE string (optional) that flavours **emphasis + recommended follow-up** in delivery curation — the two stages that are dictated it, `report-overview` and `report-card` — **never the Level/Composite**. A load-time anti-threshold guard rejects numeric/threshold phrasing (`>50%`, `Level C or above`, `threshold`); the "never decides" invariance itself is gated by review |
55
- | `marketplaceDensity` | structural | `"sparse"` (default) \| `"dense"` — selects the per-profile grid cell budget so a byte-heavy marketplace's verbatim stdout fits the worker output channel (sparse ⇒ 98-cell budget; dense ⇒ 16). Dense fits long retail listings (Zephyr beverages/supplements); gaming stores stay sparse |
55
+ | `marketplaceDensity` | structural | `"sparse"` (default) \| `"dense"` — selects the per-profile grid cell budget so a byte-heavy marketplace's verbatim stdout fits the worker output channel (sparse ⇒ 98-cell budget; dense ⇒ 16). Dense fits long retail listings (beverages and supplements); gaming stores stay sparse |
56
56
  | `frameworkPath` | rating authority | optional path to the customer's OWN risk framework (`skills/prelim-search/<file>.md`, path-escape-blocked). **The framework in force RATES the matter** — the customer's own if on file, else the Generic default `risk-framework.md`; nothing in between. Each framework is a prose deck (the client's own rubric, reasoned WITH) plus a `.manifest.json` sidecar carrying its band vocabulary (`test/framework-lint.test.mjs` guards the pair). Git + legal-team gated; the config UI shows which is in force, read-only |
57
57
  | `workedExamplesPath` | context | optional path to a per-customer worked-examples set (`skills/prelim-search/<file>.md`). The analysis DEPTH TARGET in `synthesis`, calibrated under that customer's framework; **absent ⇒ the Generic default `worked-examples.md`** |
58
58
  | `defaultProduct` | entitlement | which of the four searches runs when a request names none (`search-policy.mjs`). May be left unset, and that is not a gap — a clearance that names no product is then named by its own resolved territories |
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // One git-owned JSON file per customer under profiles/ — hand-authored, PR-reviewed, onboarded one
6
6
  // customer at a time. generic.json is the universal fallback and MUST exist;
7
- // aurora.json reproduces today's behavior exactly (the regression anchor). Every field shipped
7
+ // one test-suite profile reproduces today's behavior exactly (the regression anchor). Every field shipped
8
8
  // here has working machinery behind it — parked knobs live in profiles/README.md, not in the files.
9
9
  //
10
10
  // Resolution is FORWARDER-DOMAIN ONLY: the profile describes WHO ASKS US (the
@@ -143,10 +143,10 @@ export const SAFE_GRID_CELLS = 98;
143
143
  // A DENSE marketplace profile (long retail URLs + many listings per cell — e.g. beverages/supplements on
144
144
  // Amazon/GNC/iHerb) makes each grid cell ~5-10x heavier in OUTPUT BYTES than a sparse gaming-store cell, so
145
145
  // the cell-count budget above (calibrated on sparse stores) overflows the worker's output channel and the
146
- // verbatim stdout transcription truncates mid-JSON (Zephyr KINETIC, 2026-06-14: ~21 dense cells ≈ 20KB cut
146
+ // verbatim stdout transcription truncates mid-JSON (measured on a dense beverages profile, 2026-06-14: ~21 dense cells ≈ 20KB cut
147
147
  // the ledger unparseable). A dense profile gets a much smaller cell budget so each grid call's stdout stays
148
148
  // well under that ceiling (a 7-platform dense profile ⇒ floor 8 ⇒ batchSize 2 ⇒ ≤16 cells/call). The
149
- // gaming/Aurora budget is unchanged (default density = sparse).
149
+ // gaming budget is unchanged (default density = sparse).
150
150
  export const DENSE_GRID_CELLS = 16;
151
151
 
152
152
  // The two values a profile may hold, as a LIST rather than as a phrase repeated at each site.
@@ -238,6 +238,9 @@ export const KNOWN_PROFILE_KEYS = [
238
238
  // queue was a cost bomb the run-slot cap doesn't stop. Visible, git-tracked profile config (never a
239
239
  // hidden env var); enforced at the runner's admission chokepoint for BOTH doors (email + portal).
240
240
  "runCaps",
241
+ // AN ACCOUNT THE SUITE USES AND NO INSTALL OFFERS. `testFixture: true` keeps a profile in the tree,
242
+ // under its own name, and out of every roster this module resolves — see loadProfiles.
243
+ "testFixture",
241
244
  // THIS RECORD IS FICTION. `demoData: true` marks a profile as demo data, and a
242
245
  // real clearance refuses to start under it — at the runner's admission wall, so no door can miss it.
243
246
  //
@@ -276,6 +279,12 @@ export const CUSTOMER_ONLY_KEYS = [
276
279
  // may trigger (allowedRecipes) nor re-declare its jx deepening policy — both are the same
277
280
  // rating-authority-adjacent discipline as frameworkPath, one notch out.
278
281
  "allowedRecipes", "jxPolicy",
282
+ // WHETHER AN ACCOUNT IS OFFERED AT ALL is the account's own fact, and a project overlay must not touch
283
+ // it in either direction. Un-marking would put a test account into a customer's picker through an
284
+ // overlay nobody reviewed as identity; marking would hide a real account from its own owner. Same
285
+ // reasoning as `demoData` one notch out — that one decides whether fiction may spend money, this one
286
+ // decides whether it is visible, and neither is a project's to decide.
287
+ "testFixture",
279
288
  // Admission caps bind the ACCOUNT: a project widening its own caps would hollow the customer's.
280
289
  "runCaps",
281
290
  // Provenance binds the ACCOUNT and cannot be overlaid in either direction: a project marking a real
@@ -305,6 +314,7 @@ export const FIELD_CONSUMERS = {
305
314
  allowedRecipes: { file: "search-policy.mjs", symbol: "allowedRecipes" }, // entitlement gate on the resolved search selection
306
315
  jxPolicy: { file: "pipeline.mjs", symbol: "jxPolicy" }, // frozen into the run sidecar for the Stage-1.5 lanes (resume-safe)
307
316
  demoData: { file: "runner.mjs", symbol: "demoData" }, // the admission wall refuses a real clearance on demo data
317
+ testFixture: { file: "profiles.mjs", symbol: "loadProfiles" }, // never offered by a resolved roster; the suite asks for it by name
308
318
  runCaps: { file: "runner.mjs", symbol: "runCaps" }, // admission caps at claimAndPrep (queued + monthly, both doors)
309
319
  };
310
320
 
@@ -644,7 +654,7 @@ function readProfilesLayer(dir) {
644
654
  *
645
655
  * `dir` names the OVERLAY, not the whole store: passing it keeps the bundled set underneath, which is
646
656
  * what makes an empty store a working install. Pass `dir: null` for the bundled set alone. */
647
- export function loadProfiles({ dir, force = false } = {}) {
657
+ export function loadProfiles({ dir, force = false, includeTestFixtures, includeDemo } = {}) {
648
658
  // — LAYERING APPLIES ONLY TO THE ENV-RESOLVED STORE, and that boundary is deliberate.
649
659
  //
650
660
  // `dir` OMITTED => resolve the deployment's store, overlay over base. `dir` PASSED => that directory
@@ -659,7 +669,10 @@ export function loadProfiles({ dir, force = false } = {}) {
659
669
  const explicit = dir !== undefined;
660
670
  const overlay = explicit ? null : PROFILES_OVERLAY_DIR;
661
671
  const baseDir = explicit ? (dir || PROFILES_BASE_DIR) : PROFILES_BASE_DIR;
662
- const cacheKey = `${overlay ?? ""} :: ${baseDir}`;
672
+ // EVERY DIMENSION THAT CHANGES THE ANSWER IS IN THE KEY. A cache keyed on the directory alone
673
+ // serves the demo's roster to the next caller that did not ask for it, which is the defect this
674
+ // gate exists to prevent, arriving from inside.
675
+ const cacheKey = `${overlay ?? ""} :: ${baseDir} :: ${includeTestFixtures ?? "env"} :: ${process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ?? ""} :: ${includeDemo ?? "env"} :: ${process.env.CLEAROTRON_DEMO_PROFILES ?? ""}`;
663
676
  if (cache && !force && cache.key === cacheKey) return cache.profiles;
664
677
 
665
678
  // FAIL LOUD ON AN UNREADABLE OVERLAY, the same ruling as the doctrine tree's. existsSync() answers
@@ -675,7 +688,7 @@ export function loadProfiles({ dir, force = false } = {}) {
675
688
  // chosen. driver/test/pool-admin-reassign.test.mjs asserts "with CLEAROTRON_CUSTOMERS_DIR unset it REFUSES
676
689
  // rather than validating against the demo roster", and mcp-server's roster boot check counts the
677
690
  // configured roster exactly. Layering the whole bundled set underneath a configured store would put
678
- // aurora/petcary/zephyr into every deployment's roster: a typo'd customer key would be checked
691
+ // the three test accounts into every deployment's roster: a typo'd customer key would be checked
679
692
  // against demo fixtures, and a boot check that says "N customers" would count ours among theirs.
680
693
  //
681
694
  // `generic` is different in kind from the rest of that directory. It is not a demo customer — it is
@@ -683,6 +696,84 @@ export function loadProfiles({ dir, force = false } = {}) {
683
696
  // is the one file whose absence makes an empty store a refusal, so that is the only one that falls
684
697
  // through. Everything else in a deployment's roster is the deployment's own.
685
698
  const profiles = overlay ? readProfilesLayer(overlay) : readProfilesLayer(baseDir);
699
+
700
+ // ── A TEST FIXTURE IS PRESENT AND NEVER OFFERED ─────────────────────────────────────────────────
701
+ //
702
+ // An outside user installed this product and their brand-owner picker offered three of our test
703
+ // accounts. The picker was not wrong: it listed the roster, and the roster was the bundled directory.
704
+ //
705
+ // `package.json` already excludes those files, and that exclusion works — the published tarball
706
+ // carries two profiles. It protects ONE route. `INSTALL.md` documents `git clone && npm install` as a
707
+ // first-class way in, and on that route nothing is excluded, the checkout IS the bundled directory,
708
+ // and every fixture loads. A packaging rule cannot answer a question the loader is asked.
709
+ //
710
+ // So the loader answers it. A profile marked `testFixture` is refused from the roster this returns, on
711
+ // every route, whether it was excluded from a tarball or not — and the suite asks for it by name.
712
+ //
713
+ // TWO FLAGS, TWO QUESTIONS, AND THE SECOND ONE'S ANSWER CHANGED. `testFixture` marks visibility:
714
+ // a suite fixture is never offered. `demoData` marks provenance: the account is fiction and the
715
+ // admission wall refuses a real clearance under it. This file argued that `demoData` must leave its
716
+ // account LISTABLE, because the demo needed to show it — true until the owner ruled otherwise on
717
+ // 2026-09-08. A fresh install now resolves `generic` alone, and the demo asks for its own account
718
+ // when it runs, so the demo account is refused here too. The flags stay separate because the
719
+ // questions are: a fixture is never shown to anybody, a demo account is shown to the demo.
720
+ //
721
+ // ONE ROSTER, NOT TWO. The tested set and the resolvable set are now the same files with a flag,
722
+ // rather than five profiles the suite exercises against two a customer receives. That disjoint pair is
723
+ // the defect this module's own header narrates from 2026-07-19, and a packaging-only fix recreates it.
724
+ //
725
+ // THE SUITE ASKS ONCE, BY ENVIRONMENT, RATHER THAN AT NINETY-FOUR CALL SITES. Most of the checks that
726
+ // need a fixture do not call this function — they exercise code that calls it, so there is no argument
727
+ // to pass. `scripts/test-run.mjs` sets the variable for every child, which keeps the ask visible in
728
+ // one place and OFF everywhere else, including any process that merely imports this module.
729
+ //
730
+ // That leaves the tested roster larger than the resolved one, which is the disjoint-roster shape this
731
+ // module's header narrates and the reason a packaging-only fix was refused. What makes it one roster
732
+ // rather than two is `a-clean-install-offers-generic-alone`: it names both sides — every file
733
+ // in the directory, and exactly what a resolved roster returns — so the difference between them is
734
+ // asserted rather than assumed, and adding a fixture without marking it reds that check.
735
+ //
736
+ // AN EXPLICIT ARGUMENT BEATS THE ENVIRONMENT, and the distinction is `undefined` rather than falsiness.
737
+ // The suite sets the variable for every child, so a check asking for the RESOLVED roster — the one a
738
+ // customer gets — would otherwise be handed the suite's own. Two of this change's own arms failed that
739
+ // way before this line: they asked for what an install offers and were told what the suite runs with.
740
+ const asked = includeTestFixtures !== undefined
741
+ ? includeTestFixtures
742
+ : String(process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ?? "").trim() === "1";
743
+ if (!asked) for (const [k, p] of [...profiles]) if (p?.testFixture === true) profiles.delete(k);
744
+
745
+ // THE DEMO ACCOUNT IS NOT PART OF A FRESH INSTALL EITHER — owner ruling, 2026-09-08: a clean install
746
+ // resolves `generic` and nothing else, and the demo brings its own account when somebody runs it.
747
+ // Nobody should have to clean demo material out of an environment they just created.
748
+ //
749
+ // Same shape as the gate above and for the same reason: the packaging exclusion cannot answer this,
750
+ // because `git clone && npm install` is a documented route on which the checkout IS the bundled
751
+ // directory. The loader answers it on every route.
752
+ const askedDemo = includeDemo !== undefined
753
+ ? includeDemo
754
+ : String(process.env.CLEAROTRON_DEMO_PROFILES ?? "").trim() === "1";
755
+
756
+ // — AND IT GATES THE LAYER, NOT THE FLAG. The ruling is about the demo account this PRODUCT bundles.
757
+ // A deployment that put a `demoData` account in its OWN configured store chose to have it: that is an
758
+ // operator's decision about their own roster, and the admission wall still refuses a real clearance
759
+ // under it, so nothing is spent. Gating on the flag alone would have reached into a configured store
760
+ // and deleted an account nobody asked us to hide — the test deployment has one. When an overlay is
761
+ // configured this loader reads that store and not the bundled directory, so the question is which
762
+ // directory was actually read — NOT whether an overlay was set. An explicit `dir` pointing at a
763
+ // configured store leaves `overlay` null while reading somebody's own roster, and `!overlay` would
764
+ // have called that bundled and gated it.
765
+ const bundled = (overlay ?? baseDir) === PROFILES_BASE_DIR;
766
+ //
767
+ // THE MORE SPECIFIC FLAG DECIDES. Every shipped profile but `generic` carries `demoData`, the three
768
+ // suite fixtures included, so a demo gate applied to all of them would swallow a roster somebody had
769
+ // explicitly asked for: `includeTestFixtures: true` would return `generic` alone, silently, and a
770
+ // check that asks for its own fixtures would pass having exercised nothing. That is the same vacuous
771
+ // green two of this loader's own checks produced before the argument beat the environment. A fixture's
772
+ // visibility is answered by its own gate above; this one answers only for accounts that are demo data
773
+ // and nothing more specific.
774
+ if (!askedDemo && bundled) {
775
+ for (const [k, p] of [...profiles]) if (p?.demoData === true && p?.testFixture !== true) profiles.delete(k);
776
+ }
686
777
  if (overlay && !profiles.has("generic")) {
687
778
  const base = readProfilesLayer(baseDir);
688
779
  if (base.has("generic")) profiles.set("generic", base.get("generic"));
@@ -742,6 +833,32 @@ export function resolveProfile(job, { profiles = loadProfiles() } = {}) {
742
833
  const PROJECTS_SUBDIR = "projects";
743
834
  let projectCache = null;
744
835
 
836
+
837
+ /**
838
+ * Is `key` a profile that exists in one of these roots and is marked as a test fixture?
839
+ *
840
+ * Read from the FILE rather than from the roster, deliberately: the roster handed to the projects walk
841
+ * is the resolved one, which has already dropped every fixture — so asking it cannot tell "a fixture
842
+ * whose projects are beside it" from "a project directory whose customer is gone". Those want different
843
+ * answers and the file is the only place that still holds the difference.
844
+ */
845
+ function readProfileFlag(roots, key, flag) {
846
+ for (const root of roots) {
847
+ const f = join(root, `${key}.json`);
848
+ if (!existsSync(f)) continue;
849
+ try { return JSON.parse(readFileSync(f, "utf8"))?.[flag] === true; } catch { return false; }
850
+ }
851
+ return false;
852
+ }
853
+
854
+ const isTestFixtureKey = (roots, key) => readProfileFlag(roots, key, "testFixture");
855
+
856
+ /** Is this project directory's customer an account the DEMO brings, rather than one this install has?
857
+ * Read from the FILE for the same reason as the fixture check beside it: the roster handed to this walk
858
+ * has already dropped the account, so asking the roster cannot tell "the demo's, and hidden" from "the
859
+ * customer is gone", and the second is an authoring error that must still hard-fail. */
860
+ const isDemoKey = (roots, key) => readProfileFlag(roots, key, "demoData");
861
+
745
862
  /** Load every profiles/projects/<customer>/<slug>.json → Map("<customer>/<slug>" → overlay). Each overlay is
746
863
  * validated in SPARSE mode (PROJECT_KEYS optional, customer-only keys rejected) and carries its lifted-out
747
864
  * `projectName` (default = slug) + optional sibling `<slug>.context.md`. A project directory under an unknown
@@ -778,8 +895,22 @@ export function loadProjects({ dir, profiles, force = false } = {}) {
778
895
  for (const ent of readdirSync(projDir, { withFileTypes: true })) {
779
896
  if (!ent.isDirectory()) continue; // only <customer>/ subdirs participate; a stray file is ignored
780
897
  const ck = ent.name;
781
- if (!roster.has(ck))
898
+ // A PROJECT UNDER A TEST FIXTURE IS SKIPPED, NOT A FAULT. The refusal below is right and stays: a
899
+ // project directory with no customer is a typo or a half-deleted account, and resolving it silently
900
+ // would attach an overlay to nothing. But a fixture's own projects are in the same checkout as the
901
+ // fixture, and the roster no longer offers the fixture — so on a clone install this refusal would
902
+ // fire on the product's own files and stop the process at load. Skipped by reading the fixture
903
+ // marker from the file, because the roster this walk was handed is the one that already dropped it.
904
+ if (!roster.has(ck)) {
905
+ if (isTestFixtureKey(roots, ck)) continue;
906
+ // AND THE DEMO'S OWN PROJECTS, for the identical reason. `profiles/projects/demo-brand-owner/`
907
+ // ships, and since a fresh install stopped resolving that account this walk met a project whose
908
+ // customer it could not see — a HARD startup failure, from the product's own files, on every
909
+ // clean install. Caught by the arm below, not by review; it is the third time this shape has been
910
+ // found in this loader.
911
+ if (isDemoKey(roots, ck)) continue;
782
912
  throw new Error(`profiles/projects/${ck}/: no customer profile "${ck}" — a project must live under a known customer (add profiles/${ck}.json first)`);
913
+ }
783
914
  const cdir = join(projDir, ck);
784
915
  for (const f of readdirSync(cdir).filter((n) => n.endsWith(".json")).sort()) {
785
916
  const slug = f.replace(/\.json$/, "");
@@ -845,7 +976,7 @@ export function resolveEffectiveProfile(job, { profiles = loadProfiles(), projec
845
976
  //
846
977
  // The customer's platforms are CLIENT-MANDATED — the account asked for those marketplaces to be
847
978
  // searched, and a project may add to that instruction but never revoke it. Replace semantics meant a
848
- // project that stated its own marketplaces silently DELETED the customer's: the Aurora Interactive account
979
+ // project that stated its own marketplaces silently DELETED the customer's: one test account
849
980
  // names 7 games storefronts, its console-ecosystem project names 9 mostly-retail sites, and every run
850
981
  // of that project searched the 9 — dropping store.epicgames.com, itch.io, apps.microsoft.com and
851
982
  // mobygames.com. The report still read as clean coverage, because the sweep faithfully covered the
@@ -888,10 +1019,11 @@ export function resolveEffectiveProfile(job, { profiles = loadProfiles(), projec
888
1019
  }
889
1020
 
890
1021
  /** The self-exclusion gate: a profile's selfExclusionOwners[]
891
- * may only inject when the job's APPLICANT is the profile's customer — an aurora-interactive.example-forwarded
892
- * search for a third-party applicant must NOT classify Aurora-owned conflicts as own rights
893
- * (that would delete true conflicts from a delivered clearance). Word-boundary containment of the
894
- * profile name in the applicant string ("Aurora Interactive" ⊂ "Aurora Interactive Ltd"), never fuzzy. */
1022
+ * may only inject when the job's APPLICANT is the profile's customer — a search forwarded from a
1023
+ * customer's own domain, but asking about a THIRD-PARTY applicant, must not classify that
1024
+ * customer's conflicts as own rights (that would delete true conflicts from a delivered
1025
+ * clearance). Word-boundary containment of the profile name in the applicant string
1026
+ * ("Foxglade Interactive" ⊂ "Foxglade Interactive Ltd"), never fuzzy. */
895
1027
  export function applicantMatchesProfile(profile, customer) {
896
1028
  const n = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim();
897
1029
  const c = n(customer);