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,97 @@
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 MINIMUM NODE VERSION, READ FROM THE ONE PLACE THAT DECLARES IT.
5
+ //
6
+ // `package.json` says `engines: { node: ">=22.13.0" }`. npm reads that field, and so does everything
7
+ // here. Nothing restates the number.
8
+ //
9
+ // WHY THIS FILE EXISTS RATHER THAN A CONSTANT. There was a constant: `NODE_FLOOR = 22` in bin/onboard.mjs,
10
+ // compared as `Number(process.versions.node.split(".")[0]) >= NODE_FLOOR`. A major-only comparison
11
+ // STRUCTURALLY CANNOT SEE A MINOR FLOOR, so 22.9.0 passed a check written for a 22.13.0 floor — the check said
12
+ // `node 22.9.0` and a tick while npm, reading the same requirement from the same repository, printed
13
+ // EBADENGINE for it. One requirement, two spellings, already disagreeing.
14
+ //
15
+ // The cost was a first-run failure nobody could diagnose: an engine door exited 1 on a machine below the
16
+ // floor, and every check the product offers said the runtime was fine.
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { join, dirname } from "node:path";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+
24
+ /** The declared range, verbatim, e.g. `">=22.13.0"`. Throws if the field is gone — an absent floor is a
25
+ * packaging fault, not a licence to run on anything. */
26
+ export function declaredRange(root = join(HERE, "..")) {
27
+ const range = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))?.engines?.node;
28
+ if (!range) throw new Error("package.json declares no engines.node — the runtime floor has no source");
29
+ return String(range);
30
+ }
31
+
32
+ /**
33
+ * `">=22.13.0"` → [22, 13, 0]. `">=22"` and `">=22.13"` are accepted too, with the absent parts read as
34
+ * zero, because that is what they mean and not a guess — and because the field is edited by whoever
35
+ * changes the floor, who should not have to know which spelling this reader was written against. A
36
+ * coordination failure between two people editing one number is the defect this whole file exists for.
37
+ *
38
+ * ONLY the `>=` family. A caret or tilde range, or an `||` union, THROWS rather than being interpreted:
39
+ * a floor read wrongly is worse than one not read at all, because it would silently pass every version
40
+ * and take every check built on it with it.
41
+ */
42
+ export function floorOf(range) {
43
+ const m = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/.exec(String(range).trim());
44
+ if (!m) throw new Error(`engines.node is ${range}, which this reader does not understand — expected ">=x", ">=x.y" or ">=x.y.z"`);
45
+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
46
+ }
47
+
48
+ /**
49
+ * `"22.16.0"` → [22, 16, 0], ignoring any pre-release or build suffix. NULL when it cannot be read.
50
+ *
51
+ * Null rather than zeroes, and the difference is the whole of it: `[0, 0, 0]` compares as below every
52
+ * floor, so a version string this reader did not understand would REFUSE — and this file would become
53
+ * the reason an install fails on a Node that is probably fine. A parser gap must not become an outage.
54
+ * Found in review, 2026-09-08.
55
+ */
56
+ export const partsOf = (v) => {
57
+ // THE `v` IS OPTIONAL AND MUST BE, because both spellings are in reach: `process.versions.node` has
58
+ // no prefix and `process.version` does. Without it a caller handed the prefixed form gets null,
59
+ // null passes, and an out-of-date runtime is waved through by the guard written to stop it — the
60
+ // permissive half of the same asymmetry, which is the direction that fails silently.
61
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim());
62
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
63
+ };
64
+
65
+ /** Is `current` at or above `floor`? ALL THREE PARTS, in order — the defect this replaces compared one. */
66
+ export function meetsFloor(current, floor) {
67
+ const c = partsOf(current);
68
+ // AN UNREADABLE RUNNING VERSION PASSES, and that asymmetry is deliberate. The DECLARED range is our
69
+ // own manifest and throws loudly when it cannot be read, because that is a packaging fault we own.
70
+ // The running version is whatever the runtime reports, and refusing one we failed to parse would
71
+ // stop a working install over our own gap. Refuse only what we can show is below the floor.
72
+ if (!c) return true;
73
+ for (let i = 0; i < 3; i += 1) {
74
+ if (c[i] > floor[i]) return true;
75
+ if (c[i] < floor[i]) return false;
76
+ }
77
+ return true;
78
+ }
79
+
80
+ /**
81
+ * The whole answer, for a caller that wants to refuse.
82
+ *
83
+ * `current` and `root` are injected so a check can drive every side of this without a second Node
84
+ * installation — a floor guard that can only be exercised by running on an old runtime is one nobody
85
+ * ever sees fail.
86
+ */
87
+ export function nodeFloorVerdict({ current = process.versions.node, root } = {}) {
88
+ const range = declaredRange(root);
89
+ const floor = floorOf(range);
90
+ return { ok: meetsFloor(current, floor), current, required: floor.join("."), range };
91
+ }
92
+
93
+ /** What a person is told, in one sentence, naming both versions. No stack, no advice they cannot act on. */
94
+ export function nodeFloorRefusal(v) {
95
+ return `This needs Node ${v.required} or newer, and this is Node ${v.current}. `
96
+ + "Upgrade Node and run the command again — nothing else here will work until you do.";
97
+ }
@@ -0,0 +1,154 @@
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
+ // staff-domain.mjs — what a staff-domain rule ADMITS, decided before one is written.
4
+ //
5
+ // `PORTAL_STAFF_DOMAINS` is a list of email domains, and every address at a listed domain that gets
6
+ // past this instance's sign-in door is staff: it sees every brand owner on the instance
7
+ // (driver/portal-access.mjs). That is a rule about a set of people, not about one person.
8
+ //
9
+ // The install used to build that rule by taking everything after the last `@` of the one address it
10
+ // was given. On a laptop that address is `<account>@localhost` and the rule admits the one identity
11
+ // that can sign in, which is why it was safe and why nobody looked at it again. Given a real address
12
+ // the same line turns a person into their whole employer, or into every customer of a webmail
13
+ // provider, with nothing said to the operator and nothing to undo. A stranger's install granted a
14
+ // documentation domain that way, read its own settings page back, and reported it as a back door.
15
+ //
16
+ // So the derivation is classified before it is used, and there are three answers:
17
+ //
18
+ // "narrow" the domain cannot name a second person on this machine — `localhost`, or any bare
19
+ // hostname with no dot. Deriving a rule from it is what it always was: safe, silent.
20
+ // "public" a webmail or shared provider. Never a staff rule: it would admit strangers, and no
21
+ // deployment can want it. Refused outright, and the refusal names the domain.
22
+ // "reserved" a domain reserved for documentation and testing (RFC 2606). Nobody's real mail lives
23
+ // there, so a rule built from one is always an address somebody typed as a placeholder.
24
+ // Refused for the same reason, with a different sentence, because the remedy differs.
25
+ // "wide" an ordinary routable domain. It may well be the right rule — it is how a firm admits
26
+ // its own lawyers — but it admits people the operator has not met, so it is stated in
27
+ // the words the settings page will use and confirmed before it is written. It is never
28
+ // derived silently from one address.
29
+ //
30
+ // FAIL-CLOSED, in the direction of a smaller grant. A domain wrongly classified `public` refuses, and
31
+ // the operator sets `PORTAL_STAFF_DOMAINS` themselves in one line — an explicit decision, recorded
32
+ // where the settings page can name it. A domain wrongly classified `wide` asks a question. Neither
33
+ // outcome grants anything, which is why the lists below are allowed to be short and stay short: the
34
+ // `wide` branch is what actually protects an operator, and the lists only decide whether the product
35
+ // asks a question or refuses to ask one.
36
+
37
+ /** Last-@ semantics, matching driver/portal-access.mjs and shared/scope.mjs `isFirmDomain`. */
38
+ export function domainOfEmail(email) {
39
+ const e = String(email ?? "").trim().toLowerCase();
40
+ const at = e.lastIndexOf("@");
41
+ return at > 0 ? e.slice(at + 1) : "";
42
+ }
43
+
44
+ /**
45
+ * Webmail and shared mailbox providers, exactly.
46
+ *
47
+ * Not a census of the internet, and deliberately not growing into one: an unlisted provider still
48
+ * lands in `wide`, where it is stated and confirmed rather than written silently. What this list buys
49
+ * is that the commonest addresses a person types — their own personal mail — are refused with a
50
+ * sentence about why, instead of being offered as a rule somebody might say yes to.
51
+ */
52
+ export const PUBLIC_EMAIL_DOMAINS = Object.freeze(new Set([
53
+ "gmail.com", "googlemail.com", "icloud.com", "me.com", "mac.com",
54
+ "aol.com", "mail.com", "gmx.com", "gmx.de", "gmx.net", "web.de", "t-online.de",
55
+ "proton.me", "protonmail.com", "protonmail.ch", "pm.me", "tuta.io", "tutanota.com",
56
+ "fastmail.com", "fastmail.fm", "hey.com", "zoho.com", "hushmail.com",
57
+ "mail.ru", "inbox.ru", "list.ru", "bk.ru", "rambler.ru",
58
+ "qq.com", "163.com", "126.com", "sina.com", "naver.com", "daum.net",
59
+ "seznam.cz", "wp.pl", "o2.pl", "interia.pl", "libero.it", "virgilio.it", "tiscali.it",
60
+ "free.fr", "orange.fr", "wanadoo.fr", "laposte.net", "sfr.fr", "bbox.fr",
61
+ "comcast.net", "verizon.net", "att.net", "sbcglobal.net", "bellsouth.net", "cox.net",
62
+ "btinternet.com", "sky.com", "virginmedia.com", "ntlworld.com", "talktalk.net",
63
+ "bigpond.com", "optusnet.com.au", "shaw.ca", "sympatico.ca", "rogers.com",
64
+ "xs4all.nl", "ziggo.nl", "telenet.be", "uol.com.br", "bol.com.br", "terra.com.br",
65
+ "rediffmail.com", "yandex.com", "yandex.ru", "ya.ru",
66
+ ]));
67
+
68
+ /**
69
+ * Providers that sell the same mailbox under a per-country domain.
70
+ *
71
+ * Matched on the FIRST label, so `yahoo.co.uk` and `yahoo.fr` are covered without listing every
72
+ * country. Kept to names that are a provider and nothing else — a first label like `free` or `orange`
73
+ * is a real company's name somewhere, so those stay in the exact list above.
74
+ */
75
+ export const PUBLIC_EMAIL_FAMILIES = Object.freeze(new Set([
76
+ "yahoo", "ymail", "rocketmail", "hotmail", "outlook", "live", "msn", "gmx", "yandex", "googlemail",
77
+ ]));
78
+
79
+ /**
80
+ * Reserved for documentation and testing — RFC 2606 §2 and §3.
81
+ *
82
+ * `example.com`, `example.net`, `example.org` and everything under them, plus the whole of the
83
+ * `.example`, `.invalid`, `.test` and `.localhost` top-level names. Real mail is never delivered to
84
+ * any of them, so an address at one is a placeholder somebody typed — which is exactly what happened.
85
+ *
86
+ * `localhost` ITSELF IS NOT HERE. A bare `localhost` is the local-account default and is classified
87
+ * `narrow` below; only `something.localhost` reaches this test.
88
+ */
89
+ function isReservedDomain(domain) {
90
+ if (/^example\.(com|net|org)$/.test(domain)) return true;
91
+ if (/\.example\.(com|net|org)$/.test(domain)) return true;
92
+ const tld = domain.slice(domain.lastIndexOf(".") + 1);
93
+ return domain.includes(".") && ["example", "invalid", "test", "localhost"].includes(tld);
94
+ }
95
+
96
+ /** A domain that cannot name a second person on this machine: `localhost`, or any dotless hostname. */
97
+ function isNarrowDomain(domain) {
98
+ return domain === "localhost" || domain === "localhost.localdomain" || !domain.includes(".");
99
+ }
100
+
101
+ /**
102
+ * What a staff rule built from this domain would admit.
103
+ *
104
+ * @param {string} domain a bare domain, already lowercased — `domainOfEmail` produces one.
105
+ * @returns {"narrow"|"public"|"reserved"|"wide"|""} "" only when there is no domain to classify.
106
+ */
107
+ export function classifyStaffDomain(domain) {
108
+ const d = String(domain ?? "").trim().toLowerCase();
109
+ if (!d) return "";
110
+ if (isNarrowDomain(d)) return "narrow";
111
+ if (isReservedDomain(d)) return "reserved";
112
+ if (PUBLIC_EMAIL_DOMAINS.has(d)) return "public";
113
+ if (PUBLIC_EMAIL_FAMILIES.has(d.slice(0, d.indexOf(".")))) return "public";
114
+ return "wide";
115
+ }
116
+
117
+ /**
118
+ * The rule in the words the settings page uses, so the operator recognises what they agreed to.
119
+ *
120
+ * ONE COMPOSER FOR BOTH SURFACES. The People & access screen renders "Anyone at <domain>" under a
121
+ * heading that calls it a config rule rather than a person, and describes staff as capable of seeing
122
+ * every brand owner. A consent prompt phrased any other way asks about one thing and shows another.
123
+ */
124
+ export function staffGrantSentence(domain, { staffLabel = "Staff" } = {}) {
125
+ return `Anyone at ${domain} — a rule, not a person. ${staffLabel}: capable to see every brand owner `
126
+ + "on this instance.";
127
+ }
128
+
129
+ /**
130
+ * Why this domain cannot become a staff rule, and what to do instead.
131
+ *
132
+ * Returns null for a domain that CAN — `narrow` and `wide` are not refusals, and `wide` is answered by
133
+ * the confirmation the caller runs, not by this function.
134
+ */
135
+ export function staffDomainRefusal(domain, { variable = "PORTAL_STAFF_DOMAINS" } = {}) {
136
+ const d = String(domain ?? "").trim().toLowerCase();
137
+ const verdict = classifyStaffDomain(d);
138
+ if (verdict === "public") {
139
+ return `${d} is a public email provider, so a staff rule built from it would admit anyone with an `
140
+ + `address there — not your colleagues. Refusing to write one.\n`
141
+ + ` Use an address at a domain your organisation controls, or set ${variable} yourself to the `
142
+ + "domain you mean.\n"
143
+ + " If this machine is only yours, the local-account form takes no rule at all: leave the "
144
+ + "address as <account>@localhost.";
145
+ }
146
+ if (verdict === "reserved") {
147
+ return `${d} is reserved for documentation and receives no real mail (RFC 2606), so it is an `
148
+ + "address somebody typed as a placeholder rather than one that signs in. Refusing to build a "
149
+ + "staff rule from it.\n"
150
+ + " Use the address you actually sign in with, or the local-account form <account>@localhost "
151
+ + "if this machine is only yours.";
152
+ }
153
+ return null;
154
+ }
@@ -38,13 +38,34 @@
38
38
  // that does not exist yet is still misconfigured if it names another tree — the service would create it
