clearotron 0.3.1-beta.1 → 0.3.1-beta.2

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 (72) hide show
  1. package/.env.example +17 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +6 -1
  4. package/THIRD-PARTY-NOTICES.md +4 -4
  5. package/bin/connect.mjs +2 -2
  6. package/bin/onboard.mjs +96 -11
  7. package/bin/start.mjs +129 -23
  8. package/build-info.json +2 -2
  9. package/docs/architecture/05-config-governance.md +14 -0
  10. package/driver/CHANGELOG.md +44 -0
  11. package/driver/citation-census.json +2 -1
  12. package/driver/common-law-receipts.mjs +78 -1
  13. package/driver/contract-vocabulary.mjs +6 -6
  14. package/driver/engine/anthropic-agent.mjs +75 -0
  15. package/driver/form-neighbourhood.mjs +44 -2
  16. package/driver/matter-frame-record.mjs +68 -1
  17. package/driver/named-band.mjs +1 -1
  18. package/driver/package.json +1 -1
  19. package/driver/pipeline.mjs +27 -27
  20. package/driver/portal-config-view.mjs +6 -0
  21. package/driver/portal-mcp-client.mjs +54 -9
  22. package/driver/portal-service.mjs +69 -6
  23. package/driver/profile-page.html +23 -1
  24. package/driver/profiles.mjs +5 -1
  25. package/driver/register-availability.mjs +2 -2
  26. package/driver/register-plan.mjs +22 -0
  27. package/driver/scope-ledger.mjs +33 -0
  28. package/driver/skills/prelim-common-law/SKILL.md +3 -1
  29. package/driver/skills/prelim-search/synthesis-rules.md +3 -1
  30. package/driver/stages.mjs +10 -1
  31. package/driver/suite-census.json +74 -56
  32. package/mcp-server/CHANGELOG.md +12 -0
  33. package/mcp-server/http-server.mjs +27 -4
  34. package/mcp-server/lib/cf-access.mjs +16 -2
  35. package/mcp-server/lib/http-handler.mjs +37 -1
  36. package/mcp-server/lib/ops.mjs +22 -9
  37. package/mcp-server/lib/options.mjs +1 -1
  38. package/mcp-server/lib/plan.mjs +47 -2
  39. package/mcp-server/package.json +1 -1
  40. package/mcp-server/server.mjs +29 -4
  41. package/package.json +1 -1
  42. package/portal-ui/dist/assets/{index-Cv-E_agg.css → index-D5WAoLZI.css} +20 -6
  43. package/portal-ui/dist/assets/{index-DLa7PlKY.js → index-D8ITW-aD.js} +2131 -743
  44. package/portal-ui/dist/index.html +2 -2
  45. package/portal-ui/package.json +6 -6
  46. package/providers/_shared/term-shape.mjs +6 -0
  47. package/providers/_shared/territory-codes.mjs +43 -0
  48. package/providers/clarivate/src/capabilities.js +13 -1
  49. package/providers/clarivate/src/core.js +21 -1
  50. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  51. package/providers/oauth-mcp-bridge/package.json +1 -1
  52. package/scripts/citation-line-check.mjs +67 -21
  53. package/scripts/e2e.mjs +4 -2
  54. package/scripts/import-cycle-check.mjs +34 -3
  55. package/scripts/live-surface-check.mjs +2 -2
  56. package/scripts/mint-names-in-force.mjs +21 -2
  57. package/scripts/purge-runs.mjs +114 -3
  58. package/scripts/revisit-render-check.mjs +77 -44
  59. package/scripts/test-run.mjs +7 -0
  60. package/scripts/travelling-predicates.mjs +1 -1
  61. package/shared/connect-clients.mjs +95 -16
  62. package/shared/invocation.mjs +25 -0
  63. package/shared/mcp-challenge.mjs +32 -0
  64. package/shared/names-in-force.mjs +2 -0
  65. package/shared/notice-owed.mjs +62 -0
  66. package/shared/scope.mjs +66 -5
  67. package/shared/stdio-connect.mjs +92 -24
  68. package/shared/trigger-lane.mjs +19 -0
  69. package/shared/wsl.mjs +15 -0
  70. package/skills/clearotron-account/SKILL.md +27 -0
  71. package/skills/clearotron-ops/COURIER.md +8 -1
  72. package/skills/clearotron-ops/SKILL.md +18 -2
@@ -21,12 +21,54 @@ const PROTOCOL_VERSION = "2025-03-26";
21
21
  // dev-portal test convention), and the SSE answer needs early-close handling a streamed fetch text()
22
22
  // can wedge on. Collects the body until the server ends the response OR the wanted JSON-RPC id has
23
23
  // arrived on an SSE frame (whichever first — the transport may hold streams open).
