clearotron 0.3.3-beta.1 → 0.4.0-beta.0

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 (44) hide show
  1. package/INSTALL.md +1 -14
  2. package/bin/brandowner.mjs +5 -5
  3. package/bin/connect.mjs +4 -4
  4. package/bin/onboard.mjs +5 -7
  5. package/bin/start.mjs +20 -5
  6. package/build-info.json +2 -2
  7. package/docs/releases/0.3.3.md +52 -0
  8. package/driver/CHANGELOG.md +62 -0
  9. package/driver/company-bundle.mjs +11 -18
  10. package/driver/door-call-verdict.mjs +27 -0
  11. package/driver/driver.config.mjs +1 -2
  12. package/driver/package.json +1 -1
  13. package/driver/pipeline.mjs +14 -5
  14. package/driver/portal-mcp-client.mjs +1 -1
  15. package/driver/portal-request-origin.mjs +79 -0
  16. package/driver/portal-service.mjs +43 -15
  17. package/driver/profile-page.html +9 -13
  18. package/driver/profile-service.mjs +25 -13
  19. package/driver/profiles.mjs +17 -4
  20. package/driver/publish/index.mjs +13 -1
  21. package/driver/publish/render.mjs +13 -2
  22. package/driver/publish/search-depth.mjs +4 -2
  23. package/driver/run-economics.mjs +7 -18
  24. package/driver/stages.mjs +1 -1
  25. package/driver/suite-census.json +61 -31
  26. package/driver/tokens.mjs +18 -10
  27. package/mcp-server/CHANGELOG.md +8 -0
  28. package/mcp-server/lib/audit.mjs +9 -2
  29. package/mcp-server/lib/http-handler.mjs +7 -3
  30. package/mcp-server/mint-token.mjs +8 -6
  31. package/mcp-server/package.json +1 -1
  32. package/mcp-server/server.mjs +10 -0
  33. package/package.json +1 -1
  34. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-DVtz44vH.js} +57 -8
  35. package/portal-ui/dist/index.html +1 -1
  36. package/portal-ui/package.json +1 -1
  37. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  38. package/providers/oauth-mcp-bridge/package.json +1 -1
  39. package/scripts/freeze-example-run.mjs +1 -1
  40. package/scripts/live-surface-check.mjs +11 -2
  41. package/scripts/release-entry-catch-up.mjs +142 -0
  42. package/shared/client-door.mjs +15 -8
  43. package/shared/scope.mjs +25 -10
  44. package/shared/store-in-repo.mjs +38 -17
@@ -168,6 +168,7 @@ import { engineCommit } from "./engine-build.mjs"; // — the S
168
168
  import { engineCommitDate, engineProvenance } from "./engine-build.mjs";
169
169
  import { classifySkillsStore } from "./skills-store-provenance.mjs";
170
170
  import { makeStaticHandler, reportCsp, docCsp } from "./portal-static.mjs";
171
+ import { crossSiteReason, bodyNotJson } from "./portal-request-origin.mjs";
171
172
  import { bundleVerdict, healthUi } from "../shared/bundle-freshness.mjs"; // one definition of a usable bundle, shared with `doctor`
172
173
  import { readReport, reportsOf, resolveReportFile, batchSummaryOf } from "./portal-report.mjs";
173
174
  import { readArchivedSet, updateArchived } from "./publish/archive-tags.mjs";
@@ -1142,7 +1143,7 @@ export function makeConnectorKeyRevoker({ env = process.env, home = null } = {})
1142
1143
  const base = home ?? homedir();
1143
1144
  const recorded = recordedKeysFor(g, email);
1144
1145
  const plan = disablePlan({ env, unitDir: join(base, ".config", "systemd", "user"), exists: existsSync,
1145
- identity: email, recorded, denylistPath: denylistPathFor(env, base) });
1146
+ identity: email, recorded, denylistPath: denylistPathFor(env, base), home: base });
1146
1147
  if (!plan.possible) return { grants: g, revoked: 0, jtis: [], lateArm: false, says: plan.says };
1147
1148
  if (plan.lateArm) return { grants: g, revoked: 0, jtis: plan.jtis, lateArm: true, says: plan.says ?? [] };
1148
1149
  // THE RECORD IS NEVER STRUCK FROM HERE, and the ledger step is dropped rather than no-opped.