39
39
  // and then fail every commit — so the lexical judgment stands where the filesystem cannot overturn it.
40
40
 
41
- import { resolve, dirname } from "node:path";
41
+ import { resolve, dirname, relative, isAbsolute } from "node:path";
42
42
  import { realpathSync } from "node:fs";
43
43
 
44
44
  /** `resolve`, then the symlink-resolved form when the path exists — null when it cannot be read. */
45
45
  const real = (p) => { try { return realpathSync(resolve(p)); } catch { return null; } };
46
46
 
47
- const within = (dir, root) => dir === root || dir.startsWith(root.endsWith("/") ? root : root + "/");
47
+ // CONTAINMENT IS ASKED OF THE PATH LIBRARY, NOT OF THE STRING.
48
+ //
49
+ // This was `dir.startsWith(root + "/")`, with the separator written in. On Windows `resolve()` returns
50
+ // backslashes, so `C:\…\config\recipes` does not start with `C:\…\config/` and a folder plainly inside
51
+ // its parent read as OUTSIDE — an operator on a correct install was told their recipe store was outside
52
+ // the repository root, and the saved-search door refused. Measured on his paths.
53
+ //
54
+ // The posix control passed throughout, which is why it survived: the string test is right on the platform
55
+ // it was written on and wrong on the one nobody here runs.
56
+ //
57
+ // `relative` also settles two cases a prefix test gets wrong on Windows even with the separator fixed: a
58
+ // different drive letter yields an absolute path rather than a `..` walk, and path comparison there is
59
+ // case-insensitive in a way `startsWith` is not. A sibling directory whose name merely begins with the
60
+ // root's — `config` and `configXX` — is still outside, which the old `+ "/"` also got right and any
61
+ // replacement had to keep.
62
+ // `impl` is the path module to ask, injected ONLY so a check can drive the Windows behaviour from a
63
+ // Linux runner. That matters here more than usual: this defect was invisible on the platform every one
64
+ // of us runs, and a guard that can only be exercised on Windows is a guard nobody will ever see fail.
65
+ export const within = (dir, root, impl = { relative, isAbsolute }) => {
66
+ const rel = impl.relative(root, dir);
67
+ return rel === "" || (!rel.startsWith("..") && !impl.isAbsolute(rel));
68
+ };
48
69
 
