clearotron 0.3.0-beta.2 → 0.3.0-beta.4

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 (40) hide show
  1. package/INSTALL.md +32 -6
  2. package/bin/connect.mjs +68 -25
  3. package/bin/disconnect.mjs +16 -11
  4. package/bin/onboard.mjs +65 -6
  5. package/bin/start.mjs +7 -1
  6. package/build-info.json +2 -2
  7. package/driver/CHANGELOG.md +38 -0
  8. package/driver/driver.config.mjs +5 -0
  9. package/driver/engine/anthropic-agent.mjs +34 -17
  10. package/driver/gateway.mjs +76 -15
  11. package/driver/package.json +1 -1
  12. package/driver/portal-service.mjs +1 -1
  13. package/driver/profile-service.mjs +31 -3
  14. package/driver/publish/index.mjs +12 -9
  15. package/driver/publish/knockout.mjs +4 -2
  16. package/driver/publish/office-record-links.mjs +56 -21
  17. package/driver/recipe-service.mjs +14 -5
  18. package/driver/record-origins.mjs +14 -0
  19. package/driver/suite-census.json +34 -28
  20. package/mcp-server/CHANGELOG.md +8 -0
  21. package/mcp-server/lib/driver.mjs +2 -0
  22. package/mcp-server/lib/knockout.mjs +2 -2
  23. package/mcp-server/lib/options.mjs +9 -2
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-CsCuPshD.css → index-Cv-E_agg.css} +199 -86
  27. package/portal-ui/dist/assets/{index-CcFjgM78.js → index-DWYCsOCJ.js} +514 -380
  28. package/portal-ui/dist/index.html +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/clarivate/src/core.js +5 -0
  31. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  32. package/providers/oauth-mcp-bridge/package.json +1 -1
  33. package/scripts/e2e-unread-terminals.mjs +1 -1
  34. package/scripts/e2e.mjs +114 -9
  35. package/scripts/revisit-render-check.mjs +22 -4
  36. package/scripts/travelling-predicates.mjs +1 -1
  37. package/shared/connect-clients.mjs +282 -321
  38. package/shared/names-in-force.mjs +1 -0
  39. package/shared/stdio-connect.mjs +63 -0
  40. package/shared/store-in-repo.mjs +50 -2
@@ -1052,6 +1052,10 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1052
1052
  grant: gatherAllowedTools,
1053
1053
  })
1054
1054
  : null;
1055
+ // The start of this attempt's window in the per-run refusal journals. Taken before the witness and
1056
+ // the snapshot, which only widens the window by their own time: an earlier attempt's refusals all
1057
+ // precede its own settle, so they stay outside it.
1058
+ const dispatchedAt = Date.now();
1055
1059
  for (const line of describeMethodologyDrift(witnessStageMethodology(runDir, name, effMessage, engineResolveSkill)))
1056
1060
  note(`[${name}] ${line}`);
1057
1061
  // — the frozen judged-by set, hashed into THIS PROCESS'S MEMORY before the seat runs. Never
@@ -1368,7 +1372,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1368
1372
  // ONE artifact judgement, shared by both rescues below (the rule: never a second, drifting copy of
1369
1373
  // the contract). Present + written by THIS attempt (the per-attempt snapshot — an inherited file and
1370
1374
  // equally an earlier attempt's file never rescue a failed turn) + passes the stage's own validator.