@@ -1614,6 +1615,7 @@ export function makePortalService({
1614
1615
  // identity gets {} here exactly as it does for names and keeps reading the staff-only roster.
1615
1616
  let accountNames = {};
1616
1617
  let accountFacts = {};
1618
+ let meRoster = null; // read once for this answer: the names below and the house marketplaces
1617
1619
  // GENERIC IS NAMED TOO when the person sees an organisation's Generic. It is not a company, so it is
1618
1620
  // never in `accounts`, and without a name here the switcher printed the raw key at everyone below
1619
1621
  // everything. An organisation with no company yet is the case with an empty `accounts`, so the
@@ -1623,7 +1625,7 @@ export function makePortalService({
1623
1625
  if (named.length) {
1624
1626
  try {
1625
1627
  const { companyFactsOf } = await import("./profiles.mjs");
1626
- const profiles = await loadProfilesImpl();
1628
+ const profiles = meRoster = await loadProfilesImpl();
1627
1629
  for (const key of named) {
1628
1630
  const profile = profiles.get(key);
1629
1631
  const name = profile?.name;
@@ -1662,9 +1664,19 @@ export function makePortalService({
1662
1664
  // READ ONCE. The payload names it and the program reading below is gated on it; two calls to
1663
1665
  // `flagView` here would be two reads of the same file that could disagree with each other.
1664
1666
  const meEngineMode = flagView(poolRoot).engineMode;
1667
+ // THE HOUSE MARKETPLACES, offered as suggestions wherever a company's marketplaces are edited. A new
1668
+ // company starts with none (the owner's ruling of 2026-09-23) and somebody picks; these are the
1669
+ // Generic default's own list, read from the store so an install that changed it offers its own.
1670
+ // Deployment-wide and the same for everyone, like concurrentRuns. [] when the store cannot be read:
1671
+ // the box still takes any marketplace typed into it.
1672
+ let houseMarketplaces = [];
1673
+ try {
1674
+ const house = (meRoster ?? await loadProfilesImpl()).get("generic")?.platforms;
1675
+ if (Array.isArray(house)) houseMarketplaces = house.filter((d) => typeof d === "string" && d.trim());
1676
+ } catch { /* a suggestion list is a nicety; the door is not */ }
1665
1677
  return { status: 200, json: { email: principal.email, ...principalView(principal, grantsHere, accountNames),
1666
1678
  accounts: principal.accounts, accountNames, accountFacts,
1667
- concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode,
1679
+ concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode, houseMarketplaces,
1668
1680
  // WHERE A PERSON ASKS FOR A CHANGE TO THEIR SIGN-IN, beside the brand and read where it is read:
1669
1681
  // an href Preferences links "Clearotron administrator" to, or null, and then the words are plain.
1670
1682
  administratorContact: ADMINISTRATOR_CONTACT,
@@ -3267,15 +3279,12 @@ async function connectorDoorKind(url) {
3267
3279
  const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
3268
3280
  if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
3269
3281
  } catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
3270
- // WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL, read the same way `disablePlan` reads it: the
3271
- // door loaded a revocation list at start, or it did not and never will for the keys already
3272
- // out. The variable's PRESENCE is the whole question — its value is a path, and this route
3273
- // must not say where.
3274
- // WHETHER A REVOCATION LIST IS NAMED FOR THIS PROCESS AT ALL — which is not the same question
3275
- // as whether the connector loaded one, and the page's words are careful about the difference.
3276
- // The connector is a separate unit with its own environment; this answers only for here.
3277
- const keysRevocable = Boolean(revokeConnectorKeys)
3278
- && String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim() !== "";
3282
+ // WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL. This used to be whether a revocation list was
3283
+ // named in this process's environment, because a door with none named checked no list. Every door
3284
+ // now reads the install's default list when none is named (isRevoked, shared/scope.mjs), and that
3285
+ // is where a revocation from here is written, so keys can be withdrawn whenever this process has
3286
+ // a revoker at all.
3287
+ const keysRevocable = Boolean(revokeConnectorKeys);
3279
3288
  return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn, keysRevocable }) };
3280
3289
  }
3281
3290
  // /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
@@ -3641,9 +3650,10 @@ export function accountCapAdvice(posture) {
3641
3650
  }
3642
3651
  return "Re-mint with `mcp-server/mint-token.mjs --scope ops"
3643
3652
  + `${posture.sub ? ` --sub ${posture.sub}` : ""}`
3644
- // NO `--verbs` FLAG AT ALL when the token is full-ops: `verbs: null` means EVERY verb, and naming a
3645
- // list there would be the narrowing this whole function exists to stop.
3646
- + `${posture.verbs ? ` --verbs ${posture.verbs.join(",")}` : ""}`
3653
+ // A FULL-OPS TOKEN (`verbs: null`) IS RE-ISSUED WITH THE PORTAL'S OWN VERBS. It used to get no
3654
+ // `--verbs` at all, so as not to narrow it; mint-token now refuses an ops token that names none, so that
3655
+ // command would fail, and re-issuing verb-less tokens with verbs is the point. The portal calls these two.
3656
+ + ` --verbs ${(posture.verbs ?? ["start_run", "stop_run"]).join(",")}`
3647
3657
  + " --accounts <keys>` to make it two walls.";
3648
3658
  }
3649
3659
 
@@ -4288,6 +4298,18 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
4288
4298
  });
4289
4299
  }
4290
4300
 
4301
+ // ── A STATE CHANGE FROM ANOTHER SITE IS REFUSED BEFORE ANYTHING ELSE LOOKS AT IT ────────────────
4302
+ //
4303
+ // Before identity, so the sign-in and sign-out forms are covered as well as the API: a page elsewhere
4304
+ // that signs a browser in to an account of its choosing is the same attack as one that acts under the
4305
+ // browser's own session. The rule and why it compares hosts is in portal-request-origin.mjs.
4306
+ const crossSite = crossSiteReason(req);
4307
+ if (crossSite) {
4308
+ log(`refused a ${req.method} to ${url.pathname} from another site: ${crossSite}`);
4309
+ journal(403, "cross-site request");
4310
+ return send(res, 403, { error: "cross_site" });
4311
+ }
4312
+
4291
4313
  // ──: THE LOCAL SIGN-IN DOOR — before identity, because it is how identity is obtained ─────
4292
4314
  //
4293
4315
  // Mounted only when the local identity provider is running. On a Cloudflare-fronted instance
@@ -4420,6 +4442,12 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
4420
4442
  journal(429, "rate_limited", identity?.email);
4421
4443
  return send(res, 429, { error: "rate_limited" });
4422
4444
  }
4445
+ // A BODY THAT DOES NOT SAY IT IS JSON IS NOT READ AS JSON. A form post is the one body another site
4446
+ // can send without asking first; the portal's own pages always declare JSON when they send a body.
4447
+ if (bodyNotJson(req)) {
4448
+ journal(415, "body is not JSON", identity?.email);
4449
+ return send(res, 415, { error: "unsupported_media_type" });
4450
+ }
4423
4451
  // A FIXED reason, not `e.message`. The parser's message is about the bytes it was given, and this
4424
4452
  // row must not become a place a caller can write into by sending a body that fails to parse.
4425
4453
  let body;
@@ -214,7 +214,7 @@ function renderEditor(key, p, contextPack, derived, framework, isNew) {
214
214
  $("editor").innerHTML = `
215
215
  ${isNew ? f("Customer key"+REQ, "f_key", "", `<span class="reqtag">Required.</span> A short lowercase id — letters, numbers and hyphens only. It becomes the customer's filename and can't be changed later.`, "e.g. acme-health") : `<div class="ro">Editing <b><span data-anon="mark" data-anon-key="${esc(key)}">${esc(key)}</span></b></div>`}
216
216
  ${f("Customer name"+REQ, "f_name", p.name||"", `<span class="reqtag">Required.</span> The customer's name. We use it to recognise their own marks so we never flag the customer against itself.`, undefined, true)}
217
- ${ta("Marketplaces and registers to search"+REQ, "f_platforms", (p.platforms||[]).join("\n"), `<span class="reqtag">Required — at least one.</span> One website per line (e.g. amazon.com). These are where we look for real-world use of a mark. Don't add 'web' — the general web is always searched.`, "", true)}
217
+ ${ta("Marketplaces and registers to search", "f_platforms", (p.platforms||[]).join("\n"), `One website per line (e.g. amazon.com). These are where we look for real-world use of a mark. Don't add 'web' — the general web is always searched.`, "", true)}
218
218
  ${coverageNote}
219
219
  <details class="more"${advancedSet?" open":""}>
220
220
  <summary>More options</summary>
@@ -344,18 +344,15 @@ function clientValidate({ key, profile }) {
344
344
  else if (!KEY_RE.test(key)) problems.push({ id:"f_key", message:"The customer key can only use lowercase letters, numbers and hyphens (2–39 characters)." });
345
345
  }
346
346
  if (!profile.name) problems.push({ id:"f_name", message:"Enter the customer’s name." });
347
+ // An empty list is valid: a company may pick no marketplaces (the owner's ruling of 2026-09-23).
347
348
  const plats = profile.platforms || [];
348
- if (!plats.length) {
349
- problems.push({ id:"f_platforms", message:"Add at least one marketplace or register to search — a website like amazon.com, one per line." });
350
- } else {
351
- const seen = new Set();
352
- for (const x of plats) {
353
- const dom = x.toLowerCase();
354
- if (dom === "web") { problems.push({ id:"f_platforms", message:"Don’t list “web” — the general web is always searched. Remove it." }); continue; }
355
- if (!DOMAIN_RE.test(dom) || /\s/.test(dom)) { problems.push({ id:"f_platforms", message:`“${x}” doesn’t look like a website address — use a domain like amazon.com.` }); continue; }
356
- if (seen.has(dom)) { problems.push({ id:"f_platforms", message:`“${x}” is listed twice.` }); continue; }
357
- seen.add(dom);
358
- }
349
+ const seen = new Set();
350
+ for (const x of plats) {
351
+ const dom = x.toLowerCase();
352
+ if (dom === "web") { problems.push({ id:"f_platforms", message:"Don’t list “web” — the general web is always searched. Remove it." }); continue; }
353
+ if (!DOMAIN_RE.test(dom) || /\s/.test(dom)) { problems.push({ id:"f_platforms", message:`“${x}” doesn’t look like a website address — use a domain like amazon.com.` }); continue; }
354
+ if (seen.has(dom)) { problems.push({ id:"f_platforms", message:`“${x}” is listed twice.` }); continue; }
355
+ seen.add(dom);
359
356
  }
360
357
  return problems;
361
358
  }
@@ -366,7 +363,6 @@ function prettyServerError(msg) {
366
363
  const M = String(msg||"");
367
364
  const MAP = [
368
365
  [/name \(string\) is required/i, "f_name", "Enter the customer’s name."],
369
- [/platforms must be a non-empty array/i, "f_platforms", "Add at least one marketplace or register to search — a website like amazon.com, one per line."],
370
366
  [/must not list "web"/i, "f_platforms", "Don’t list “web” — the general web is always searched. Remove it."],
371
367
  [/must be a bare store DOMAIN/i, "f_platforms", "One of the marketplaces isn’t a website address — use domains like amazon.com, one per line."],
372
368
  [/is duplicated/i, "f_platforms", "A marketplace is listed twice — remove the duplicate."],
@@ -176,6 +176,22 @@ function bandsBandRows(deck, manifest) {
176
176
  return out;
177
177
  }
178
178
 
179
+ // NO STORE PATH AND NO LOAD-LOG WORDING REACHES A BROWSER. The profile validator opens every sentence with
180
+ // the file it judged (`profiles/<key>.json: `), which is right in a terminal and in the load log and is a
181
+ // server path on the company page, so the door takes the prefix off. The three marketplace-entry refusals
182
+ // are then worded for the browser too: their load-log words ("the grid program domain-restricts to it",
183
+ // "inflates the floor") are engine vocabulary, and the staff profile editor already carries a sentence for
184
+ // each (profile-page.html's MAP), copied here unchanged. Every other sentence passes as the validator wrote it.
185
+ const BROWSER_WORDING = [
186
+ [/^platforms must not list "web"/, "Don’t list “web” — the general web is always searched. Remove it."],
187
+ [/^platforms entry .* must be a bare store DOMAIN/, "One of the marketplaces isn’t a website address — use domains like amazon.com, one per line."],
188
+ [/^platforms entry .* is duplicated/, "A marketplace is listed twice — remove the duplicate."],
189
+ ];
190
+ export const forBrowser = (errors) => (errors ?? []).map((e) => {
191
+ const bare = String(e).replace(/^profiles\/[^\s:]*\.json: /, "");
192
+ return BROWSER_WORDING.find(([re]) => re.test(bare))?.[1] ?? bare;
193
+ });
194
+
179
195
  /**
180
196
  * A refusal, worded for the person in the browser.
181
197
  *
@@ -200,18 +216,14 @@ export function browserRefusal(e) {
200
216
  return { error: "refused", code: "domain_claimed", domain: d.domain, heldBy: d.heldBy,
201
217
  message: `${d.domain} is already used by another company. An address can only belong to one, `
202
218
  + `because the engine uses it to tell them apart. Remove it here, or take it off the other company first.` };
203
- case "no_marketplaces":
204
- return { error: "refused", code: "no_marketplaces",
205
- message: "This installation has no default marketplaces set up, so there is nothing to search. "
206
- + "Add at least one marketplace for this company, or ask an administrator to set the defaults." };
207
219
  case "framework_missing":
208
220
  return { error: "refused", code: "framework_missing",
209
221
  message: "The risk framework this company was pointed at is not on this installation, so nothing "
210
222
  + "was created. Rating a company under a framework nobody chose is worse than refusing." };
211
223
  case "invalid_bundle":
212
224
  // The validator collects, so these are already per-field sentences written for a reader.
213
- return { error: "refused", code: "invalid_bundle", errors: d.errors ?? [],
214
- message: (d.errors ?? []).join(" ") || String(e.message) };
225
+ return { error: "refused", code: "invalid_bundle", errors: forBrowser(d.errors),
226
+ message: forBrowser(d.errors).join(" ") || String(e.message) };
215
227
  default:
216
228
  return { error: "refused", message: String(e.message) };
217
229
  }
@@ -439,13 +451,12 @@ export function makeProfileService({
439
451
  try { assertProfileKey(wanted); }
440
452
  catch (e) { return { status: 400, json: { error: "bad_key", message: String(e.message) } }; }
441
453
 
442
- const existing = resolveExisting();
443
454
  let framework, platforms, profile;
444
455
  try {
445
456
  // ALWAYS SET, NEVER ABSENT — the company's own when one is supplied, the house default named
446
457
  // otherwise. This is the whole reason a create does not go through save.
447
458
  framework = resolveFramework(isStr(body?.frameworkPath) ? body.frameworkPath : null);
448
- platforms = resolvePlatforms(Array.isArray(body?.platforms) ? body.platforms : null, existing);
459
+ platforms = resolvePlatforms(Array.isArray(body?.platforms) ? body.platforms : null);
449
460
  profile = buildProfile({
450
461
  key: wanted, name,
451
462
  domains: Array.isArray(body?.matchDomains) ? body.matchDomains : [],
@@ -511,7 +522,8 @@ export function makeProfileService({
511
522
  // The receipt reads off THESE, so it can say which framework and how many marketplaces, and
512
523
  // whether each was chosen or defaulted, rather than restating what was typed.
513
524
  framework: { path: framework.path, defaulted: framework.source === "default" },
514
- marketplaces: { count: platforms.platforms.length, defaulted: platforms.source !== "supplied" },
525
+ // Never defaulted any more: a company created without marketplaces has none (resolvePlatforms).
526
+ marketplaces: { count: platforms.platforms.length, defaulted: false },
515
527
  ...(commitError ? { commitError: `created and LIVE, but the git commit failed (${commitError}) — the audit line records the gap` } : {}),
516
528
  } };
517
529
  }
@@ -621,14 +633,14 @@ export function makeProfileService({
621
633
  const v = validateProfileEdit(errKey, overlayBody, ctx, { sparse: true });
622
634
  let keyError = null;
623
635
  if (!exists) { try { assertProfileKey(project); } catch (e) { keyError = String(e.message); } }
624
- const errors = keyError ? [keyError, ...v.errors] : v.errors;
636
+ const errors = forBrowser(keyError ? [keyError, ...v.errors] : v.errors);
625
637
  return { status: 200, json: { ok: errors.length === 0, errors, isNew: !exists } };
626
638
  }
627
639
 
628
640
  // save — the validated AUTO-COMMIT write (sparse validators; customer-only keys 400 here)
629
641
  if (!exists) { try { assertProfileKey(project); } catch (e) { return { status: 400, json: { error: String(e.message) } }; } }
630
642
  const v = validateProfileEdit(errKey, overlayBody, ctx, { sparse: true });
631
- if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: v.errors } };
643
+ if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: forBrowser(v.errors) } };
632
644
  // The write lands FIRST (atomic temp+rename); a git failure after it must never hide the mutation
633
645
  // behind a 500 — the overlay is LIVE the moment it is renamed (every loadProjects door reads
634
646
  // force-fresh), so the response says written:true with the commit error named, and the audit line
@@ -670,7 +682,7 @@ export function makeProfileService({
670
682
  // a brand-new key must also be a safe slug (it becomes a filename); surface that in the dry run
671
683
  let keyError = null;
672
684
  if (!exists) { try { assertProfileKey(key); } catch (e) { keyError = String(e.message); } }
673
- const errors = keyError ? [keyError, ...v.errors] : v.errors;
685
+ const errors = forBrowser(keyError ? [keyError, ...v.errors] : v.errors);
674
686
  return { status: 200, json: { ok: errors.length === 0, errors, isNew: !exists } };
675
687
  }
676
688
 
@@ -684,7 +696,7 @@ export function makeProfileService({
684
696
  preserveUncontrolled(profile, exists ? profiles.get(key) : null);
685
697
  const effective = preserveCodeOwned(profile, exists ? profiles.get(key) : null);
686
698
  const v = validateProfileEdit(key, effective, contextPack);
687
- if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: v.errors } };
699
+ if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: forBrowser(v.errors) } };
688
700
 
689
701
  const { files } = writeProfile({ profileDir, key, profile: effective, contextPack });
690
702
  // The write is atomic and LIVE the instant it renames — the next run freezes THIS file. An
@@ -175,13 +175,22 @@ export function derivedBatchSize(profile) {
175
175
  return Math.max(1, Math.floor(gridCellBudget(profile) / derivedFloor(profile)));
176
176
  }
177
177
 
178
+ // THE TERMS ONE GRID CALL MAY CARRY when each term runs `cellsPerTerm` cells: the cell budget divided by the
179
+ // cells the grid actually runs, never more than the profile's own figure. The profile's figure assumes its
180
+ // own stores plus the web cell, so it is too large for a grid that runs more per term: a matter frame's
181
+ // channels, or a closure over a store the matter chose for a company that picked no marketplaces, whose
182
+ // figure is the whole budget in terms. One oversized call truncates (the 224-cell note above).
183
+ export function gridBatchFor(profile, cellsPerTerm) {
184
+ return Math.max(1, Math.min(profile?.batchSize ?? 14, Math.floor(gridCellBudget(profile) / Math.max(1, cellsPerTerm))));
185
+ }
186
+
178
187
  // The per-entry platform rules as DATA rather than as throws, so every door applies the identical checks
179
188
  // and phrases the failure in its own register: a profile load DIES (a broken profile bricks every run
180
189
  // under it), a job CLARIFIES (one request, answerable by asking). Same rules either way — a domain that is
181
190
  // a foot-gun in a profile is the same foot-gun arriving on a job.
182
191
  //
183
- // SHAPE stays with the caller deliberately: a profile REQUIRES a non-empty platforms array, while a job's
184
- // platforms are optional and additive. Only the per-entry vocabulary is shared.
192
+ // SHAPE stays with the caller deliberately: a profile REQUIRES a platforms array (possibly empty), while a
193
+ // job's platforms are optional and additive. Only the per-entry vocabulary is shared.
185
194
  export function platformEntryErrors(list, { label = "platforms" } = {}) {
186
195
  const errs = [];
187
196
  const seen = new Set();
@@ -518,9 +527,13 @@ function validateProfileShape(key, p, { sparse = false } = {}) {
518
527
  }
519
528
  // platforms is REQUIRED on a whole profile but OPTIONAL on an overlay; the foot-gun guards below run whenever
520
529
  // it is present (a project that re-states its marketplaces must state them just as carefully).
530
+ //
531
+ // AN EMPTY LIST IS VALID, by the owner's ruling of 2026-09-23: a company may pick no marketplaces, and a
532
+ // new one starts that way. What stays required is the ARRAY — an absent key is not "none", it is a
533
+ // profile nobody finished (derivedFloor reads absent as the historical default of six).
521
534
  if (!sparse || p.platforms != null) {
522
- if (!Array.isArray(p.platforms) || !p.platforms.length || !p.platforms.every((x) => typeof x === "string" && x.trim()))
523
- die("platforms must be a non-empty array of store-domain strings");
535
+ if (!Array.isArray(p.platforms) || !p.platforms.every((x) => typeof x === "string" && x.trim()))
536
+ die("platforms must be an array of store-domain strings");
524
537
  // a one-character authoring slip here bricks every run under the profile (the floor counts entries
525
538
  // verbatim while the grid dedupes) — reject the known foot-guns at load time. Shared with the job
526
539
  // doors via platformEntryErrors so a domain rule can never drift between where it is stored and
@@ -1071,12 +1071,23 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1071
1071
  // the copy. The register-only LEVEL is retired, so no new run reaches this true; a run that already
1072
1072
  // did still must, or its workbook would claim a sweep that never ran.
1073
1073
  const registerOnly = isRegisterOnly(searchPolicy);
1074
+ // A RUN WHOSE COMPANY PICKED NO MARKETPLACES (the owner's ruling of 2026-09-23). Marketplaces were still
1075
+ // covered by the general web search, and by any stores the engine chose for the matter, so the lines that
1076
+ // describe the common-law layer say that instead of naming a marketplace search nobody picked. Read from
1077
+ // the frozen profile, which is the list the run actually searched; absent (a legacy run) reads as picked.
1078
+ let noMarketplacesPicked = false;
1079
+ try {
1080
+ const frozenProfile = runDir ? JSON.parse(readFileSync(driverDir(runDir, 'profile.json'), 'utf8')) : null;
1081
+ noMarketplacesPicked = Array.isArray(frozenProfile?.platforms) && frozenProfile.platforms.length === 0;
1082
+ } catch { /* no frozen profile — a legacy run keeps its wording */ }
1074
1083
 
1075
1084
  // Jurisdiction line for the Summary — searched register scope + the common-law surface layer.
1076
1085
  const regScope = (searchedJurisdictions && searchedJurisdictions.length) ? searchedJurisdictions.join('/') : 'Worldwide';
1077
1086
  const jurisdiction = registerOnly
1078
1087
  ? `${regScope} (register only — no common-law / marketplace search)`
1079
- : `${regScope} (register) + common-law (Western web / marketplace / social)`;
1088
+ : noMarketplacesPicked
1089
+ ? `${regScope} (register) + common-law (Western web / social, plus any stores chosen for this matter)`
1090
+ : `${regScope} (register) + common-law (Western web / marketplace / social)`;
1080
1091
 
1081
1092
  // Common-law URL-join: a grid hit whose candidate URL resolves to a finding's own link PROVES that search
1082
1093
  // term became a finding (rescues an anchor term whose finding wears a different name). Best-effort; absent
@@ -1224,6 +1235,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1224
1235
  caseLawText: rdText(join(dirname(reportMd), 'case-law-findings.md')),
1225
1236
  registerPlan: rdJson(driverDir(runBase, 'register-plan.json')),
1226
1237
  laneDepthVerdicts,
1238
+ noMarketplacesPicked,
1227
1239
  });
1228
1240
  writeRO('search-depth.json', JSON.stringify(searchDepth, null, 2));
1229
1241
  } catch { /* the depth record is additive — a publish never fails for want of it */ }
@@ -70,6 +70,9 @@ let CASE_LAW_BY_ORD = new Map(); // E5: ordinal → grounded case-law profile
70
70
  // is full-country only), so suppressing there would take case-law off the page with nothing left
71
71
  // saying so. Absence of a state is not a statement that nothing was found.
72
72
  let COURT_DECISIONS = null;
73
+ // The company picked no marketplaces (search-depth counts.sweep): the use-check's nothing-found line names the
74
+ // general web and the stores chosen for the matter instead of a marketplace search. Set per render.
75
+ let NO_MARKETPLACES_PICKED = false;
73
76
  let ENFORCER_SIGNALS = new Map(); // E6: registration uri (lowercase) → {aggression, oppositions, owner}
74
77
  // WP-receipts W2 — per-render provider record-link origin + label, resolved by publish from the run's
75
78
  // OWN _driver/receipts.json provider (never the currently-configured provider — a re-published archive
@@ -552,6 +555,9 @@ const useEvidence = (m) => [USE_EVIDENCE_LABEL[m?._status], USE_SOURCE_LABEL[m?.
552
555
  // runs carry the old value forever and a fourth spelling of it would have to be accepted everywhere.
553
556
  const USE_CHECK_NO_RESULT = 'perplexity_research — no result';
554
557
  const USE_CHECK_NO_RESULT_CITE = 'Nothing found in the marketplaces searched.';
558
+ // The same "searched, nothing found" line for a run whose company picked no marketplaces (the owner's
559
+ // ruling of 2026-09-23): there was no marketplace search to name, and the general web still ran.
560
+ const USE_CHECK_NO_RESULT_CITE_NO_MARKETPLACES = 'Nothing found in the general web search or in any store chosen for this matter.';
555
561
  // — MATCHED ON NORMALISED PUNCTUATION, NOT ONE SPELLING. The constant itself does not
556
562
  // move (archived runs carry it forever, the validators name it), but the SEAT emitted a hyphen where
557
563
  // the doctrine writes an em dash, and exact equality let the raw tool name through to a delivered
@@ -1222,7 +1228,9 @@ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri
1222
1228
  }
1223
1229
  const sw = c.sweep || {};
1224
1230
  if (sw.spellings) rows.push(['Spellings searched', String(sw.spellings)]);
1225
- if (sw.checks) rows.push(['Marketplace and web', `${sw.checks.toLocaleString('en-GB')} checks on ${sw.platforms} platforms`]);
1231
+ if (sw.checks) rows.push(['Marketplace and web', sw.noMarketplacesPicked
1232
+ ? `${sw.checks.toLocaleString('en-GB')} checks: the general web, plus any stores chosen for this matter.`
1233
+ : `${sw.checks.toLocaleString('en-GB')} checks on ${sw.platforms} platforms`]);
1226
1234
  if (sw.reputation) rows.push(['Reputation and meaning', `${sw.reputation.toLocaleString('en-GB')} checks`]);
1227
1235
  rows.push(['Local-script spellings', c.localScriptSearched ? 'Searched' : 'Not searched']);
1228
1236
  // HOW DEEP THE LOCAL-LANGUAGE INVESTIGATION WENT, which is a different question from the row above it.
@@ -1803,7 +1811,9 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1803
1811
  const useStatus = useEvidence(f.meters?.use);
1804
1812
  // D7 — the code-owned "searched, nothing found" sentinel becomes client words HERE, by exact
1805
1813
  // equality against the one constant. Any other value is a source string and rides through untouched.
1806
- const useSrc = isUseCheckNoResult(f.use_check?.source) ? USE_CHECK_NO_RESULT_CITE : f.use_check?.source;
1814
+ const useSrc = isUseCheckNoResult(f.use_check?.source)
1815
+ ? (NO_MARKETPLACES_PICKED ? USE_CHECK_NO_RESULT_CITE_NO_MARKETPLACES : USE_CHECK_NO_RESULT_CITE)
1816
+ : f.use_check?.source;
1807
1817
  // NO EVIDENCE TAG ON AN EMPTY RESULT. The line read "Use checked. Marketplace search run — no result
1808
1818
  // found. Evidence: inferred", and "inferred" beside "no result" reads as a contradiction: it qualifies
1809
1819
  // how a FINDING was established, and there is no finding here. Nothing was found, and that is the
@@ -2511,6 +2521,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2511
2521
  SCOPE_WORLDWIDE = opts.scopeBasis === 'worldwide' ? true : null; // the plan's scope_basis; null ⇒ fall back to the ledger-prose sniff
2512
2522
  CASE_LAW_BY_ORD = opts.caseLawByOrdinal instanceof Map ? opts.caseLawByOrdinal : new Map(); // T7 (E5)
2513
2523
  COURT_DECISIONS = opts.searchDepth?.counts?.courtDecisions ?? null; // gates the card's case-law strand
2524
+ NO_MARKETPLACES_PICKED = opts.searchDepth?.counts?.sweep?.noMarketplacesPicked === true;
2514
2525
  ENFORCER_SIGNALS = new Map((Array.isArray(opts.enforcerSignals) ? opts.enforcerSignals : []).map((e) => [String(e.uri ?? '').toLowerCase(), e])); // T7 (E6)
2515
2526
  RECORD_ORIGIN = opts.recordOrigin ?? null; // WP-receipts W2
2516
2527
  // — `null` and `` mean DIFFERENT things and the render must not collapse them. `` is an
@@ -275,7 +275,7 @@ export function localScriptSearched(registerPlan) {
275
275
  *
276
276
  * @returns {{schemaVersion: number, cleared: object, counts: object}}
277
277
  */
278
- export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNames = [], bandRecordIds = null, commonLawGrid = null, caseLawText = "", registerPlan = null, laneDepthVerdicts = null } = {}) {
278
+ export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNames = [], bandRecordIds = null, commonLawGrid = null, caseLawText = "", registerPlan = null, laneDepthVerdicts = null, noMarketplacesPicked = false } = {}) {
279
279
  // `recordFileNames: null` travels all the way to the page — see recordsByCountry. The default stays `[]`
280
280
  // because that is "the caller said nothing", not "the store is absent"; only the publish path knows the
281
281
  // difference and it is the one producer.
@@ -295,7 +295,9 @@ export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNa
295
295
  counts: {
296
296
  recordsByCountry: recordsByCountry(recordFileNames),
297
297
  recordsRead: recordFileNames === null ? null : recordFileNames.length,
298
- sweep: sweepCounts(commonLawGrid, auditMd),
298
+ // The company picked no marketplaces: the report says the general web ran plus any stores chosen for
299
+ // the matter. Stamped only when true, so every other run's record is unchanged.
300
+ sweep: { ...sweepCounts(commonLawGrid, auditMd), ...(noMarketplacesPicked ? { noMarketplacesPicked: true } : {}) },
299
301
  localScriptSearched: localScriptSearched(registerPlan),
300
302
  courtDecisions: courtDecisionsState(caseLawText),
301
303
  // `localScriptSearched` above answers whether the spellings were searched; this answers how deep
@@ -106,7 +106,7 @@ import { runLog, note } from "./log.mjs";
106
106
  import { writeRunStatus } from "./progress.mjs";
107
107
  // tokens.mjs imports this module too (isCodeSide, stampRunEconomics). The cycle is safe because each side
108
108
  // reads the other's bindings only inside functions, never while the module is loading.
109
- import { isAttemptRow } from "./tokens.mjs";
109
+ import { isAttemptRow, modelKey } from "./tokens.mjs";
110
110
 
111
111
  /**
112
112
  * The provider's separately-priced token kinds, in the driver's own `usage` vocabulary (gateway.mjs /
@@ -185,23 +185,12 @@ function billingKeyOf(rec) {
185
185
  // is rather than dragged into "unknown" beside genuinely unstamped legacy rows.
186
186
  const engine = isCodeSide(rec) ? "code" : String(rec.engine ?? "unknown");
187
187
  const authMode = isCodeSide(rec) ? "not-provider-billed" : String(rec.authMode ?? "unknown");
188
- // A TURN THAT NAMED NO MODEL (a jx row with `modelActual: null` and no `model`) is keyed the way the
189
- // token rollup keys it (modelKey in tokens.mjs): `<engine>/no-model-reported`, a name that says the
190
- // model is missing. Read through the old `?? "unknown"` it landed beside legacy rows nobody stamped,
191
- // and its byBilling bucket named a different model from the rollup's byModel for the same turn.
192
- //
193
- // A COPY OF modelKey's RULE, NOT A SHARED ONE: tokens.mjs does not export it. So the test for "no stamp"
194
- // is modelKey's own, a non-empty string `modelUsed`, and not `modelUsed == null`: under that looser test
195
- // a row stamped `modelUsed: ""` keyed its bucket as the empty string while the rollup keyed the same
196
- // turn `<engine>/no-model-reported`. Two copies of one rule drifting apart is how the census and the
197
- // rollup came to disagree about what an attempt is, so the tests hold these two copies to each other on
198
- // the rows the engine writes. They still part on a row no writer produces: no model and no engine, or
199
- // engine `anthropic-agent`. This key names the missing model there, while modelKey resolves the absent
200
- // model through the catalog before it asks whether one exists, and buckets the row as `undefined`.
201
- const stamped = typeof rec.modelUsed === "string" && rec.modelUsed;
202
- const model = !stamped && typeof rec.model !== "string"
203
- ? `${typeof rec.engine === "string" && rec.engine ? rec.engine : "unknown"}/no-model-reported`
204
- : String(rec.modelUsed ?? rec.model ?? "unknown");
188
+ // THE MODEL IS NAMED BY THE TOKEN ROLLUP'S OWN RULE (modelKey in tokens.mjs), not a copy of it. A copy
189
+ // lived here while tokens.mjs did not export the rule, and the two drifted: a native-language turn that
190
+ // named its model was billed under the raw served id while the rollup keyed it `anthropic/unstamped:<id>`,
191
+ // so one turn had two names in the same run's figures. A code-side row comes back as its own name
192
+ // (`code:<step>`), which says no model ran.
193
+ const model = modelKey(rec);
205
194
  return { engine, authMode, model, key: `${engine}|${authMode}|${model}` };
206
195
  }
207
196
 
package/driver/stages.mjs CHANGED
@@ -2527,7 +2527,7 @@ export const STAGES = {
2527
2527
  },
2528
2528
  "adopt-or-override each placement by engaging its reason, and the `### Disagreement resolutions` rows (one per surfaced disagreement and per borderline:true, each ADOPTED/OVERRODE in writing)": {
2529
2529
  class: "judgment", tokens: ["registerdigest_adjudication_invalid", "registerdigest_adjudication_incomplete"],
2530
- why: "Answering the promotion question the other way, in writing, against a reason another stage authored. #850 keeps it J. The row's SUBJECT is handed over as data (the driver appends the PLACEMENT RULINGS TAIL block, pipeline.mjs:3584), so nothing here is a fetch. [citation unverified]",
2530
+ why: "Answering the promotion question the other way, in writing, against a reason another stage authored. #850 keeps it J. The row's SUBJECT is handed over as data (the driver appends the PLACEMENT RULINGS TAIL block, digestDispatchExtra() in pipeline.mjs), so nothing here is a fetch.",
2531
2531
  },
2532
2532
  // ── REWRITTEN, NEVER DELETED (the ruling) — AND THE ROW THAT COST THIS CONVERSION A DESIGN ──
2533
2533
  //