24
- function post(urlStr, { headers = {}, body = "", wantId = null, timeoutMs = 30000 } = {}) {
24
+ // ── AN ADDRESS IS EITHER A NETWORK ORIGIN OR A LOCAL SOCKET ─────────────────────────────────────────
25
+ //
26
+ // `unix:/run/clearotron/engine.sock` names the engine's local key door. The engine can serve a key door
27
+ // on a socket and an identity door on a port from ONE process, which is the whole point — a deployment
28
+ // that needed both used to run the process twice, and the last attempt to collapse that by switching
29
+ // modes instead of adding a transport cost an outage.
30
+ //
31
+ // THE SOCKET PATH AND THE REQUEST PATH ARE DIFFERENT THINGS, which is the trap this shape exists to
32
+ // avoid. For a network origin the request path is what `/mcp` is appended to; for a socket the whole
33
+ // value IS a filesystem path and the request path is `/mcp` alone. Composing one string for both gives
34
+ // `unix:/run/x.sock/mcp`, which is a file that does not exist — and the failure arrives as a connect
35
+ // error naming a path nobody configured.
36
+ //
37
+ // THE HOST HEADER IS EXPLICIT, AND THE REASON IS NARROWER THAN THE FIRST DRAFT CLAIMED. That draft said
38
+ // Node sends no Host over a socket. It does: measured on this runtime, a request with `socketPath` and
39
+ // no `host` option arrives carrying `Host: localhost` already. So the header is NOT what makes the
40
+ // request work today, and an arm asserting the server saw a Host would pass with this line deleted.
41
+ //
42
+ // It stays because the value is then OURS rather than a runtime default — HTTP/1.1 requires a Host, a
43
+ // socket has no hostname to derive one from, and a default that is currently convenient is not a
44
+ // contract. It names no machine and resolves nothing either way.
45
+ export function dialTarget(urlStr, requestPath) {
46
+ const raw = String(urlStr ?? "").trim();
47
+ // ── ONE SPELLING, AND THE OTHERS NORMALISE RATHER THAN ALMOST-WORK ───────────────────────────────
48
+ //
49
+ // `unix:/run/x.sock` is the documented form. `unix://` and `unix:///` are what somebody types who has
50
+ // seen a URL before, and taking everything after the colon verbatim made those produce `//run/x.sock`
51
+ // and `///run/x.sock`. Both CONNECT, because POSIX collapses leading slashes — so the wrong spelling
52
+ // works on this platform, is recorded in a settings file, and is a defect waiting for a reader who
53
+ // compares the configured path to the one the engine prints and finds they differ.
54
+ //
55
+ // Collapsed to a single leading slash rather than refused: refusing a spelling that demonstrably
56
+ // connects would be a new failure for deployments that already use it, and the value is unambiguous
57
+ // either way — a socket address is always absolute.
58
+ const m = /^unix:(.+)$/i.exec(raw);
59
+ if (m) return { socketPath: m[1].replace(/^\/+/, "/"), path: requestPath, headers: { host: "localhost" }, https: false };
60
+ const u = new URL(`${raw.replace(/\/$/, "")}${requestPath}`);
61
+ return { socketPath: null, host: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80),
62
+ path: u.pathname + u.search, headers: {}, https: u.protocol === "https:" };
63
+ }
64
+
65
+ function post(urlStr, { headers = {}, body = "", wantId = null, timeoutMs = 30000, target = null } = {}) {
25
66
  return new Promise((resolve, reject) => {
26
- const u = new URL(urlStr);
27
- const req = (u.protocol === "https:" ? httpsRequest : httpRequest)({
28
- host: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80), path: u.pathname + u.search,
29
- method: "POST", headers: { ...headers, "content-length": Buffer.byteLength(body) },
67
+ const t = target ?? dialTarget(urlStr, "");
68
+ const req = (t.https ? httpsRequest : httpRequest)({
69
+ ...(t.socketPath ? { socketPath: t.socketPath } : { host: t.host, port: t.port }),
70
+ path: t.path,
71
+ method: "POST", headers: { ...t.headers, ...headers, "content-length": Buffer.byteLength(body) },
30
72
  }, (res) => {
31
73
  let data = "";
32
74
  const done = () => resolve({ status: res.statusCode ?? 0, headers: res.headers, text: data });
@@ -103,7 +145,10 @@ export const SOCKET_FAILURE_CODES = Object.freeze([
103
145
  export const isSocketFailure = (e) => SOCKET_FAILURE_CODES.includes(e?.code);
104
146
 
105
147
  export async function mcpToolCall({ url, token, tool, args, timeoutMs = 30000 }) {
106
- const endpoint = `${String(url).replace(/\/$/, "")}/mcp`;
148
+ // Resolved ONCE, so all three requests of the handshake dial the same place. Composed per call, the
149
+ // socket form would have to be re-parsed three times and a divergence would show up as a session that
150
+ // initializes and then cannot be found.
151
+ const endpoint = dialTarget(url, "/mcp");
107
152
  const headers = (extra = {}) => ({
108
153
  "content-type": "application/json",
109
154
  "accept": "application/json, text/event-stream",
@@ -112,7 +157,7 @@ export async function mcpToolCall({ url, token, tool, args, timeoutMs = 30000 })
112
157
  });
113
158
 
114
159
  // 1 — initialize (the transport refuses everything else without a session)
115
- const initRes = await post(endpoint, { headers: headers(), wantId: 1, timeoutMs,
160
+ const initRes = await post(null, { target: endpoint, headers: headers(), wantId: 1, timeoutMs,
116
161
  body: JSON.stringify({ jsonrpc: JSONRPC, id: 1, method: "initialize", params: {
117
162
  protocolVersion: PROTOCOL_VERSION, capabilities: {},
118
163
  clientInfo: { name: "trademark-portal", version: "poc" } } }) });
@@ -122,11 +167,11 @@ export async function mcpToolCall({ url, token, tool, args, timeoutMs = 30000 })
122
167
  parseRpcText(initRes, 1); // surfaces JSON-RPC-level init errors
123
168
 
124
169
  // 2 — initialized notification (the spec's handshake close; some transports require it)
125
- await post(endpoint, { headers: headers({ "mcp-session-id": sessionId }), timeoutMs,
170
+ await post(null, { target: endpoint, headers: headers({ "mcp-session-id": sessionId }), timeoutMs,
126
171
  body: JSON.stringify({ jsonrpc: JSONRPC, method: "notifications/initialized" }) }).catch(() => { /* best-effort */ });
127
172
 
128
173
  // 3 — the one tool call
129
- const callRes = await post(endpoint, { headers: headers({ "mcp-session-id": sessionId }), wantId: 2, timeoutMs,
174
+ const callRes = await post(null, { target: endpoint, headers: headers({ "mcp-session-id": sessionId }), wantId: 2, timeoutMs,
130
175
  body: JSON.stringify({ jsonrpc: JSONRPC, id: 2, method: "tools/call", params: { name: tool, arguments: args } }) });
131
176
  if (callRes.status >= 400) throw transportError(`MCP tools/call refused (${callRes.status}): ${callRes.text.slice(0, 200)}`, callRes.status);
132
177
  const msg = parseRpcText(callRes, 2);
@@ -35,12 +35,20 @@
35
35
  import "../shared/env-local.mjs"; // — FIRST: applies the CLEAROTRON_* translation before any module-top
36
36
  // capture evaluates. Reads no `.env` here — that load is gated on isCliEntry(argv[1]).
37
37
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
38
+ import { hostname as osHostname } from "node:os"; // — the address a refusal page names when nothing else identifies this instance
39
+
40
+ // The port this service listens on when nobody says otherwise. Declared here rather than at the
41
+ // listener, because the refusal page names the address too and it is composed on IMPORT-side code: the
42
+ // listener's own constants live inside the entry-point branch and do not exist when another module
43
+ // calls in. One definition, two readers, no second literal to drift.
44
+ const PORTAL_PORT_DEFAULT = 18802;
38
45
  import { storeInRepo, storeOutsideRepoMessage, makeCommittableAudit, resolveStoreRepoRoot, makeStoreCommit } from "../shared/store-in-repo.mjs"; //,
39
46
  import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mjs"; // — one store for the surface and the runs
40
47
  import { clientFailureNote } from "../shared/client-failure-note.mjs"; // — one sentence, three surfaces
41
48
  import { bareInvocation, browserCommand, invocationPrefix, installRoute, npxVersionOf } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
42
49
  import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
43
- import { isWsl } from "../shared/wsl.mjs"; // — on WSL, the connect lines say where they run
50
+ import { isWsl, wslTarget } from "../shared/wsl.mjs"; // — on WSL, the connect lines say where they run and cross the boundary themselves
51
+ import { doorKind } from "../shared/mcp-challenge.mjs"; // — one reading of what a connector asks for, shared with doctor
44
52
  import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
45
53
 
46
54
  /**
@@ -80,7 +88,7 @@ export function opsTokenFor({ bootToken, roster, mint }) {
80
88
  // `EnvironmentFile=%h/.env`, and `childEnv` passes the same value to the portal child — so this process
81
89
  // has held the signing secret on both start paths for as long as both have existed. The comment has been
82
90
  // corrected in place rather than left to be trusted.
83
- import { mintToken, loadGrants, resolvePerson } from "../shared/scope.mjs";
91
+ import { mintToken, loadGrants, resolvePerson, addressesInGrants } from "../shared/scope.mjs";
84
92
  import { withPerson, withCompany } from "../shared/grants-edit.mjs";
85
93
  import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, decided once
86
94
  import { fileURLToPath } from "node:url";
@@ -2301,6 +2309,30 @@ export function makePortalService({
2301
2309
  // every multi-account client was told the connector did not exist yet, while single-account clients
2302
2310
  // saw it. `door:true` is the mode for exactly this question — may this identity enter — and it is what
2303
2311
  // /portal/api/me already uses.
2312
+ // ── ONE READING OF THE CONNECTOR'S OWN DOOR, CACHED ────────────────────────────────────────────────
2313
+ //
2314
+ // The same unauthenticated probe `doctor` makes, asked here so the connector steps follow what the door
2315
+ // actually answers. Bounded three ways, because this sits on a page load: a short timeout, a one-minute
2316
+ // cache, and a failure that answers `null` rather than throwing — a page that cannot read its door says
2317
+ // so, which is the honest half of this change.
2318
+ let doorKindCache = { at: 0, url: null, kind: null };
2319
+ const DOOR_KIND_TTL_MS = 60_000;
2320
+ async function connectorDoorKind(url) {
2321
+ if (!url) return null;
2322
+ const now = Date.now();
2323
+ if (doorKindCache.url === url && now - doorKindCache.at < DOOR_KIND_TTL_MS) return doorKindCache.kind;
2324
+ let probe = null;
2325
+ try {
2326
+ const res = await fetch(url, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(2000) });
2327
+ let body = null;
2328
+ if (res.status === 401) { try { body = (await res.text()).slice(0, 400); } catch { body = null; } }
2329
+ probe = { ok: res.status < 500, status: res.status, error: null, challenge: res.headers.get("www-authenticate"), body };
2330
+ } catch (e) { probe = { ok: false, status: null, error: String(e?.cause?.code ?? e?.name ?? e?.message ?? e) }; }
2331
+ const kind = doorKind(probe);
2332
+ doorKindCache = { at: now, url, kind };
2333
+ return kind;
2334
+ }
2335
+
2304
2336
  if (parts[1] === "api" && parts[2] === "mcp-access" && method === "GET") {
2305
2337
  assertPrincipal(principal, { door: true }); // throws PortalDeny(403) for an unmapped identity
2306
2338
  const url = process.env.CLEAROTRON_CLIENT_MCP_URL || null;
@@ -2347,7 +2379,7 @@ export function makePortalService({
2347
2379
  const offers = connectOffers({
2348
2380
  stdioRoutes: stdio
2349
2381
  ? Object.fromEntries(Object.keys(STDIO_SHAPES).map((shape) =>
2350
- [shape, stdioConnectFor(shape, { workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null })]))
2382
+ [shape, stdioConnectFor(shape, { workDir: process.env.CLEAROTRON_WORK_DIR || null, reportsDir: process.env.CLEAROTRON_REPORTS_DIR || null, wsl: wslTarget() })]))
2351
2383
  : {},
2352
2384
  wsl: isWsl(),
2353
2385
  // ONE ADDRESS, and it is the publicly reachable one ( §5). The
@@ -2356,6 +2388,13 @@ export function makePortalService({
2356
2388
  // a question the moment it auto-started with the product.
2357
2389
  publicAddress: url,
2358
2390
  operator: principal.email ?? null,
2391
+ // WHAT THE DOOR ANSWERS, read from the door rather than assumed by the row. The steps used to
2392
+ // be fixed: Claude's said to paste a key and set authentication to None, which is right for a
2393
+ // self-hosted door and cannot work on any deployment behind an identity provider — the key is
2394
+ // minted for nothing and the warning the reader is told to ignore IS the sign-in. Cached
2395
+ // because this is a page load, not a check: one reading serves every reader for a minute, and
2396
+ // a door that could not be read leaves this null so the page says so instead of guessing.
2397
+ door: await connectorDoorKind(url),
2359
2398
  });
2360
2399
  return { status: 200, json: {
2361
2400
  url, // null ⇒ the UI keeps its honest empty state
@@ -3385,7 +3424,20 @@ async function readJsonBody(req, limitBytes = 131072) {
3385
3424
  /**
3386
3425
  * The instance a refusal page names: its organisation when one is configured, otherwise the host of the
3387
3426
  * sign-in service it sends people to (the OIDC issuer, or the Access team's own domain, derived as the
3388
- * verifier derives it). `null` when neither is set. Read from the environment at call time.
3427
+ * verifier derives it), and failing both the address this process is answering on.
3428
+ *
3429
+ * NEVER NULL, AND THAT IS THE CHANGE. It used to return null with neither configured, and a page that
3430
+ * says nothing about which instance it is defeats the whole point of the sentence — the reader who met
3431
+ * this through a forwarded port could not tell somebody else's refusal from their own, which is the
3432
+ * defect the line exists for. The unconfigured instance is the one most likely to be reached by
3433
+ * accident, so it is the last case that should stay silent.
3434
+ *
3435
+ * THE HOSTNAME IS THE LAST RESORT, not the first, and the note that used to say "never by this
3436
+ * server's own hostname" was right about the order and wrong about the floor. An organisation name and
3437
+ * a sign-in service are public by nature and mean something to a reader; a machine name is neither, and
3438
+ * it is disclosed only where the alternative is a page that identifies nothing at all — an instance
3439
+ * with no organisation and no sign-in service, which is a local one whose reader is the person running
3440
+ * it. Read from the environment at call time.
3389
3441
  */
3390
3442
  function instanceIdentity(env = process.env) {
3391
3443
  if (ORGANISATION_NAME) return `This is ${ORGANISATION_NAME}'s ${BRAND.name} portal`;
@@ -3394,7 +3446,13 @@ function instanceIdentity(env = process.env) {
3394
3446
  let host = null;
3395
3447
  try { host = issuer ? new URL(issuer).host : null; } catch { host = null; }
3396
3448
  if (!host && team) host = `${team}.cloudflareaccess.com`;
3397
- return host ? `This ${BRAND.name} portal signs people in through ${host}` : null;
3449
+ if (host) return `This ${BRAND.name} portal signs people in through ${host}`;
3450
+ // The address this process answers on: what a reader can compare against the one they typed. The port
3451
+ // is the half that matters where a forward is in play, since the machine name will often be the same.
3452
+ const port = String(env.PORTAL_SERVICE_PORT ?? "").trim() || String(PORTAL_PORT_DEFAULT);
3453
+ const machine = (() => { try { return osHostname(); } catch { return ""; } })();
3454
+ const at = [machine, port].filter(Boolean).join(":");
3455
+ return at ? `This ${BRAND.name} portal is the one running on ${at}` : `This is a ${BRAND.name} portal`;
3398
3456
  }
3399
3457
 
3400
3458
  /**
@@ -4058,7 +4116,7 @@ if (isMain) {
4058
4116
  // — resolved through the shared helper so the SOURCE travels with the number. "18802" and
4059
4117
  // "18802 because nobody said otherwise" are different addresses to an operator, and only the
4060
4118
  // second one is a guess at which instance this is.
4061
- const PORT_CHOICE = resolvePort({ value: process.env.PORTAL_SERVICE_PORT, name: "PORTAL_SERVICE_PORT", fallback: 18802 });
4119
+ const PORT_CHOICE = resolvePort({ value: process.env.PORTAL_SERVICE_PORT, name: "PORTAL_SERVICE_PORT", fallback: PORTAL_PORT_DEFAULT });
4062
4120
  const PORT = PORT_CHOICE.port;
4063
4121
  const HOST = process.env.PORTAL_SERVICE_HOST || "127.0.0.1";
4064
4122
  // ──: THE IDENTITY SOURCE IS A MODE, AND IT IS NAMED, NOT INFERRED ─────────────────────────
@@ -4177,7 +4235,12 @@ const PORT = PORT_CHOICE.port;
4177
4235
  // within one domain, and catastrophic here: staff would fail the email list while clients failed the
4178
4236
  // domain list, refusing everyone. That is not hypothetical; it happened in production and locked out
4179
4237
  // every identity including the one the domain rule exists for.
4238
+ // AND THE PEOPLE ADDED SINCE THIS LINE RAN. The two lists above are the environment's, read once —
4239
+ // they mirror a sign-in configuration that lives off this box, and they are the fail-closed floor.
4240
+ // The guest list is this deployment's own, written by the People page, and it is asked at verify
4241
+ // time so somebody added on the page can sign in without a restart. It can only ADD.
4180
4242
  verify = makeAccessVerifier({ team: TEAM, aud: AUD, allowedDomains: DOMAINS, allowedEmails: EMAILS, identityMode: "union",
4243
+ allowedNow: () => addressesInGrants(),
4181
4244
  issuer: OIDC_ISSUER || undefined, jwksUrl: JWKS_URL || undefined, emailClaim: EMAIL_CLAIM });
4182
4245
  // THE BANNER NAMES THE HEADER AND THE CLAIM, because a mis-set header is otherwise indistinguishable
4183
4246
  // from a blanket 401 — the operator reads it here instead of discovering it as "nobody can log in".
@@ -299,8 +299,30 @@ function clearFieldErrors() {
299
299
  [...$("editor").querySelectorAll(".invalid")].forEach(el => el.classList.remove("invalid"));
300
300
  [...$("editor").querySelectorAll(".fielderr")].forEach(el => el.remove());
301
301
  }
302
+ // ── ONE REFUSAL, WHICHEVER VIEW IS RENDERED ─────────────────────────────────────────────────────────
303
+ //
304
+ // The customer editor and the project overlay both write into #editor, so they REPLACE each other and
305
+ // at most one input for a given concept is ever in the document. They name the same concepts under
306
+ // different prefixes: f_juris and p_juris, f_platforms and p_platforms, ten of the thirteen in all.
307
+ //
308
+ // The server-error table names the customer editor's id. On the overlay that id resolves to nothing,
309
+ // markField's guard correctly returns, and the refusal reaches the panel with no field marked — the
310
+ // reader is told WHAT is wrong and not WHERE, which is the half the main editor already fixes.
311
+ //
312
+ // RESOLVED HERE, IN THE ONE FUNCTION EVERY CALLER GOES THROUGH, rather than by giving the table a
313
+ // second column. A column covers the rows somebody remembered; this covers every row by construction,
314
+ // including the three concepts that have no overlay counterpart today and any row added later. Those
315
+ // three resolve to nothing and markField's guard handles them exactly as before.
316
+ //
317
+ // It never crosses views: the alternate is consulted ONLY when the named id is absent, so a customer
318
+ // editor on screen always marks its own field even though the overlay names the same concept.
319
+ function onScreenField(id) {
320
+ if ($(id)) return id;
321
+ const alt = String(id).replace(/^f_/, "p_");
322
+ return alt !== id && $(alt) ? alt : id;
323
+ }
302
324
  function markField(id, message) {
303
- const el = $(id); if (!el) return;
325
+ const el = $(onScreenField(id)); if (!el) return;
304
326
  el.classList.add("invalid");
305
327
  const det = el.closest("details"); if (det) det.open = true; // surface a highlight hidden under More options
306
328
  const field = el.closest(".field") || el.parentElement;
@@ -664,7 +664,11 @@ export function validateProfileEdit(key, profileObj, contextPack = "", { sparse
664
664
  errors.push(`profiles/${key}.json: ${dropped.map((d) => JSON.stringify(d)).join(", ")} `
665
665
  + `${dropped.length === 1 ? "is not a territory" : "are not territories"} the engine can search, `
666
666
  + `so storing ${dropped.length === 1 ? "it" : "them"} would be a default that silently does `
667
- + `nothing. Use a country name, or a two-letter code such as US, GB or EU.`);
667
+ // NAME THE RULE THAT FIRED, and this is the sentence a person actually reads — the panel renders
668
+ // THIS, not the field contract's wording, so correcting that one and not this one left the
669
+ // refusal telling a reader to do what they had just done. The rule is not the shape of the entry:
670
+ // an unknown two-letter code is refused by a sentence that used to ask for a two-letter code.
671
+ + `nothing. Use a country name, or one of the two-letter codes the engine holds, such as US, GB or EU.`);
668
672
  }
669
673
  }
670
674
  return { ok: errors.length === 0, errors };
@@ -41,9 +41,9 @@
41
41
  // CORRECTED 2026-08-11 — THIS PARAGRAPH USED TO SAY "one qid per office … which is the shape
42
42
  // joinPlanToBands already has", AND THAT WAS FALSE ABOUT THE COMPILER IT DOCUMENTS. There is no
43
43
  // per-office qid. compileRegisterPlan narrows ONE shared `regions` array and hands it to every entry
44
- // (register-plan.mjs:550); the unreachable office produces no entry, so no qid, so no band block, so
44
+ // (register-plan.mjs:572 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
45
45
  // nothing ever reaches joinPlanToBands' deferred bucket — whose only source is a block stamped
46
- // `error:true && deferred:true` (register-plan.mjs:1093).
46
+ // `error:true && deferred:true` (register-plan.mjs:1115 extendRegisterPlan).
47
47
  //
48
48
  // The consequence was not academic. `deferred_coverage` rode the plan and nothing that a reader sees
49
49
  // read it: coverage-form.mjs seeded its deferred rows from skeleton qids alone, so an EU+US matter on a
@@ -260,6 +260,28 @@ export const planMaxOrWidth = (capabilities) =>
260
260
  // The plan emits ONE `wildcard` predicate; the provider contract splits it into three sub-capabilities.
261
261
  // Mirrors the executor's planPredicateParams anchoring exactly (trailing * → prefix/starts-with,
262
262
  // leading * → suffix/ends-with, both/neither → infix over the raw pattern).
263
+ //
264
+ // ── WHICH ANCHOR, NEVER WHETHER THE TERM IS A PATTERN ────────────────────────────────────────────────
265
+ //
266
+ // This function and `termPredicateIssue` (providers/_shared/term-shape.mjs) were read as contradicting
267
+ // each other: this one maps an unanchored term to `wildcardInfix` — a real, dispatchable capability —
268
+ // while that one calls a wildcard term carrying no pattern syntax a compiler bug. They are not in
269
+ // conflict, because they are keyed on different things and answer different questions:
270
+ //
271
+ // this function keys on `*` ALONE, because `*` is what the executor anchors on, and answers
272
+ // WHICH of the three sub-capabilities a provider must declare.
273
+ // termPredicateIssue keys on `[*?]`, and answers WHETHER the term is a pattern at all.
274
+ //
275
+ // `?` is the case that shows the two apart rather than merely asserting they differ. The
276
+ // form-neighbourhood generator mints vowel-slot patterns like `sk?` and `v?ltr?n`: starless, so this
277
+ // function correctly calls them infix, and full of pattern syntax, so termPredicateIssue correctly
278
+ // passes them. They are dispatched, and they must stay dispatchable. Keying this function on `[*?]`
279
+ // to make the two "agree" would desync it from planPredicateParams and misroute every `?` pattern.
280
+ //
281
+ // A term carrying NEITHER `*` NOR `?` is the only string both functions see, and it is one that must
282
+ // never reach the compiler at all: skeletonPatterns no longer emits one (see the note there). This
283
+ // function keeps no opinion about it, which is correct — asking an anchoring mirror to police
284
+ // well-formedness is what would put the two definitions in genuine conflict.
263
285
  export function wildcardCapabilityKey(term) {
264
286
  const t = String(term ?? "");
265
287
  if (t.endsWith("*") && !t.startsWith("*")) return "wildcardPrefix";
@@ -305,3 +305,36 @@ export function channelsDiagnosis(md) {
305
305
  // empty `offered` — which is honest, because the seat did write the line.
306
306
  return { state: channels.length ? "named" : "all-rejected", channels, offered, rejected };
307
307
  }
308
+
309
+ /**
310
+ * THE CHANNELS THE OFF-REGISTER GRID ACTUALLY RUNS, for any profile.
311
+ *
312
+ * The grid used to search the customer profile's storefronts whatever the matter was about. A delivered
313
+ * global preliminary carried 45 terms across 8 platforms, and the 8 were the profile's list; the matter
314
+ * frame had named the matter's own ecosystem — server hosts, a plugin repository, a competing platform —
315
+ * and none of it reached the grid, because nothing carried a frame-named channel into it. The reviewing
316
+ * lawyer graded a plugin published on a repository the run never queried.
317
+ *
318
+ * THE FRAME'S CHANNELS ARE ADDED, NOT SUBSTITUTED, for a named profile. That profile's platform list is
319
+ * the client's own mandate — it is what they told us to sweep — so it stands, and what the frame names
320
+ * about this matter runs beside it.
321
+ *
322
+ * THE GENERIC PROFILE STILL REPLACES, and that is a decision rather than an inconsistency. Generic's
323
+ * platforms are a house default, not anybody's mandate, and the reason the replacement exists is that a
324
+ * regulated or B2B matter must not be forced onto consumer storefronts and then read as swept. A union
325
+ * there would put the storefronts back into exactly the runs that branch was written to keep them out of.
326
+ *
327
+ * Order is profile-first so an existing run's cells keep their place in the spec, and the frame's
328
+ * additions follow. Deduplicated on the normalised value: a frame naming a platform the profile already
329
+ * carries adds a sentence, never a second cell. PURE.
330
+ *
331
+ * @returns {{channels: string[], added: string[], diag: object}}
332
+ */
333
+ export function gridChannels({ profilePlatforms = [], profileKey = null, matterMd = "" } = {}) {
334
+ const diag = channelsDiagnosis(matterMd);
335
+ const base = (profilePlatforms ?? []).filter(Boolean);
336
+ if (profileKey === "generic") return { channels: diag.channels.length ? diag.channels : base, added: [], diag };
337
+ const seen = new Set(base.map((p) => String(p).trim().toLowerCase()));
338
+ const added = diag.channels.filter((c) => !seen.has(String(c).trim().toLowerCase()));
339
+ return { channels: [...base, ...added], added, diag };
340
+ }
@@ -498,9 +498,11 @@ For every Consumer-confusion-risks row that is a **game title** (Steam game, Mic
498
498
 
499
499
  **Extraction sources, in priority order:**
500
500
  1. The Perplexity result's explicit "developed by X" / "published by Y" statement, when present
501
- 2. The platform store page's developer / publisher metadata (Steam, Microsoft Store, App Store all expose these as structured fields)
501
+ 2. **The platform's own record of the thing** — whatever that platform publishes about who made it, when it appeared and how large it is. A store's developer / publisher metadata is the familiar case (Steam, Microsoft Store and the app stores expose these as structured fields), and it is an EXAMPLE rather than the set: a platform that is not a store still keeps a record, in page metadata or on a public endpoint of its own, and it is read the same way. Open it before writing that the owner is unknown.
502
502
  3. The game's Wikipedia article's infobox developer / publisher row, when the platform page is ambiguous
503
503
 
504
+ **"Not extracted" is written with the source OPEN and the field genuinely absent.** A delivered run held a competing platform's own page address for a title, wrote that no creator account behind it could be identified, and carried no launch date and no scale — while that platform published the creator, the creation date and the visit count, unauthenticated, to anyone who asked. The reviewing lawyer named the studio, the month and the visit count, and rated the game inactive on them. The pass had treated a non-store platform as unreadable because this list named only stores. Record what you read, including the reading that came back empty; `not extracted` after a look is a finding, and `not extracted` instead of a look is a gap the reader has to close by hand.
505
+
504
506
  **When the attribution cannot be extracted:**
505
507
  - Write **`not extracted`** as the field value
506
508
  - Add the candidate to the Open verification flags section with the specific gap
@@ -205,7 +205,7 @@ For each material finding, document (in the Findings sheet's Key Factors column)
205
205
  1. **Factual basis:** what was found, on which layer(s), how significant
206
206
  2. **The LEGAL read — the claim's strength net of credible merits defences.** It answers one question — *if this party sued us, would they win?* — so it is the probability the claim **prevails**, assessed **net of the client's credible merits defences** and stated **with its bounds**. **State that probability in plain words, in the vocabulary THE FRAMEWORK IN FORCE uses** — read its Legal position definitions and answer in their terms, whether that is a likelihood-of-winning ladder, a probability band or a lettered level. These words are what that framework's band definitions key on, and the read and the band are one judgment that may not disagree (Reasoning posture 4: *the band follows the words*). If your reasoning reaches "better-than-even is not reached" / "distinguishable as wholes" / "confusion is unlikely", the read is the client-favoured one — and a crowded field is a ceiling that lets you reach it, never a floor that parks you above it. Start from the whole-mark confusion read (marks compared *as wholes*, centred on the dominant element, × G&S proximity — see *The consumer confusion test* below); element-overlap alone is not a legal read. Then **price in, by name, the merits defences that bear on whether the claim prevails** — descriptiveness / weak distinctiveness of the senior mark, the opponent's own undermining conduct (their own descriptive or off-field use of the term — from the Step 3.6 workup), **no real commercial / manner-of-use overlap between our actual use and theirs** (judged against the senior right's own scope — a registered senior meets us on its mark and G&S **as registered**, a common-law senior on its **actual trade**: our use is an in-product / descriptive label vs their standalone brand, or the two sit in genuinely different commercial fields — the goods-half of the confusion test, so it lowers the read; the senior's *current presentation / trade dress* never narrows its registered scope — that is Stage-2 enforcement reality), the client's descriptive / fair-use defence, and relative priority. The **gross identity read** ("identical mark, identical class, prior filing — direct identity on the register") is stated *inside* the finding as the starting observation; it is **not** the headline read. The headline carries the **net** strength **plus its bounds** — the jurisdiction and time it holds in, and the named triggers that would move it (e.g. "more likely than not to prevail — Germany only, only while the application remains live; rises if [named trigger]"). This **replaces** a gross-identity assignment — it does not footnote the defences under a maximal headline. **Practical** factors (actual use, revocability, enforcement appetite, coexistence, the senior's own market presentation / trade dress) are *not* merits — they sit in the business read (item 4) and enter the band through the framework's own **Practical position** definitions (or its matrix inputs where it states one); they never move the legal read directly. **Name the theory setting the read.** If the mark is distinguished by its own distinctive matter (a house mark or distinctive prefix), that distinguished read is the ceiling — any escalation above it must be a named consumer-confusion theory (e.g. evidence of actual confusion). Optics, PR, partner-sensitivity, audience overlap, and owner size are **not** confusion theories: they annotate (Reasoning posture 3), they never raise the read.
207
207
  3. **Business / practical read (separate from the legal read):** actual use, revocation-vulnerability, owner enforcement appetite, coexistence likelihood, channels, commercial-relationship / partnership context — the practical exposure stated *beside* the legal read, never folded into it. **For high-risk findings, this read must carry a practical-likelihood statement — how likely this owner is to actually create a problem — grounded in the Step 3.6 owner workup (their own use of the term, portfolio under owner-name variants, enforcement posture), not inferred from the register row alone.**
208
- 4. **The BAND — reasoned through the framework in force.** Take the legal read (item 2) and the practical read (item 3) through the framework's own band definitions — its Legal position / Practical position / Potential outcomes per band, or its Level × Dispute Type matrix where it states one — and state the band **word**, verbatim from that framework. The two reads move the framework's *inputs*; its stated method yields the band, honouring any ceilings it states — a practical or optics factor never lifts the band past what the framework's method produces. A clear win with no material risk yields **no band** (not a rated conflict — Reasoning posture 4).
208
+ 4. **The BAND — reasoned through the framework in force.** Take the legal read (item 2) and the practical read (item 3) through the framework's own band definitions — its Legal position / Practical position / Potential outcomes per band, or its Level × Dispute Type matrix where it states one — and state the band **word**, verbatim from that framework. The two reads move the framework's *inputs*; its stated method yields the band, honouring any ceilings it states — a practical or optics factor never lifts the band past what the framework's method produces. A clear win with no material risk yields **no band** (not a rated conflict — Reasoning posture 4), and so does a conflict whose proprietor you could not identify on the record searched: with no party there is no legal read to take through the framework, so it is carried as an open item naming the identification step, never rated.
209
209
  5. **Elevation / mitigation factors observed** (if any) — tag each as bearing on the legal read or the business read (the factor lists live under *Firm-wide reasoning discipline* below).
210
210
  6. **Source-layer note:** which layer(s) surfaced this; whether cross-pollination ran for it
211
211
  7. **Client prior-use adjacency check:** examine the request form's "Manner of Use" and "Additional Information" fields for evidence of prior use of the proposed mark by the client. If found, apply the *client prior-use rule* (under Firm-wide reasoning discipline below; adjacency tiers — same/adjacent-goods/adjacent-industry/none) to weigh the applicant's prior-use defence on the **business** read. When same-goods or adjacent-goods prior use exists, headline-frame it in the narrative summary's scope statement (not just in the per-finding Key Factors). Example framing: "the applicant may own common-law rights in the mark for [its prior goods], or at least a right to continued use for those goods and highly similar ones." **When a senior conflict makes priority live, resolve the *filing* branch instead of punting it:** consume the applicant own-rights sweep (`prelim-register/digest.md` Step 4 — rows tagged `applicant_own_rights`) and state the client's own filing/footprint position as a **separate "client's own prior rights" note** (e.g. "no prior client filing predating [conflict] was found" or "the client holds earlier filing [X] covering [Y]"). This note is **never a conflict / Findings-sheet row, never gates or down-rates the conflict sweep, and never feeds the rating** — it surfaces as an own-rights note / `# Actions` line (per `delivery-contract.md`). The *internal / undocumented-use* branch is invisible to any external search, so leave it as a one-line client question, framed as such ("confirm any unregistered prior use of the mark by the client").
@@ -569,6 +569,8 @@ For every common-law game-title finding (Steam title, Microsoft Store title, App
569
569
 
570
570
  The `narrative-refutation` gate (Phase 2 Step 4.7) blocks delivery if the narrative contains a named publisher / developer not traceable to `common-law-findings.md`'s extraction.
571
571
 
572
+ **A conflict whose proprietor cannot be identified on the record searched has no band at all.** Not a low one: the framework rates a legal read, and a legal read needs a party — who holds the right, what they have done with it, whether they would act. With the owner unknown none of that can be reasoned, so a band placed anyway is a number standing in for the work of finding out, and it reads to the client as though somebody had looked. A delivered run rated such a conflict Low as "distinguished on the mark itself" while its own off-register pass had met the title four times without ever looking it up, and the pending family behind it was found later by the reviewing lawyer. Carry it in the actions as an open item that names the identification step — who the proprietor is, and what is pleaded — and leave it out of the rated findings and out of the reasoned negatives, which are the two places a reader takes as settled.
573
+
572
574
  ## Registration metadata — copy, never restate
573
575
 
574
576
  Same anti-laundering discipline as common-law attribution above, applied to **register records**: a registration fact is **copied verbatim from the fetched record, never restated from memory.** This covers the **registration number, application/serial number, filing date, registration date, expiry date, Nice classes, and live/dead status.**
package/driver/stages.mjs CHANGED
@@ -1359,7 +1359,7 @@ export const STAGES = {
1359
1359
  // EACH FIELD CARRIES ITS OWN IMPERATIVE IN ITS OWN SENTENCE (: a field phrased outside one was
1360
1360
  // written 0 of 9 times against 74 of 74 when imperative-carried).
1361
1361
  `Hand the frame back by calling the \`record_matter_frame\` tool. Send \`prose_body\` — the commercial read of the matter in full prose: client, sector, product description, customer base, channels of trade, off-field sectors, sector-convergence flags, watchlist-owner seeds, your scope reasoning, the class scope and adjacency call with a one-line reason per class, the applicant's own and affiliated marks, and the campaign shape where you are inferring one (label an inference as an inference).`,
1362
- `Send \`scope_basis\` as "instructed" or "derived", with \`scope_jurisdictions\` and \`excluded_jurisdictions\` as arrays of territories.`,
1362
+ `Send \`scope_basis\` as "instructed" or "derived", with \`scope_jurisdictions\` and \`excluded_jurisdictions\` as arrays of territories. Send \`identified_classes\` — the Nice classes you judge NECESSARY that the request did NOT instruct, each as {class, reason}, the class a whole number 1-45 and the reason one line. These are searched for every variant, not only the exact name, so name a class only where you would defend charging for it; omit the field or send an empty array where the instructed classes are the whole scope, which is the ordinary answer and adds nothing.`,
1363
1363
  // The driver STAMPS the instructed-scope section from _driver/instructed-scope.json, so the seat is
1364
1364
  // not asked to quote back values the driver wrote at intake. That retyping was the stage's
1365
1365
  // `frame_scope_missing` loop and it is gone; see matter-frame-record.mjs.
@@ -4613,6 +4613,15 @@ export function whatsappRouting(job, agentId) {
4613
4613
  const who = email || handle || "the requester";
4614
4614
  return {
4615
4615
  whatsappTo: to,
4616
+ // THE CHANNEL, BESIDE THE NUMBER, because a courier's messaging tool refuses a send that does not
4617
+ // name one as soon as the assistant has more than one channel configured — "Channel is required
4618
+ // when multiple channels are configured: msteams, whatsapp" — and the notice is then composed,
4619
+ // addressed and discarded on one log line. The courier sheet said "send it there" and named no
4620
+ // channel, so the instruction was correct for an assistant with exactly one and silently wrong for
4621
+ // any other. A field is harder to lose than a sentence: the packet now states which channel this
4622
+ // route is on, and the sheet tells the courier to pass it. It rides into the delivery packet by
4623
+ // construction — pipeline.mjs and pipeline-knockout.mjs both spread this object into it.
4624
+ whatsappChannel: "whatsapp",
4616
4625
  whatsappToReason: to ? null : `no chat number is held for ${who} — set one in CLEAROTRON_REQUESTER_WHATSAPP to notify them`,
4617
4626
  whatsappCcOperator: OPERATOR_WHATSAPP_COPY ? (AGENT_WHATSAPP[agentId] ?? null) : null,
4618
4627
  };