1371
- const attemptWroteTruth = () => {
1375
+ const attemptWroteTruth = (why = null) => {
1372
1376
  // — the rescues judge with `validate` directly rather than through judgeArtifacts, so the union
1373
1377
  // has to run here too or a rescued turn would be refused for rows the form already holds. Idempotent,
1374
1378
  // so the double call on the normal path costs a regeneration and changes nothing.
@@ -1381,16 +1385,21 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1381
1385
  // answer for a killed-in-the-gap attempt is no. Idempotent, so the double call costs a regeneration.
1382
1386
  const pu = syncPlacementForm(files);
1383
1387
  if (pu) lastPlacementUnion = pu;
1388
+ // `why`, when a caller passes one, is told WHICH file failed and on what, so a refusal can name both.
1384
1389
  return files.every((f) => {
1385
1390
  const now = statOf(f);
1386
- if (now === null || now === preArtifact.get(f)) return false; // absent, or not written by this attempt
1387
- if (validate) { const v = validate(f, readFileSync(f, "utf8")); if (!v.ok) return false; }
1391
+ const no = (cause, reason) => { if (why) Object.assign(why, { file: rel(f), cause, ...(reason ? { reason } : {}) }); return false; };
1392
+ if (now === null) return no("absent");
1393
+ if (now === preArtifact.get(f)) return no("not-written-by-this-attempt");
1394
+ if (validate) { const v = validate(f, readFileSync(f, "utf8")); if (!v.ok) return no("invalid", v.reason ? String(v.reason) : undefined); }
1388
1395
  return true;
1389
1396
  });
1390
1397
  };
1391
1398
  let rescued = null;
1392
1399
  let quiescentMs = null; //: how long the artifact had been untouched when the turn settled
1393
- let rescueRefused = null; //: WHICH of the rescue's three causes refused — on the row, not only in a note
1400
+ let rescueRefused = null; //: WHICH of the rescue's four causes refused — on the row, not only in a note
1401
+ let rescueRefusedFile = null, rescueRefusedReason = null; //: and which file, and on what
1402
+ let attemptRefusals = null; //: this dispatch's refusals of a tool-written artifact, on a timeout
1394
1403
  if (fail && /^nonzero_exit_/.test(fail) && files.length) {
1395
1404
  if (killClass || killSeen) {
1396
1405
  note(`[${name}] ${fail} with a kill-class attempt in this ladder — the exit-1 rescue stays CLOSED (a killed turn's artifact may be torn mid-write and a validator cannot prove it whole); failing honestly instead`);
@@ -1427,16 +1436,28 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1427
1436
  //
1428
1437
  // A stage with no validator, or whose artifact fails it, or which never wrote, is untouched.
1429
1438
  else if (fail === "timeout" && turn.signals?.hardWall && files.length && validate && wallRescueEnabled()) {
1430
- // — `unreadable` is tracked as its own fact rather than inferred from the -1 sentinel. The
1431
- // sentinel is not the only way this goes negative: an artifact written in the same instant the turn
1432
- // settled yields a small NEGATIVE elapsed, and the refusal message read that as "artifact
1433
- // unreadable" — a diagnosis about the filesystem for a file that was perfectly readable and simply
1434
- // still being written. That is the under-quiescence case, and it is now named as one.
1435
- let unreadable = false;
1436
- const quiet = files.map((f) => { try { return settledAt - statSync(f).mtimeMs; } catch { unreadable = true; return -1; } });
1437
- quiescentMs = quiet.length ? Math.min(...quiet) : null;
1439
+ // — A FILE THAT COULD NOT BE STAT'ED IS NAMED, WITH WHY, rather than folded into a number. It
1440
+ // used to be a -1 in `quiescentMs` and the cause `artifact-unreadable`, and the commonest way to get
1441
+ // there is not a filesystem fault at all: the turn was killed before it wrote. Measured on two
1442
+ // stages of one run, 2026-09-10: both rows read `wrote: false`, `quiescentMs: -1`,
1443
+ // `artifact-unreadable`, for files that did not exist. An ABSENT file (ENOENT) is its own cause now;
1444
+ // `artifact-unreadable` stays for a stat that fails any other way, with its error code. Rows
1445
+ // written before this change say `artifact-unreadable` for both.
1446
+ //
1447
+ // `quiescentMs` IS A MEASUREMENT OR IT IS ABSENT. With a file unstat'able there is no quiescence of
1448
+ // the set to measure, so it stays null and the row omits it: a -1 reads as a number to anything that
1449
+ // averages or compares it. An mtime that lands after the settle instant (written in that same
1450
+ // instant, or a clock step) measures 0 rather than a negative; it is the under-quiescence case.
1451
+ let missing = null;
1452
+ const quiet = [];
1453
+ for (const f of files) {
1454
+ try { quiet.push(Math.max(0, settledAt - statSync(f).mtimeMs)); }
1455
+ catch (e) { if (!missing) missing = { file: rel(f), code: String(e?.code ?? "unknown") }; }
1456
+ }
1457
+ quiescentMs = missing || !quiet.length ? null : Math.min(...quiet);
1438
1458
  const bar = wallRescueQuiesceMs();
1439
- if (quiescentMs >= bar && attemptWroteTruth()) {
1459
+ const why = {};
1460
+ if (quiescentMs !== null && quiescentMs >= bar && attemptWroteTruth(why)) {
1440
1461
  rescued = fail;
1441
1462
  fail = null;
1442
1463
  note(`[${name}] hard-wall kill at ${Math.round(wall)}s, but every expected artifact was written by this attempt, passes its validator and had been untouched for ${Math.round(quiescentMs / 1000)}s when the turn settled — the stage FINISHED and the wall is a fact about the dispatch, not a failure of the stage (the resume's skip path would accept these same bytes)`);
@@ -1451,12 +1472,24 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1451
1472
  // rescue was refused on the third cause, its validator — and 31 minutes of finished work were
1452
1473
  // discarded and re-run cold. `grep -c rescue run.jsonl` on that run returns 0. Nothing in the
1453
1474
  // record said why, and reconstructing it took a file-mtime comparison against a preserved run dir.
1454
- rescueRefused = unreadable ? "artifact-unreadable"
1475
+ rescueRefused = missing ? (missing.code === "ENOENT" ? "artifact-absent" : "artifact-unreadable")
1455
1476
  : quiescentMs < bar ? "under-quiescence"
1456
1477
  : "not-written-by-this-attempt-or-invalid";
1457
- note(`[${name}] hard-wall kill at ${Math.round(wall)}s — wall rescue REFUSED (${unreadable ? "artifact unreadable" : quiescentMs < bar ? `artifact touched ${Math.round(quiescentMs / 1000)}s before the kill, under the ${Math.round(bar / 1000)}s quiescence bar` : "not written by this attempt, or fails its validator"}); failing honestly as timeout`);
1478
+ // — AND WHICH FILE, AND ON WHAT: an unstat'able file with its error code; under the bar, the file
1479
+ // touched last (`quiescentMs` carries the number); otherwise the first file the artifact judgement
1480
+ // failed, with its cause — `absent`, `not-written-by-this-attempt`, or the validator's own reason.
1481
+ if (missing) { rescueRefusedFile = missing.file; rescueRefusedReason = missing.code; }
1482
+ else if (quiescentMs < bar) rescueRefusedFile = rel(files[quiet.indexOf(quiescentMs)]);
1483
+ else { rescueRefusedFile = why.file ?? null; rescueRefusedReason = why.reason ?? why.cause ?? null; }
1484
+ note(`[${name}] hard-wall kill at ${Math.round(wall)}s — wall rescue REFUSED (${rescueRefused}: ${rescueRefusedFile ?? "no file named"}${rescueRefusedReason ? `, ${rescueRefusedReason}` : quiescentMs < bar ? `, touched ${Math.round(quiescentMs / 1000)}s before the kill, under the ${Math.round(bar / 1000)}s bar` : ""}); failing honestly as timeout`);
1458
1485
  }
1459
1486
  }
1487
+ // — WHAT A KILLED ATTEMPT WAS DOING, where its transport kept a record. A tool-written artifact's
1488
+ // refusal journal says how many times this attempt sent its record and was told no, and the reason the
1489
+ // last time. The missing-file row has carried that last reason since the journal existed; a timeout row
1490
+ // carried nothing, so an attempt that spent forty minutes being refused read the same as one that never
1491
+ // called. Counted from THIS dispatch only: the journal is per run, and every attempt appends to it.
1492
+ if (fail === "timeout" && files.length) attemptRefusals = refusalsInWindow(files, runDir, dispatchedAt, settledAt);
1460
1493
  // Arm the ladder-wide refusal for every LATER attempt. Read before `classifyWedge` below only because
1461
1494
  // the rescue above needs it; the two can never disagree in reach — isTaintRow excludes lane_wedge, and
1462
1495
  // a wedge is a `timeout` fail that breaks the ladder immediately, so no later attempt exists to judge.
@@ -1675,6 +1708,8 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1675
1708
  // arm did not apply (no wall, no validator, no declared file).
1676
1709
  quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined,
1677
1710
  rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
1711
+ rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
1712
+ refusedCalls: attemptRefusals ?? undefined, // — {count, last}: this dispatch's refusals, on a timeout
1678
1713
  //: the verbatim message this attempt was dispatched with — {file, sha, bytes, chars, kind}.
1679
1714
  // null when the run has no directory or the gate is off; {present:false, error} when the write
1680
1715
  // failed. An absence is a record here, never a silence.
@@ -1740,6 +1775,8 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1740
1775
  rescued: rescued ?? undefined, killed: killed || undefined,
1741
1776
  quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
1742
1777
  rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
1778
+ rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
1779
+ refusedCalls: attemptRefusals ?? undefined, // — see the per-stage row
1743
1780
  // — AND THE BILLING PAIR, by the same argument makes for the model pair one field up:
1744
1781
  // the spine carries it or the two logs disagree about what ran. This is the row a sweep across
1745
1782
  // archived runs actually reads, and the question "has this box ever billed API" could not be
@@ -2007,6 +2044,30 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
2007
2044
  return { ok: false, attempts: attempt, fail: lastFail, sessionKey: lastKey, modelWire: lastModelWire, modelUsed: lastModelUsed, attemptFails: [...attemptFails], warmEscalated: warmEscalatedAt > 0 || undefined, quantity: lastQuantity, reads: lastReads, readsTruncated: lastReadsTruncated, warm: lastWarm, wrote: lastWrote, formRepairs: formRepairsUsed };
2008
2045
  }
2009
2046
 
2047
+ /**
2048
+ * One dispatch's refusals, read from the refusal journals of the tool-written artifacts in `files`.
2049
+ * An entry is placed by its `at`; one with no readable `at` cannot be placed in a dispatch, so it is
2050
+ * counted apart rather than guessed into this one.
2051
+ * @returns {{ count: number, last: string|null, unattributed?: number } | null} null when no file in
2052
+ * `files` has a refusal journal, so "this stage keeps no journal" never reads as "nothing was refused".
2053
+ */
2054
+ function refusalsInWindow(files, runDir, from, to) {
2055
+ let journals = 0, count = 0, unattributed = 0, last = null, lastAt = -Infinity;
2056
+ for (const f of files) {
2057
+ const reader = toolWrittenArtifact(f)?.refusals;
2058
+ if (!reader) continue;
2059
+ journals++;
2060
+ for (const r of reader(runDir, f) ?? []) {
2061
+ const at = Date.parse(String(r?.at ?? ""));
2062
+ if (!Number.isFinite(at)) { unattributed++; continue; }
2063
+ if (at < from || at > to) continue;
2064
+ count++;
2065
+ if (at >= lastAt) { lastAt = at; last = String(r?.reason ?? ""); }
2066
+ }
2067
+ }
2068
+ return journals ? { count, last, ...(unattributed ? { unattributed } : {}) } : null;
2069
+ }
2070
+
2010
2071
  function rel(p) {
2011
2072
  const i = p.indexOf("/prelim-search/");
2012
2073
  return i >= 0 ? p.slice(i + 1) : p;
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.2",
5
+ "version": "0.3.0-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -4589,7 +4589,7 @@ const PORT = PORT_CHOICE.port;
4589
4589
  const recipeCommit = committing(recipeRepoRoot, "saved-search");
4590
4590
  // — same, against the RECIPE repo root, which may differ from the profile one.
4591
4591
  const recAudit = makeCommittableAudit({ auditPath: recipeAuditPath, repoRoot: recipeRepoRoot });
4592
- const recipes = makeRecipeService({ recipesDir, profileDir, gitCommit: recipeCommit, audit: recAudit });
4592
+ const recipes = makeRecipeService({ recipesDir, profileDir, readLayered: true, gitCommit: recipeCommit, audit: recAudit });
4593
4593
  callRecipes = (method, path, body, identity) => recipes.route(method, path, { email: identity?.email }, body ?? {});
4594
4594
  log(`saved searches ON — store=${recipesDir} repo=${recipeRepoRoot}`);
4595
4595
  } else {
@@ -471,13 +471,41 @@ export function makeProfileService({
471
471
  throw e;
472
472
  }
473
473
 
474
+ // A CREATE THE STORE CANNOT RECORD IS REFUSED, NOT REPORTED AS DONE. This answered 201 with the
475
+ // company live and a `commitError` beside it, so a machine with no git identity got a company with no
476
+ // record of who made it or when, on its first company, and the store move had been made to close
477
+ // exactly that gap. The store is asked first, and a refusal writes nothing: no profile, no audit row
478
+ // and, since the organisation's grant is filed only on a 201, no grant.
479
+ const cannot = typeof gitCommit?.refusal === "function" ? gitCommit.refusal() : null;
480
+ if (cannot) {
481
+ return { status: 409, json: { error: `No company was created: ${cannot.message}, then try again.`, code: `store_${cannot.code.replace(/-/g, "_")}` } };
482
+ }
474
483
  const { files } = writeProfile({ profileDir, key: wanted, profile, contextPack: "" });
475
- // WRITTEN AND RECORDED ARE TWO EVENTS. The write is live the instant it renames; the commit can
476
- // fail on its own. Reporting them as one is how somebody is told nothing happened about a company
477
- // that is already governing runs.
478
484
  const message = `chore(clearotron): create company ${wanted} (via portal, by ${by})`;
479
485
  const { commit, commitError } = commitWithAuditRow({ audit, gitCommit, files, message, by,
480
486
  row: { event: "profile-create", key: wanted, by, fields: Object.keys(profile) } });
487
+ // AND ONE THAT FAILS ANYWAY IS WITHDRAWN. A hook or a full disk can refuse the commit after the store
488
+ // said it could record. The paths this create wrote were absent before it, so they are returned to
489
+ // absent, and a third audit row says so after the two the failed commit left.
490
+ if (commitError) {
491
+ // GIT'S LAST WORD, NOT ITS ECHO. The error opens with the whole command line it ran, message and
492
+ // author included, and ends with the refusal itself: a hook's own sentence, "No space left on
493
+ // device". The operator acts on the last line.
494
+ const cause = String(commitError).trim().split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? String(commitError);
495
+ let left = null;
496
+ try {
497
+ if (typeof gitCommit?.withdraw !== "function") throw new Error("this store cannot withdraw what it wrote");
498
+ gitCommit.withdraw(files);
499
+ } catch (e) { left = String(e?.message ?? e).slice(0, 200); }
500
+ try {
501
+ audit({ event: "profile-create-withdrawn", of: "profile-create", key: wanted, by,
502
+ note: left ? `the create could not be recorded and its files could not be removed (${left})` : "the create could not be recorded, so its files were removed" });
503
+ } catch { /* the refusal below is the report; a journal failure must not mask it */ }
504
+ if (left) {
505
+ return { status: 500, json: { key: wanted, error: `The company could not be recorded (${cause}), and its file could not be removed afterwards (${left}). It is on disk with no record behind it — tell an administrator.` } };
506
+ }
507
+ return { status: 409, json: { error: `No company was created: the store could not record it (${cause}). Nothing was left behind.`, code: "store_commit_failed" } };
508
+ }
481
509
  return { status: 201, json: {
482
510
  key: wanted, name, written: true, created: true, commit,
483
511
  // The receipt reads off THESE, so it can say which framework and how many marketplaces, and
@@ -22,7 +22,7 @@ import { reportIdentityFor, productCoverageNote, isRegisterOnly } from '../searc
22
22
  import { readRecordArtifacts, bindFindingsToRecords, joinEvidenceStatus } from '../registry-fidelity.mjs';
23
23
  import { deliveryFlagLines } from '../predelivery-lint.mjs';
24
24
  import { PROVIDERS, config } from '../driver.config.mjs';
25
- import { recordOriginsFor } from '../record-origins.mjs';
25
+ import { declaredRecordOrigins } from '../record-origins.mjs';
26
26
  import { NEUTRAL_DELIVERY, loadProfiles } from '../profiles.mjs';
27
27
  import { resolveDemoData, demoBannerMd } from './demo-marking.mjs'; // — one demo question, every product; 2134 — every SURFACE
28
28
  import { engineCommit } from '../engine-build.mjs';
@@ -546,7 +546,7 @@ export async function regenSurfaces(poolRoot) {}
546
546
  * all three client surfaces state the same link. That agreement is the point:
547
547
  * a repair that reached the report and not the workbook would put two
548
548
  * different registers in one delivery.
549
- * @param {string[]|null} origins recordOriginsFor(the run's own provider). `null` = the run has no fetch
549
+ * @param {string[]|null} origins declaredRecordOrigins(the run's own provider). `null` = the run has no fetch
550
550
  * receipts (legacy/archived): a NO-OP, byte-identical output, because a run
551
551
  * that never named its register cannot be judged against one. `[]` is a
552
552
  * different answer — this provider publishes no per-record page at all, so no
@@ -773,7 +773,8 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
773
773
  //
774
774
  // So this REPAIRS rather than refuses, keyed on the RUN's own provider (the receipts above), never on
775
775
  // today's CLEAROTRON_DATABASE — a republished archive keeps its own register.
776
- const runOrigins = fetchReceipts ? recordOriginsFor(String(fetchReceipts[0]?.provider ?? '').toLowerCase()) : null;
776
+ // Receipts that name no provider are read as no receipts: the gate is off, as it is for a legacy run.
777
+ const runOrigins = fetchReceipts ? declaredRecordOrigins(fetchReceipts[0]?.provider) : null;
777
778
  const foreignRecordLinks = normalizeRecordLinks(findings, runOrigins);
778
779
  // — the SAME list reaches the renderer. normalizeRecordLinks repairs foreign ABSOLUTE links on
779
780
  // register-sourced findings; render.mjs separately CONSTRUCTS links from bare record paths, and until
@@ -859,7 +860,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
859
860
  const esPath = driverDir(runDir ?? dirname(reportMd), 'enforcer-signals.json');
860
861
  if (existsSync(esPath)) { const es = JSON.parse(readFileSync(esPath, 'utf8')); if (Array.isArray(es)) enforcerSignals = es; }
861
862
  } catch { /* absent — no telemetry lines */ }
862
- const officeLinks = officeLinksFor(findings, recordsByUri, fetchReceipts); bindFindingsToRecords(findings, recordsByUri);
863
+ const officeLinks = officeLinksFor(findings, recordsByUri, runOrigins); bindFindingsToRecords(findings, recordsByUri);
863
864
  // 404-card caveat (2026-07-22): the V4-2 closure pass persisted every cited record its targeted
864
865
  // fetch definitively could not retrieve (predelivery-lint.json artifactSet.recordFetchFailures);
865
866
  // the evidence join stamps `_recordFetchFailure` from it so the card render carries the
@@ -1621,12 +1622,14 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1621
1622
 
1622
1623
  // ── The office's own page for each fetched record, where the run's register publishes none of its own ──
1623
1624
  // Addressed from the record's numbers (office-record-links.mjs), never from the model or the handle, and
1624
- // null on every other register, so those runs publish exactly as before. The tally goes to meta.json and
1625
- // the log, so a register whose numbers never fit shows up as a count rather than as silence. Kept down
1626
- // here, below every line the rest of the tree cites by number.
1625
+ // null on every register that publishes pages of its own, so those runs publish exactly as before. Which
1626
+ // registers those are is `runOrigins`, the same answer the record-URL normalisation above keys on, so the
1627
+ // two cannot disagree about one register. The tally goes to meta.json and the log, so a register whose
1628
+ // numbers never fit shows up as a count rather than as silence. Kept down here, below every line the rest
1629
+ // of the tree cites by number.
1627
1630
  import { recordLinksFor } from './office-record-links.mjs';
1628
- function officeLinksFor(findings, recordsByUri, fetchReceipts) {
1629
- const links = recordLinksFor(findings, recordsByUri, fetchReceipts?.[0]?.provider);
1631
+ function officeLinksFor(findings, recordsByUri, runOrigins) {
1632
+ const links = recordLinksFor(findings, recordsByUri, runOrigins);
1630
1633
  if (links) console.log(`[record-links] ${links.summary}`);
1631
1634
  return links;
1632
1635
  }
@@ -401,8 +401,9 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
401
401
  // THE OFFICE'S OWN PAGE FOR EACH LISTED FILING, where the run's register publishes none of its own:
402
402
  // the addressing the clearance gives its register findings (office-record-links.mjs), set on the
403
403
  // sidecar here for the same reason the normalisation above is. Keyed on the run's own provider, and
404
- // the tally goes to meta.json, so numbers that never fit show as a count rather than as silence.
405
- const officeLinks = addressListedFilings(registerRecords);
404
+ // the tally goes to meta.json, so numbers that never fit show as a count rather than as silence. A
405
+ // sidecar that names no provider is left as it was, as the normalisation above leaves it.
406
+ const officeLinks = addressListedFilings(registerRecords, declaredRecordOrigins(registerRecords?.provider));
406
407
  if (officeLinks) note(`[record-links] ${officeLinks.summary}`);
407
408
 
408
409
  // ── Predelivery lint — the APPLICABLE subset, FLAGS not FAILS (2026-07-31) ─────────────────────────
@@ -753,3 +754,4 @@ export function knockoutDocumentRoutes(reports, { auditFile = null } = {}) {
753
754
  // The office's own page for each listed filing (office-record-links.mjs). Kept down here, below every
754
755
  // line the rest of the tree cites by number.
755
756
  import { addressListedFilings, reasonCellFor } from './office-record-links.mjs';
757
+ import { declaredRecordOrigins } from '../record-origins.mjs';
@@ -67,9 +67,13 @@ export const OFFICE_RECORD_PAGES = Object.freeze({
67
67
  fr: { name: "France", address: (r) => addressFrom([r.applicationNumber, r.registrationNumber], /^(?:FR)?(\d{7})$/i, (m) => ({
68
68
  number: `FR${m[1]}`, href: `https://data.inpi.fr/marques/FR${m[1]}` })) },
69
69
  // The office's record address as indexed; it challenges automated fetches, so none was opened from a
70
- // server. UK numbers are UK plus eleven digits (UK000..., UK008..., UK009...).
71
- gb: { name: "United Kingdom", address: (r) => addressFrom([r.applicationNumber, r.registrationNumber], /^(UK\d{11})$/i, (m) => ({
72
- number: m[1].toUpperCase(), href: `https://trademarks.ipo.gov.uk/ipo-tmcase/page/Results/1/${m[1].toUpperCase()}` })) },
70
+ // server. UK numbers are UK plus eleven digits (UK000..., UK008..., UK009...). A national number given
71
+ // bare, seven digits as one register hands it over, is the same number under the office's UK0000
72
+ // prefix: UK00003456789 is national mark 3456789.
73
+ gb: { name: "United Kingdom", address: (r) => addressFrom([r.applicationNumber, r.registrationNumber], /^(?:(UK\d{11})|(\d{7}))$/i, (m) => {
74
+ const n = m[1] ? m[1].toUpperCase() : `UK0000${m[2]}`;
75
+ return { number: n, href: `https://trademarks.ipo.gov.uk/ipo-tmcase/page/Results/1/${n}` };
76
+ }) },
73
77
  // The register's own search opens records at exactly this address: the application number, then the
74
78
  // registration number when there is one. Opened a real record. The older search.patentstyret.no
75
79
  // addresses now answer 410 Gone.
@@ -91,6 +95,12 @@ export const OFFICE_RECORD_PAGES = Object.freeze({
91
95
 
92
96
  const handleOffice = (uri) => (/^\/mark\/([a-z]{2,4})\//i.exec(clean(uri))?.[1] ?? "").toLowerCase();
93
97
 
98
+ // The same office under another register's code. The EUIPO's WIPO ST.3 code is EM, and a register that
99
+ // keeps it (Compumark does) names the office the table holds as EU. An alias, never a second table entry:
100
+ // the table is keyed by the offices, and one office has one address.
101
+ const OFFICE_ALIASES = Object.freeze({ em: "eu" });
102
+ const officeOf = (code) => OFFICE_ALIASES[code] ?? code;
103
+
94
104
  // A number that already carries its office's letters (FR4123456, UK00003456789) stands alone; a bare
95
105
  // one is shown with its office (CH 08133/2025), so the label always says whose number it is.
96
106
  const labelOf = (office, number) => (number ? (/^[a-z]{2}/i.test(number) ? number : `${office.toUpperCase()} ${number}`) : null);
@@ -108,7 +118,7 @@ const labelOf = (office, number) => (number ? (/^[a-z]{2}/i.test(number) ? numbe
108
118
  export function officeRecordLink(rec, uri = "") {
109
119
  if (!rec || typeof rec !== "object") return null;
110
120
  const designation = clean(rec.filingRoute) === "madrid_designation";
111
- const office = designation ? "wo" : (clean(rec.office).toLowerCase() || handleOffice(uri));
121
+ const office = designation ? "wo" : officeOf(clean(rec.office).toLowerCase() || handleOffice(uri));
112
122
  const page = OFFICE_RECORD_PAGES[office];
113
123
  const own = designation ? clean(rec.irNumber) : clean(rec.applicationNumber) || clean(rec.registrationNumber);
114
124
  const unlinked = (reason) => ({ office, label: labelOf(office, own), href: null, reason });
@@ -118,17 +128,28 @@ export function officeRecordLink(rec, uri = "") {
118
128
  return a ? { office, label: labelOf(office, a.number), href: a.href, reason: null } : unlinked("unaddressable");
119
129
  }
120
130
 
121
- /** The registers whose records carry no page of the vendor's own, so the office's page is the link. */
122
- export const VENDORS_WITHOUT_RECORD_PAGES = Object.freeze(["signa"]);
131
+ /**
132
+ * Does the run's register publish no page per record, so that the office's page is the only link a reader
133
+ * can be given? `origins` is `recordOriginsFor`'s answer for the run's register: the hosts it may put in a
134
+ * record URL. An EMPTY list is that answer, and it is the same one normalizeRecordLinks enforces, which
135
+ * strips every record URL on such a register, so none of its records reaches publish with a link of its
136
+ * own. Null means no register was named, and nothing is addressed.
137
+ *
138
+ * DECIDED BY THE REGISTER'S DECLARATION, NEVER BY A LIST OF VENDOR NAMES. A list here once named one
139
+ * vendor, on the premise that every other register's records carry links of their own. Compumark's do
140
+ * not, and its runs reached the reader with a handle and no reason, while this code knew the address.
141
+ */
142
+ const publishesNoRecordPages = (origins) => Array.isArray(origins) && origins.length === 0;
123
143
 
124
144
  /**
125
- * Every registration the findings cite, keyed by its lower-cased uri, for a run whose register is
126
- * `provider`. Returns null for any other register, so those runs render exactly as before: their records
127
- * carry links of their own. `tally` is what publish records, per office, so that a register whose numbers
128
- * never fit shows up as a count instead of as a report that quietly looks the way it always did.
145
+ * Every registration the findings cite, keyed by its lower-cased uri, for a run whose register may put a
146
+ * record URL on `origins` (see publishesNoRecordPages). Returns null for a register that publishes pages
147
+ * of its own, so those runs render exactly as before. `tally` is what publish records, per office, so
148
+ * that a register whose numbers never fit shows up as a count instead of as a report that quietly looks
149
+ * the way it always did.
129
150
  */
130
- export function recordLinksFor(findings, recordsByUri, provider) {
131
- if (!VENDORS_WITHOUT_RECORD_PAGES.includes(clean(provider).toLowerCase())) return null;
151
+ export function recordLinksFor(findings, recordsByUri, origins) {
152
+ if (!publishesNoRecordPages(origins)) return null;
132
153
  const byUri = new Map();
133
154
  const tally = { linked: {}, cited: {}, notRetrieved: 0 };
134
155
  for (const f of Array.isArray(findings) ? findings : []) {
@@ -169,17 +190,30 @@ const nameOf = (office) => OFFICE_RECORD_PAGES[office]?.name ?? office.toUpperCa
169
190
  * NO COUNT. The cards list only some findings' registrations (an off-field finding lists none), so a
170
191
  * number here could name more registrations than the reader can find on the page. The workbook lists
171
192
  * them all, each with its reason.
193
+ *
194
+ * ONE SENTENCE FOR EVERY OFFICE THE TABLE HOLDS NO ADDRESS FOR, after the per-office ones. A register
195
+ * covering the world cites filings from offices this table does not know: one Compumark knockout on the
196
+ * test instance listed sixteen of them. A sentence each would repeat one caveat sixteen times, which is
197
+ * the repeated note the owner ruled reads to a client as a broken report.
172
198
  */
173
199
  export function officeReasonSentences(byUri) {
174
200
  const keys = new Set();
175
- for (const l of byUri instanceof Map ? byUri.values() : []) if (l && !l.href) keys.add(`${l.office} ${l.reason}`);
176
- return [...keys].sort().map((k) => {
201
+ const unknown = new Set();
202
+ for (const l of byUri instanceof Map ? byUri.values() : []) {
203
+ if (!l || l.href) continue;
204
+ if (l.reason === "unknown-office") unknown.add(l.office);
205
+ else keys.add(`${l.office} ${l.reason}`);
206
+ }
207
+ const each = [...keys].sort().map((k) => {
177
208
  const [office, reason] = k.split(" ");
178
209
  const name = nameOf(office);
179
210
  if (reason === "no-page") return `${name}: the register publishes no page for a single record, so its registrations are cited by number.`;
180
- if (reason === "unknown-office") return `${name}: we hold no page address for this register, so its registrations are cited by number.`;
181
211
  return `${name}: registrations whose numbers are not in the form the register's page address takes are cited by number, not linked.`;
182
212
  });
213
+ const codes = [...unknown].sort().map(nameOf);
214
+ if (codes.length === 1) each.push(`${codes[0]}: we hold no page address for this register, so its registrations are cited by number.`);
215
+ else if (codes.length > 1) each.push(`${codes.join(", ")}: we hold no page address for these registers, so their registrations are cited by number.`);
216
+ return each;
183
217
  }
184
218
 
185
219
  /**
@@ -215,20 +249,21 @@ export function linkCellFor(registrations, byUri) {
215
249
 
216
250
  /**
217
251
  * A knockout's listed filings, each addressed at its office's page, for a listing taken on a register
218
- * with no record pages of its own. `doc` is register-records.json, and each filing that carries an office
219
- * number gains an `officeLink` in place, as normalizeRegisterRecordLinks rewrites links in place, so the
220
- * report, the workbook and report-data.json all state the same link. Returns null for any other
221
- * register, whose filings render exactly as before.
252
+ * with no record pages of its own. `doc` is register-records.json and `origins` is `recordOriginsFor`'s
253
+ * answer for the register the sidecar names (see publishesNoRecordPages). Each filing that carries an
254
+ * office number gains an `officeLink` in place, as normalizeRegisterRecordLinks rewrites links in place, so
255
+ * the report, the workbook and report-data.json all state the same link. Returns null for a register that
256
+ * publishes pages of its own, whose filings render exactly as before.
222
257
  *
223
258
  * A FILING WITH NO OFFICE NUMBER IS LEFT AS IT WAS, and shows as it always did. Every listing taken
224
259
  * before the numbers were kept is that case, so an archived knockout republishes unchanged. An
225
260
  * `officeLink` found already on the sidecar is dropped first: the link is set here, from the numbers, and
226
261
  * nowhere else.
227
262
  */
228
- export function addressListedFilings(doc) {
263
+ export function addressListedFilings(doc, origins) {
229
264
  const records = (doc?.marks ?? []).flatMap((m) => m?.records ?? []).filter((r) => r && typeof r === "object");
230
265
  for (const r of records) delete r.officeLink;
231
- if (!VENDORS_WITHOUT_RECORD_PAGES.includes(clean(doc?.provider).toLowerCase())) return null;
266
+ if (!publishesNoRecordPages(origins)) return null;
232
267
  const tally = { linked: {}, cited: {}, noNumber: 0 };
233
268
  for (const r of records) {
234
269
  const link = officeRecordLink(r, r.recordId);
@@ -24,8 +24,8 @@
24
24
  // server-owned (monotonic per save); EVERY write re-runs the SAME load-time validator the driver uses
25
25
  // (search-policy.mjs validateRecipe), so the UI can never persist a recipe the driver would later reject;
26
26
  // free text gets the profiles.mjs anti-rule prose guards (a recipe must never smuggle a rating rule in as
27
- // prose — levels select machinery, never rating authority); a recipe belongs to a REAL customer on the
28
- // profile roster (never "generic"). The routing core (`makeRecipeService`) is fs-only + injected
27
+ // prose — levels select machinery, never rating authority); a recipe belongs to a company on the
28
+ // profile roster, Generic included. The routing core (`makeRecipeService`) is fs-only + injected
29
29
  // git/audit so it unit-tests offline; auth + rate-limit are wired in the bootstrap.
30
30
 
31
31
  import "../shared/env-local.mjs"; // — FIRST: the CLEAROTRON_* translation must land before any
@@ -61,6 +61,13 @@ export const componentCatalog = () =>
61
61
  export function makeRecipeService({
62
62
  recipesDir,
63
63
  profileDir = undefined, // undefined ⇒ profiles.mjs default dir
64
+ // THE ROSTER THE PORTAL SHOWS, read the way profile-service reads it (its `readLayered` note). The
65
+ // portal passes true: the deployment's store, overlay over base, with `generic` falling through from
66
+ // the product. Read as an explicit directory instead, a store holding no `generic.json` — every fresh
67
+ // install and every demo — throws "generic.json is REQUIRED", the catch below turned that into "no
68
+ // such company", and saved searches answered 404 for every company on the install, one created a
69
+ // minute earlier included, while the same company's projects answered 200.
70
+ readLayered = false,
64
71
  loadRecipes = loadRecipesDefault,
65
72
  loadProfiles = loadProfilesDefault,
66
73
  writeRecipe = defaultWriteRecipe,
@@ -69,8 +76,10 @@ export function makeRecipeService({
69
76
  } = {}) {
70
77
  const rosterHas = (customer) => {
71
78
  try {
72
- const profiles = profileDir === undefined ? loadProfiles({ force: true }) : loadProfiles({ dir: profileDir, force: true });
73
- return profiles.has(customer) && customer !== "generic";
79
+ const profiles = readLayered || profileDir === undefined ? loadProfiles({ force: true }) : loadProfiles({ dir: profileDir, force: true });
80
+ // GENERIC OWNS SAVED SEARCHES LIKE ANY COMPANY (owner ruling, 2026-09-11). It was refused here as not a
81
+ // real customer, and a fresh install, which holds Generic alone, opened Custom searches on an error.
82
+ return profiles.has(customer);
74
83
  } catch { return false; } // an unreadable roster fails CLOSED here — writes need a verifiable owner
75
84
  };
76
85
  const listRow = ([key, r]) => {
@@ -122,7 +131,7 @@ export function makeRecipeService({
122
131
  return { status: 404, json: { error: "not_found" } };
123
132
  if (method !== "POST") return { status: 405, json: { error: "method_not_allowed" } };
124
133
  if (!rosterHas(customer))
125
- return { status: 400, json: { error: `customer "${customer}" is not on the profile roster — a saved search belongs to a real customer (create the profile first; "generic" cannot own recipes)` } };
134
+ return { status: 400, json: { error: `customer "${customer}" is not on the profile roster — a saved search belongs to a company on the roster (create the company first)` } };
126
135
 
127
136
  const incoming = body.recipe;
128
137
  if (!incoming || typeof incoming !== "object" || Array.isArray(incoming))
@@ -43,3 +43,17 @@ export function activeRecordOrigins(env = process.env) {
43
43
  const id = String(env?.CLEAROTRON_DATABASE ?? "").trim().toLowerCase();
44
44
  return id ? recordOriginsFor(id) : null;
45
45
  }
46
+
47
+ /**
48
+ * The record origins of the register a stored artefact NAMES — a knockout's listing, a run's fetch
49
+ * receipts — or null when it names none.
50
+ *
51
+ * EMPTY IS "NOBODY SAID", and it turns the gate off, as normalizeRegisterRecordLinks reads the same
52
+ * field: an artefact that never recorded its register cannot be judged against one. An id this table
53
+ * does not know is an answer, and resolves as recordOriginsFor resolves it, to no origins, because no
54
+ * host of its can be established as legitimate.
55
+ */
56
+ export function declaredRecordOrigins(provider) {
57
+ const id = String(provider ?? "").trim().toLowerCase();
58
+ return id ? recordOriginsFor(id) : null;
59
+ }