49
70
  /**
50
71
  * Is `storeDir` somewhere `git -C repoRoot add` could stage it?
@@ -41,7 +41,7 @@ Extract from the request, faithfully (verbatim beats paraphrase):
41
41
  - **`forwarder`** (+ `forwarderEmail`) — the requester/reply route. REQUIRED, no default: this is
42
42
  where the report goes; a wrong route misdelivers a confidential document.
43
43
  - **`profileKey`** — call **`list_profiles`** and resolve by JUDGMENT: an explicit name, a
44
- misspelling ("Zefyr" zephyr), or an implicit reference ("our functional-beverage client") all
44
+ misspelling (a name typed as it sounds), or an implicit reference ("our functional-beverage client") all
45
45
  map to a key. OMIT it for a new/unknown customer (the neutral generic profile applies — this is
46
46
  non-blocking by design). Ask the requester only when you genuinely cannot tell. **Never pick a
47
47
  profile from the sender's email domain.**
@@ -1,49 +0,0 @@
1
- # Saved searches ("recipes")
2
-
3
- One subdirectory per customer — `aurora/` and `zephyr/` — each holding one JSON file per saved search at
4
- `<customer>/<slug>.json`; the loader walks directories only, so nothing else here is a store. A recipe is a
5
- small named bundle: a base product, optional component toggles, an optional `scope` block (where the
6
- machinery points: `jurisdictions`, `platforms`, `classes`), and instruction-shaped `extras`.
7
- The display field is `label`, deliberately not `name` — `name` is a profile key, and the two key sets are
8
- disjoint by design, so a saved search can never carry rating config.
9
-
10
- ## What reads it
11
-
12
- `loadRecipes()` in `../search-policy.mjs` walks `<dir>/<customer>/<slug>.json` into a
13
- `Map("customer/slug" → recipe)`, validating each file with `validateRecipe` — the same validator the write
14
- door uses, so the UI cannot persist a recipe the driver would later reject. `resolveSearchPolicy` then
15
- turns a job's `recipeKey` into the product, components, extras and scope the run uses; a recipe only runs
16
- for its own customer, and an `archived` one refuses with a clarify.
17
-
18
- The write side is `../recipe-service.mjs` (loopback, CF Access JWT, git auto-commit as the verified
19
- identity). The portal mounts those routes through `../portal-upstream.mjs`, which rebuilds the customer
20
- path segment from the resolved account; `../dev-portal.mjs` proxies `/recipes/*` for dev.
21
-
22
- **The store is named, never guessed.** `CLEAROTRON_RECIPES_DIR` unset means the deployment has no saved
23
- searches — not that it falls back to this directory. That fallback existed and was removed: production is
24
- exactly where the variable is unset, so it would have surfaced invented customers inside the product.
25
-
26
- ## These are synthetic demos
27
-
28
- `aurora` and `zephyr` are fictional customers (`../profiles/aurora.json`, `../profiles/zephyr.json`) that
29
- exist so the dev cockpit and the test suite have something to render. A real deployment's recipes live
30
- outside the repo, in the customer-config store beside the profiles — no client data in git.
31
-
32
- | File | Base product, and any extras it carries |
33
- |---|---|
34
- | `aurora/quarterly-screen.json` | `knockout-search`, `extras.emailTable` |
35
- | `aurora/screen-with-register-counts.json` | `knockout-search` |
36
- | `zephyr/standard-clearance.json` | `multi-country-focus-search`, `extras.standingInstructions` + `extras.defaultDeadlineDays` |
37
-
38
- Two of them carry settings the product no longer offers, which is not an oversight: `emailTable` is inert
39
- but still validated so recipes written while it worked keep loading, and `defaultDeadlineDays` is a retired
40
- extra that `loadRecipes` drops on read and `validateRecipe` refuses on save. A stored recipe must not brick
41
- on a change it never asked for.
42
-
43
- ## Where to start
44
-
45
- `zephyr/standard-clearance.json` — twelve lines, and the one whose `extras` show both what an
46
- instruction-shaped setting looks like (`standingInstructions`) and what a retired one looks like still
47
- sitting in a stored file. Then the recipe block in `../search-policy.mjs` (`RECIPE_KEYS`,
48
- `RECIPE_SCOPE_KEYS`, `RECIPE_EXTRA_KEYS`, `validateRecipe`), which is where every rule above is enforced
49
- and commented.
@@ -1,19 +0,0 @@
1
- {
2
- "schema_version": 1,
3
- "framework_key": "aurora",
4
- "title": "Aurora Interactive risk framework (synthetic demo)",
5
- "source_deck": "Synthetic demo transposition of a customer risk deck (structure faithful, content invented), doc 50 shape",
6
- "entity_label": "Aurora Interactive",
7
- "bands": [
8
- { "label": "Very High", "tone": "severe" },
9
- { "label": "High", "tone": "high" },
10
- { "label": "Medium", "tone": "medium" },
11
- { "label": "Manageable", "tone": "low" },
12
- { "label": "Low", "tone": "minimal" }
13
- ],
14
- "structure": {
15
- "kind": "matrix",
16
- "axes": ["Legal Risk Level (A–E)", "Dispute Type"],
17
- "display_note": "The band is read off this framework's Level × Dispute Type matrix; the matrix ceilings are stated in the deck and honoured as written."
18
- }
19
- }
@@ -1,77 +0,0 @@
1
- # Risk framework — Aurora Interactive (SYNTHETIC demo customer)
2
-
3
- - **Applies to:** Aurora Interactive matters (`profileKey: aurora`).
4
- - **Source:** house-authored demonstration framework. Matrix-shaped, to exercise the two-input path.
5
- - **Bands, highest to lowest:** Very High · High · **Medium** · Manageable · **Low** (this framework, unlike the house default, has a Low band — its Level A output).
6
- - **Entity in prose:** **Aurora Interactive** (name the company; acquired studios are self-exclusions, not the entity).
7
- - **Customer emphasis:** defensibility-first and conservative — lead with a clear go / no-go and the practical "would they actually enforce" read; surface an alternative-name track early on a headline conflict.
8
-
9
- *A rubric to be reasoned with — not paraphrased away and not reduced to a lookup.* This framework RATES Aurora Interactive matters (doc 50). It is **matrix-shaped**: reason the two inputs below in order — the Legal Risk Level, then the Dispute Type — and read the band off the matrix. **State the rating as the band word** (Very High / High / Medium / Manageable / Low); the numerals beside the bands are internal indices, never the output. The engine applies this framework on an advisory basis; the senior lawyer makes the final determination. The firm-wide reasoning discipline — the consumer-confusion gate, the dominant-element rule, crowded-field handling, G&S discipline — lives in [`synthesis-rules.md`](synthesis-rules.md) and applies under every framework; Aurora calibration examples live in [`worked-examples-aurora.md`](worked-examples-aurora.md).
10
-
11
- ## Legal Risk Levels (probability adverse party prevails)
12
-
13
- | Level | Probability | Description |
14
- |-------|------------|-------------|
15
- | **A** | 0–24% | Weak claim, negligible risk, strong defences available |
16
- | **B** | 25–49% | Some credible claims but more likely to prevail; manageable |
17
- | **C** | 51–60% | Better than even chance of adverse outcome; material risk, may need settlement |
18
- | **D** | 61–74% | Likely adverse outcome; some credible defences but unsuccessful result expected |
19
- | **E** | 75%+ | Strong claim / slam dunk against us; no or only weak defences |
20
-
21
- > **The letter follows the words (consistency rule — apply to every rated finding).** State the likelihood of confusion in plain words *first* — "more likely than not they'd win", "better-than-even is not reached", "confusion is unlikely", "distinguishable as wholes" — **then pick the letter those words require**: better-than-even reached → **C–E**; not reached → **A or B** (B = 25–49%, "credible claim but we have the better of it"). The prose read and the A–E letter are **one judgment and may not disagree.**
22
-
23
- ## Dispute Types
24
-
25
- | Type | Characteristics |
26
- |------|----------------|
27
- | **Classic** | Actual confusion anticipated, well-known/famous mark, aggressive brand owner, direct industry competitor |
28
- | **Horse Trade** | Market overlap, settlement likely with payment or use restrictions |
29
- | **Paper Conflict** | Material register risk but no obvious market overlap in practice |
30
- | **Descriptive Terms** | Descriptiveness defence available, or extensive unregistered use weakens distinctiveness |
31
- | **Nuisance Claim** | Weak claim but party may still complain (trolls, squatters, speculative filings) |
32
-
33
- ## The band matrix — reason the two inputs, then read the band off the matrix
34
-
35
- The band is **derived from two reasoned inputs, in this order — it is not chosen freely, and no practical /
36
- optics factor moves the band directly:**
37
-
38
- 1. **Legal Risk Level (A–E) — first.** Answers one question: *"if this third party sued Aurora Interactive,
39
- would they win?"* Set it from the likelihood-of-confusion comparison (marks-as-wholes × goods/services; see
40
- `synthesis-rules.md` → *The consumer confusion test*), net of merits defences. (50% is not allowed — take a
41
- view.) Applies to registered AND common-law rights.
42
- 2. **Dispute Type — second.** Classic / Horse Trade / Paper Conflict / Descriptive (or extensive-use) / Nuisance.
43
-
44
- Then read the **band off the matrix** — this is the *output*; you do not adjust it afterwards:
45
-
46
- | Band | Requires |
47
- |---|---|
48
- | **Very High** *(5)* | Level **D or E** AND **Classic** |
49
- | **High** *(4)* | Level **D or E** AND (**Horse Trade** or **Nuisance**) |
50
- | **Medium** *(3)* | Level **C / D / E** AND (**Paper** or **Descriptive**) — OR — Level **C** AND (**Horse Trade** / **Classic** / **Nuisance**) |
51
- | **Manageable** *(2)* | Level **B** (routinely accepted; still alert on a troll/squatter, a known competitor / major brand owner, or heavy clean-up cost) |
52
- | **Low** *(1)* | Level **A** |
53
-
54
- **The ceilings are hard — and they ARE this framework's anti-escalation mechanism:**
55
- - **High and Very High REQUIRE Legal Level D or E.** A **Level-C finding tops out at Medium**, whatever the
56
- dispute type or the practical factors. **Level A → Low and Level B → Manageable are fixed.**
57
- - **Elevate / mitigate considerations move the INPUTS — the Legal Level or the Dispute Type — never the output
58
- band** (the factor lists live in `synthesis-rules.md`). A "confirmed aggressive enforcer", owner size, PR
59
- sensitivity, or an Aurora Interactive partnership cannot lift a finding past its level's ceiling: they only
60
- change *whether they'd win* (the Level) or the *character* of the dispute (the Type), and the matrix then
61
- produces the band. This is exactly the optics-escalation error the ceilings prevent — a Level-C read does NOT
62
- become High because the owner enforces aggressively; a house-mark-distinguished Level-B mark does NOT become
63
- Very High because the owner is large or a partner.
64
-
65
- **Calibration anchors (structure-faithful; the mark names are invented, the anchor companies are real and named only as public examples of scale):** VELARIS / POLARIS-class famous-mark conflicts = Level
66
- **E** + **Classic** = **Very High**; TerraForge / TerraFlex = Level **E** + **Horse Trade** = **High**;
67
- Northgate / NorthData = Level **D** + **Horse Trade** = **High**.
68
-
69
- ## Band meanings
70
-
71
- | Band | Meaning | Typical Response |
72
- |-------|---------|-----------------|
73
- | **Very High** | Generally not accepted | Run alternative name in parallel |
74
- | **High** | May accept with strong mitigation | Requires active risk management plan |
75
- | **Medium** | May accept with settlement/clean-up plan | Proceed with awareness and contingency |
76
- | **Manageable** | Routinely accepted | Note and monitor |
77
- | **Low** | Target outcome | Proceed with confidence |
@@ -1,14 +0,0 @@
1
- {
2
- "schema_version": 1,
3
- "framework_key": "zephyr",
4
- "title": "Zephyr Beverages risk framework (synthetic demo)",
5
- "source_deck": "Synthetic demo transposition of a customer risk deck (structure faithful, content invented), doc 50 shape",
6
- "entity_label": "Zephyr/Volt/Kaskade",
7
- "bands": [
8
- { "label": "Very High", "tone": "severe" },
9
- { "label": "High", "tone": "high" },
10
- { "label": "Medium", "tone": "medium" },
11
- { "label": "Manageable", "tone": "low" }
12
- ],
13
- "structure": { "kind": "bands" }
14
- }
@@ -1,33 +0,0 @@
1
- # Risk framework — Zephyr Beverages (SYNTHETIC demo customer)
2
-
3
- - **Applies to:** Zephyr matters (`profileKey: zephyr`).
4
- - **Source:** house-authored demonstration framework. The house default with a different entity family and one band relabelled.
5
- - **Bands, highest to lowest:** Very High · High · **Medium** · Manageable. There is no "Low"; Manageable is the lowest band.
6
- - **Entity in prose:** **Zephyr/Volt/Kaskade** — name the brand family, not "the company".
7
- - **Customer emphasis:** pragmatic and launch-oriented — a clear go / no-go and the facts that condition each real blocker, rather than exhaustive caveats. (Express this emphasis in the product voice: facts that condition, never a recommended "path" — retired voice, 2026-07-28.)
8
-
9
- *This is the house default with exactly two differences: the entity family, and band 3 is labelled **Medium** (not "Moderate"). A rubric to be reasoned with, not paraphrased.* This framework RATES the matter (doc 50): reason each conflict through the band definitions below — Legal position first, then Practical position, then Potential outcomes — and the band follows the words. A conflict Zephyr/Volt/Kaskade clearly wins with no material risk is **not a rated conflict** (there is no "Low" band; see `synthesis-rules.md`). The engine applies this framework on an advisory basis; the senior lawyer makes the final determination. The firm-wide reasoning discipline lives in [`synthesis-rules.md`](synthesis-rules.md) and applies under every framework; Zephyr calibration examples live in [`worked-examples-zephyr.md`](worked-examples-zephyr.md).
10
-
11
- ## VERY HIGH RISK
12
-
13
- - **Legal position.** Prior rights owner is **very likely to win** and there are **no** credible defences or risk-mitigation strategies available to **Zephyr/Volt/Kaskade**.
14
- - **Practical position (one or more).** There is an obvious market overlap or risk of actual confusion — coexistence does not appear possible, even with payment of money, use restrictions or other exchange; **or** the prior mark is famous, owned by a known aggressor, or an industry competitor.
15
- - **Potential outcomes.** Name change likely required, possibly on an urgent basis · monetary exposure **more than $10 million** · severe PR consequences · highly unlikely to mitigate risk irrespective of time and money.
16
-
17
- ## HIGH RISK
18
-
19
- - **Legal position.** Prior rights owner is **likely to win**, although some credible defences are available to **Zephyr/Volt/Kaskade**.
20
- - **Practical position.** There is possible market overlap, but co-existence appears possible with settlement involving payment of money, use restrictions, and/or other exchange.
21
- - **Potential outcomes.** Name change may be required, possibly on an urgent basis · high business disruption possible · monetary exposure **up to $10 million** · PR consequences · cleanup or mitigation may be possible depending on time and budget.
22
-
23
- ## MEDIUM RISK
24
-
25
- - **Legal position.** Prior rights owner is **more likely than not to win**, although reasonable defences or risk-mitigation steps are available to **Zephyr/Volt/Kaskade**.
26
- - **Practical position (one or more).** There is no obvious market overlap; **or** risk-mitigation strategies are available to Zephyr/Volt/Kaskade (e.g. the prior mark is arguably weak or vulnerable to attack); **or** there are practical factors which suggest the prior rights owner may not object (e.g. settlement discussions are underway).
27
- - **Potential outcomes.** Name change may be required, possibly on an urgent basis · business disruption possible · monetary exposure **$5 to 7 figures** · PR consequences possible · cleanup or mitigation may be possible depending on time and budget.
28
-
29
- ## MANAGEABLE RISK *(lowest band)*
30
-
31
- - **Legal position.** **Zephyr/Volt/Kaskade** is **more likely than not to win**, although there is still material risk and uncertainty.
32
- - **Practical position.** This level of risk includes nuisance claims, strategic (but weak) complaints by a competitor, and registration obstacles.
33
- - **Potential outcomes.** Budget potentially required for prosecution hurdles and nuisance claims.
@@ -1,10 +0,0 @@
1
- # Example per-customer worked examples — aurora (synthetic)
2
-
3
- Synthetic placeholder demonstrating a profile's `workedExamplesPath` hook. Real per-customer worked
4
- examples are a customer-config-store artifact, not repo code.
5
-
6
- **Convention (P6):** a per-customer file ADDS CALIBRATION and never replaces the voice. The before/after
7
- voice pairs live in [`worked-examples.md`](worked-examples.md) → *Voice — worked before / after pairs*, and
8
- a customer artifact that omits them leaves this customer's runs without the examples the house voice is
9
- learned from. The house prose contract itself is carried unconditionally by the stage message, so the RULES
10
- reach every run regardless of profile — it is the worked pairs that a replacement file can silently drop.
@@ -1,10 +0,0 @@
1
- # Example per-customer worked examples — zephyr (synthetic)
2
-
3
- Synthetic placeholder demonstrating a profile's `workedExamplesPath` hook. Real per-customer worked
4
- examples are a customer-config-store artifact, not repo code.
5
-
6
- **Convention (P6):** a per-customer file ADDS CALIBRATION and never replaces the voice. The before/after
7
- voice pairs live in [`worked-examples.md`](worked-examples.md) → *Voice — worked before / after pairs*, and
8
- a customer artifact that omits them leaves this customer's runs without the examples the house voice is
9
- learned from. The house prose contract itself is carried unconditionally by the stage message, so the RULES
10
- reach every run regardless of profile — it is the worked pairs that a replacement file can silently drop.