clearotron 0.3.0-beta.1 → 0.3.0-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 (63) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +12 -10
  4. package/README.md +3 -2
  5. package/bin/brandowner.mjs +94 -1
  6. package/bin/onboard.mjs +156 -55
  7. package/bin/passphrase.mjs +23 -4
  8. package/bin/start.mjs +177 -38
  9. package/build-info.json +2 -2
  10. package/docs/architecture/04-configuration-reference.md +5 -2
  11. package/docs/architecture/05-config-governance.md +3 -2
  12. package/driver/CHANGELOG.md +95 -0
  13. package/driver/demo-posture.mjs +59 -8
  14. package/driver/driver.config.mjs +36 -7
  15. package/driver/engine/child-record.mjs +93 -0
  16. package/driver/findings-model.mjs +6 -1
  17. package/driver/knockout-assess-record.mjs +6 -3
  18. package/driver/package.json +1 -1
  19. package/driver/portal-local-auth.mjs +98 -3
  20. package/driver/portal-service.mjs +65 -29
  21. package/driver/publish/knockout.mjs +12 -1
  22. package/driver/publish/office-record-links.mjs +61 -10
  23. package/driver/publish/render-knockout.mjs +31 -11
  24. package/driver/publish/xlsx.mjs +33 -1
  25. package/driver/register-records.mjs +6 -0
  26. package/driver/run-requirements.mjs +24 -1
  27. package/driver/runner.mjs +10 -5
  28. package/driver/skills/knockout-assess/SKILL.md +1 -1
  29. package/driver/suite-census.json +174 -30
  30. package/driver/unit-inventory.mjs +109 -5
  31. package/driver/updater-identity.mjs +178 -0
  32. package/driver/usage-ledger.mjs +5 -5
  33. package/mcp-server/CHANGELOG.md +6 -0
  34. package/mcp-server/http-server.mjs +16 -13
  35. package/mcp-server/lib/driver.mjs +7 -0
  36. package/mcp-server/lib/knockout.mjs +14 -2
  37. package/mcp-server/lib/ops.mjs +38 -16
  38. package/mcp-server/package.json +2 -2
  39. package/mcp-server/server.mjs +1 -1
  40. package/package.json +1 -1
  41. package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CcFjgM78.js} +32 -17
  42. package/portal-ui/dist/assets/{index-KpytsmNH.css → index-CsCuPshD.css} +7 -2
  43. package/portal-ui/dist/index.html +2 -2
  44. package/portal-ui/package.json +3 -3
  45. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  46. package/providers/oauth-mcp-bridge/package.json +2 -2
  47. package/scripts/changelog-plain-language.mjs +7 -30
  48. package/scripts/e2e.mjs +13 -2
  49. package/scripts/env-audit.mjs +10 -3
  50. package/scripts/env-classify.mjs +205 -12
  51. package/scripts/live-surface-check.mjs +74 -2
  52. package/scripts/plain-language-rules.mjs +103 -0
  53. package/scripts/release-note-required.mjs +118 -24
  54. package/scripts/release-notes-lint.mjs +22 -43
  55. package/scripts/release-version.mjs +59 -0
  56. package/scripts/revisit-render-check.mjs +6 -2
  57. package/scripts/text-difference.mjs +22 -0
  58. package/shared/env-local.mjs +25 -2
  59. package/shared/names-in-force.mjs +1 -0
  60. package/shared/reap-on-exit.mjs +27 -14
  61. package/shared/store-in-repo.mjs +147 -0
  62. package/shared/withheld-paths-access.mjs +6 -6
  63. package/shared/yes-no-echo.mjs +35 -0
@@ -131,7 +131,6 @@ export function recordLinksFor(findings, recordsByUri, provider) {
131
131
  if (!VENDORS_WITHOUT_RECORD_PAGES.includes(clean(provider).toLowerCase())) return null;
132
132
  const byUri = new Map();
133
133
  const tally = { linked: {}, cited: {}, notRetrieved: 0 };
134
- const bump = (o, k) => { o[k] = (o[k] ?? 0) + 1; };
135
134
  for (const f of Array.isArray(findings) ? findings : []) {
136
135
  for (const r of f?.owner?.registrations ?? []) {
137
136
  const key = clean(r?.uri).toLowerCase();
@@ -140,17 +139,26 @@ export function recordLinksFor(findings, recordsByUri, provider) {
140
139
  if (!rec) { byUri.set(key, null); tally.notRetrieved += 1; continue; }
141
140
  const link = officeRecordLink(rec, key);
142
141
  byUri.set(key, link);
143
- if (link.href) bump(tally.linked, link.office);
144
- else bump((tally.cited[link.office] ??= {}), link.reason);
142
+ tallyLink(tally, link);
145
143
  }
146
144
  }
145
+ return { byUri, tally, summary: summaryOf(tally, `record not retrieved ${tally.notRetrieved}`) };
146
+ }
147
+
148
+ const bump = (o, k) => { o[k] = (o[k] ?? 0) + 1; };
149
+ function tallyLink(tally, link) {
150
+ if (link.href) bump(tally.linked, link.office);
151
+ else bump((tally.cited[link.office] ??= {}), link.reason);
152
+ }
153
+
154
+ /** The log line for a tally: how many were linked and how many cited by number, per office and reason. */
155
+ function summaryOf(tally, tail) {
147
156
  const sum = (xs) => xs.reduce((a, b) => a + b, 0);
148
157
  const linked = Object.entries(tally.linked).map(([o, n]) => `${o} ${n}`).join(", ");
149
158
  const cited = Object.entries(tally.cited).flatMap(([o, rs]) => Object.entries(rs).map(([r, n]) => `${o} ${n} ${r}`)).join(", ");
150
- const summary = `linked ${sum(Object.values(tally.linked))}${linked ? ` (${linked})` : ""}`
159
+ return `linked ${sum(Object.values(tally.linked))}${linked ? ` (${linked})` : ""}`
151
160
  + ` · cited by number ${sum(Object.values(tally.cited).flatMap((rs) => Object.values(rs)))}${cited ? ` (${cited})` : ""}`
152
- + ` · record not retrieved ${tally.notRetrieved}`;
153
- return { byUri, tally, summary };
161
+ + ` · ${tail}`;
154
162
  }
155
163
 
156
164
  const nameOf = (office) => OFFICE_RECORD_PAGES[office]?.name ?? office.toUpperCase();
@@ -174,6 +182,26 @@ export function officeReasonSentences(byUri) {
174
182
  });
175
183
  }
176
184
 
185
+ /**
186
+ * What a linked registration number opens, when one is linked, then the reasons per office, as the
187
+ * knockout says them under its listed filings. They are the sentences the clearance says in Scope; its
188
+ * renderer is frozen (render-frozen.test.mjs) and keeps its own copy of the first.
189
+ */
190
+ export function officeLinkSentences(byUri) {
191
+ if (!(byUri instanceof Map)) return [];
192
+ const linked = [...byUri.values()].some((l) => l?.href);
193
+ return [...(linked ? ["A registration number shown as a link opens the office’s own page for that record."] : []),
194
+ ...officeReasonSentences(byUri)];
195
+ }
196
+
197
+ /** Why a registration is cited by number rather than linked, in the workbook's words. */
198
+ export function reasonCellFor(link) {
199
+ const name = nameOf(link.office);
200
+ if (link.reason === "no-page") return `No page for a single record at this register (${name}); cited by number`;
201
+ if (link.reason === "unknown-office") return `No page address held for this register (${name}); cited by number`;
202
+ return `Number not in the form this register's page address takes (${name}); cited by number`;
203
+ }
204
+
177
205
  /** The workbook's Link cell for a finding: its first linked registration, else the first stated reason. */
178
206
  export function linkCellFor(registrations, byUri) {
179
207
  if (!(byUri instanceof Map)) return "";
@@ -182,8 +210,31 @@ export function linkCellFor(registrations, byUri) {
182
210
  if (linked) return linked.href;
183
211
  const first = links[0];
184
212
  if (!first) return "";
185
- const name = nameOf(first.office);
186
- if (first.reason === "no-page") return `No page for a single record at this register (${name}); cited by number`;
187
- if (first.reason === "unknown-office") return `No page address held for this register (${name}); cited by number`;
188
- return `Number not in the form this register's page address takes (${name}); cited by number`;
213
+ return reasonCellFor(first);
214
+ }
215
+
216
+ /**
217
+ * 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.
222
+ *
223
+ * A FILING WITH NO OFFICE NUMBER IS LEFT AS IT WAS, and shows as it always did. Every listing taken
224
+ * before the numbers were kept is that case, so an archived knockout republishes unchanged. An
225
+ * `officeLink` found already on the sidecar is dropped first: the link is set here, from the numbers, and
226
+ * nowhere else.
227
+ */
228
+ export function addressListedFilings(doc) {
229
+ const records = (doc?.marks ?? []).flatMap((m) => m?.records ?? []).filter((r) => r && typeof r === "object");
230
+ for (const r of records) delete r.officeLink;
231
+ if (!VENDORS_WITHOUT_RECORD_PAGES.includes(clean(doc?.provider).toLowerCase())) return null;
232
+ const tally = { linked: {}, cited: {}, noNumber: 0 };
233
+ for (const r of records) {
234
+ const link = officeRecordLink(r, r.recordId);
235
+ if (!link?.label) { tally.noNumber += 1; continue; }
236
+ r.officeLink = link;
237
+ tallyLink(tally, link);
238
+ }
239
+ return { tally, summary: summaryOf(tally, `no office number ${tally.noNumber}`) };
189
240
  }
@@ -45,6 +45,7 @@ import {
45
45
  import { SUMMARY_BLOCK_LINE, parseSummaryBlocks } from '../../shared/summary-blocks.mjs';
46
46
  import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine } from '../register-count.mjs';
47
47
  import { RECORD_BASIS, recordsForMark, recordsLine } from '../register-records.mjs';
48
+ import { officeLinkSentences } from './office-record-links.mjs';
48
49
  import { knockoutFindingViews, splitKnockoutNotes } from '../findings-model.mjs';
49
50
  import { demoBannerHtml } from './render.mjs'; // — the SAME banner the clearance template renders, not a second wording
50
51
  // — the two facts the register card is allowed to read off a raw record, and NEITHER is minted
@@ -78,6 +79,16 @@ const isHttpUrl = (u) => /^https?:\/\//i.test(String(u ?? '').trim());
78
79
  // beside it — noopener severs window.opener, noreferrer withholds the run's URL from the evidence host.
79
80
  const linkOrText = (u) => (isHttpUrl(u) ? `<a href="${escAttr(u)}" target="_blank" rel="noopener noreferrer">${esc(u)}</a>` : esc(u));
80
81
 
82
+ // A listed filing's record as its office publishes it, where the run's register has no page of its own:
83
+ // publish sets `officeLink` from the filing's own numbers (office-record-links.mjs). The label is the
84
+ // office and the number, linked where the office has a page. The handle names the vendor's record, not
85
+ // the office's, and a reader can look it up nowhere. A filing with no number shows `fallback`, as before.
86
+ const officeRecordCell = (r, fallback) => {
87
+ const l = r?.officeLink;
88
+ if (!l?.label) return fallback;
89
+ return l.href ? `<a href="${escAttr(l.href)}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a>` : esc(l.label);
90
+ };
91
+
81
92
  // Minimal inline markdown, escaped FIRST. The old renderer ran esc() and nothing else, so a batch
82
93
  // summary reading `BRIMSTONE rates **High (Classes 9, 28, 41)**.` rendered its asterisks verbatim —
83
94
  // the model writes markdown because every other surface it feeds renders markdown.
@@ -189,7 +200,6 @@ const KO_CSS = `
189
200
  /* The reviewer-notes legend. NOT .ko-legend — that name is taken by the framework attribution row
190
201
  above, and reusing it would restyle the caption. It names the purple convention report.css already
191
202
  draws for .internal, so the colour is stated once and never re-specified. */
192
- .ko-refnote{margin:0;padding:11px 24px 0;font-size:12px;color:#6a2b6e;font-style:italic}
193
203
  /* The notes sit inside a mark's column, so the shared .internal block's bullets keep the column's
194
204
  own list indent and do not fight the paragraph above them. */
195
205
  .ko-row .internal .ko-bul{margin:0 0 4px}
@@ -603,7 +613,7 @@ function filingsSection(marks, registerRecords) {
603
613
  <td>${esc(r.status ?? '—')}</td>
604
614
  <td>${esc((r.classes ?? []).join(', ') || '—')}</td>
605
615
  <td>${esc(r.territory ?? '—')}</td>
606
- <td class="ko-findev">${r.url ? linkOrText(r.url) : esc(r.recordId ?? '—')}</td>
616
+ <td class="ko-findev">${officeRecordCell(r, r.url ? linkOrText(r.url) : esc(r.recordId ?? '—'))}</td>
607
617
  </tr>`).join('');
608
618
  // "First N" WAS TRUE AND IS NOT ANY MORE, and saying it of a ranked table would be worse than the
609
619
  // cap: it invites the reader to take the sample for the top of the provider's list. Say the count
@@ -620,7 +630,15 @@ function filingsSection(marks, registerRecords) {
620
630
  <p class="ko-basis">${esc(line ?? '')}${esc(more)}</p>
621
631
  </div>`;
622
632
  }).join('');
623
- return `<div class="panel ko-glance">${blocks}<p class="ko-basis">${esc(RECORD_BASIS)}</p></div>`;
633
+ return `<div class="panel ko-glance">${blocks}<p class="ko-basis">${esc(RECORD_BASIS)}${officeLinkNote(registerRecords)}</p></div>`;
634
+ }
635
+
636
+ // Once, under the filings: what a linked number opens and, per office, why the rest are cited by number.
637
+ // The clearance's own sentences, said once for the reason it says them once: a note repeated beside every
638
+ // filing reads as a broken report. Empty unless a filing carries an office number.
639
+ function officeLinkNote(registerRecords) {
640
+ const links = (registerRecords?.marks ?? []).flatMap((m) => m?.records ?? []).map((r) => r?.officeLink).filter(Boolean);
641
+ return officeLinkSentences(new Map(links.map((l, i) => [i, l]))).map((s) => ` ${esc(s)}`).join('');
624
642
  }
625
643
 
626
644
  // The tier-absence line ( part 2). A knockout whose product bought no register step used to render
@@ -1069,7 +1087,6 @@ function sourceChips(v) {
1069
1087
  // already defines — the same one the clearance report uses for this material — so a reader can see whose
1070
1088
  // voice a line is in. Merging them into the client-voiced body would make the reviewer's asides read as
1071
1089
  // findings about the mark, which is the one way this ruling could produce a worse document.
1072
- const REVIEWER_NOTES_LEGEND = 'Purple notes are for the reviewing lawyer. Remove them before this goes to the client.';
1073
1090
 
1074
1091
  // The clearance lane's own label, copied rather than re-worded. One spelling across
1075
1092
  // both products is the point: a reader who has seen it on a clearance report knows what it means here.
@@ -1155,7 +1172,7 @@ function registerFindingBlock(v, markIndex, reads = null, framework = null, owne
1155
1172
  const useCheckSource = useCheck?.source ?? null;
1156
1173
  const meta = [r.owner ? esc(r.owner) : 'proprietor not stated', r.territory ? esc(r.territory) : null]
1157
1174
  .filter(Boolean).join(' · ');
1158
- const receipt = isHttpUrl(r.url) ? linkOrText(r.url) : esc(r.recordId ?? 'no record address supplied');
1175
+ const receipt = officeRecordCell(r, isHttpUrl(r.url) ? linkOrText(r.url) : esc(r.recordId ?? 'no record address supplied'));
1159
1176
  return `<div class="card ko-find" data-ko-mark="${Number(markIndex)}" data-ko-ord="${Number(v.ordinal)}">
1160
1177
  <div class="top">
1161
1178
  <div class="rail" style="background:var(${stop ?? '--faint'})"></div>
@@ -1425,12 +1442,12 @@ function analysisSection(marks, framework, { registerCounts = null, probeRan = f
1425
1442
  </div>
1426
1443
  </div>`;
1427
1444
  }).join('');
1428
- // The legend rides the panel and only when a note is actually on it — a standing sentence explaining a
1429
- // colour no reader can see would be the report describing a convention it did not use.
1430
- const anyNotes = marks.some((m) => (Array.isArray(m?.purpleNotes) ? m.purpleNotes : [])
1431
- .some((n) => String(n ?? '').trim()));
1432
- const legend = anyNotes ? `<p class="ko-refnote">${esc(REVIEWER_NOTES_LEGEND)}</p>` : '';
1433
- return `<div class="panel ko-glance">${legend}${cards}</div>`;
1445
+ // NO LEGEND. A standing line saying "remove these before this goes to the client" was an instruction
1446
+ // to the reader printed on the document itself, and the owner ruled it out: a report that explains its
1447
+ // own conventions in a caveat is telling the reader how to handle it rather than what was found. The
1448
+ // notes and their "For the reviewing lawyer" label stay — that label names a reader, which is a fact
1449
+ // about the note; the legend told somebody what to do about it, which is not.
1450
+ return `<div class="panel ko-glance">${cards}</div>`;
1434
1451
  }
1435
1452
 
1436
1453
  // The method line names EXACTLY what ran, and its wording is doctrine — with counts on the page, "register
@@ -1885,6 +1902,9 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
1885
1902
  mark: r.mark, owner: r.owner, status: r.status, classes: r.classes ?? [],
1886
1903
  territory: r.territory, matchedForm: r.matchedForm, matchedBasis: r.matchedBasis,
1887
1904
  recordId: r.recordId, url: r.url,
1905
+ // The office's page for the filing, or its office and number and why it is not a link: the
1906
+ // report's own cell, as data. Only where the run's register has no record pages of its own.
1907
+ ...(r.officeLink ? { officeRecord: { label: r.officeLink.label, href: r.officeLink.href, reason: r.officeLink.reason } } : {}),
1888
1908
  })),
1889
1909
  // Which searches did NOT answer, by name. A consumer that lists only `records` would report
1890
1910
  // a partial listing as a complete one.
@@ -283,7 +283,7 @@ export function searchRows(auditParsed, { findings = [], joinedTerms = null, reg
283
283
 
284
284
  const rows = [];
285
285
  let i = 0;
286
- const push = r => rows.push({ '#': ++i, ...r });
286
+ const push = r => rows.push({ '#': ++i, ...r, Result: readerWords(r.Result), Note: readerWords(r.Note) }); // plain words, after each branch below has classified its row from the engine's own
287
287
 
288
288
  if (reg.length) {
289
289
  push({ 'Search term / variant': 'REGISTER — vendor register, worldwide', Scope: '', Result: '', Outcome: '', Note: '', _section: true });
@@ -640,6 +640,38 @@ export async function buildAudit(contract, auditParsed, outPath, mark = '', fm =
640
640
  };
641
641
  }
642
642
 
643
+ // READER_WORDS — the plain word a reader of "What was searched" gets in place of each engine word BANNED
644
+ // names. That sheet's Result and Note cells carry the search log's own prose, model-authored and already
645
+ // written on every archived run, so the words are put right where each row is written (`push` in
646
+ // `searchRows()`) instead of being left to the advisory gate. The gate still runs; a hit on this sheet is
647
+ // now a name, or a word this table is missing.
648
+ //
649
+ // Lower case only. In these cells a capitalised or upper-case word is a name — a mark, an owner, a
650
+ // platform — and a rewritten name misstates what was searched, so names stay as written. HTTP keeps to
651
+ // that rule with one exception, which is never a name: before a status code it goes in any case, so
652
+ // "provider-rejected (HTTP 429)" reads "provider-rejected (429)" while a mark called HTTP HOUSE keeps its
653
+ // name. A web address loses its scheme in any case and keeps its host and path. has_more is plainNote's.
654
+ //
655
+ // Kept down here, below every line the rest of the tree cites in this file by number.
656
+ export const READER_WORDS = Object.freeze({
657
+ receipt: 'record', receipts: 'records',
658
+ lint: 'check', tripwire: 'check', tripwires: 'checks',
659
+ composite: 'combined', scatter: 'chart', cache: 'stored copy',
660
+ quadrant: 'position', quadrants: 'positions', coordinate: 'position', coordinates: 'positions',
661
+ meter: 'measure', meters: 'measures',
662
+ });
663
+ const READER_WORD = new RegExp(`\\b(?:${Object.keys(READER_WORDS).join('|')})\\b`, 'gi');
664
+
665
+ function readerWords(s) {
666
+ return String(s ?? '')
667
+ .replace(/\bhttps?:\/\//gi, '')
668
+ .replace(/\bhttps?\s+(?=\d{3}\b)/gi, '')
669
+ .replace(/\bhttps?\b\s*/g, '')
670
+ .replace(READER_WORD, (w) => (w === w.toLowerCase() ? READER_WORDS[w] : w))
671
+ .replace(/\s+/g, ' ')
672
+ .trim();
673
+ }
674
+
643
675
  /**
644
676
  * validateAudit — the build gate. Reads the assembled workbook back and asserts the spec's MUST-BE-TRUE list.
645
677
  * Returns { ok, violations[] }. Exported so tests assert the same contract the build enforces.
@@ -143,6 +143,12 @@ function toRecord(row, { term, basis, provider }) {
143
143
  territory,
144
144
  applicationDate: row?.application_date ?? null,
145
145
  registrationDate: row?.registration_date ?? null,
146
+ // The office's own numbers, where the provider hands them over. On a register with no record pages of
147
+ // its own, publish addresses the office's page for the filing from these (office-record-links.mjs).
148
+ applicationNumber: row?.application_number ?? null,
149
+ registrationNumber: row?.registration_number ?? null,
150
+ irNumber: row?.ir_number ?? null,
151
+ filingRoute: row?.filing_route ?? null,
146
152
  // WHICH QUESTION FOUND IT. Without this a reader cannot tell a filing on the name from a filing on
147
153
  // a generated variant, and the two mean very different things to the person deciding on the name.
148
154
  matchedForm: term,
@@ -186,7 +186,7 @@ export function missingRequirements(env = {}, tables = {}) {
186
186
  *
187
187
  * Returns null when nothing blocks, so a caller cannot mistake "configured" for "could not look".
188
188
  */
189
- export function orderTimeRefusal(env = {}, tables = {}, { envFile = null, readFile = null } = {}) {
189
+ export function orderTimeRefusal(env = {}, tables = {}, { envFile = null, readFile = null, startFile = null } = {}) {
190
190
  const missing = missingRequirements(env, tables).atOrder;
191
191
  if (!missing.length) return null;
192
192
  const names = missing.map((r) => r.name);
@@ -197,12 +197,21 @@ export function orderTimeRefusal(env = {}, tables = {}, { envFile = null, readFi
197
197
  // was told to create a file nothing on that box reads — and the one actually read went unnamed.
198
198
  // `readFile` is the file THIS process loaded at start; it is null under a unit, where the unit's own
199
199
  // EnvironmentFile is the only configuration and `envFile` alone is the true answer.
200
+ //
201
+ // AND A RUNNER THAT `clearotron start` STARTED READ NO FILE EITHER. The supervisor read the install's own
202
+ // file and handed its values down with CLEAROTRON_NO_ENV_FILE=1, so `readFile` is null there exactly as
203
+ // it is under a unit, and this sentence named the units' file: on a box with no units, a file that does
204
+ // not exist, while the file the values came from went unnamed. Measured on a fresh install, 2026-09-10.
205
+ // `startFile` is the file that supervisor read. It reaches the runner as a command-line flag that a unit's
206
+ // fixed ExecStart never carries, so it is set exactly when `clearotron start` is the parent.
200
207
  const cmd = "`clearotron install` in a terminal, which writes them for you";
201
208
  const both = readFile && envFile && readFile !== envFile;
202
209
  const one = readFile || envFile;
203
210
  const where = both
204
211
  ? ` Set them in either of these — both reach a run:\n ${readFile} — the file this runner read when it started\n`
205
212
  + ` ${envFile} — the file background units read\n then restart, or run ${cmd}.`
213
+ : !readFile && startFile
214
+ ? ` Set them in ${startFile} — the file \`clearotron start\` read when it started this runner — then restart it, or run ${cmd}.`
206
215
  : one
207
216
  ? ` Set them in ${one} and restart, or run ${cmd}.`
208
217
  : " Run `clearotron install` in a terminal to configure them, or set them in the file this install's units read.";
@@ -217,3 +226,17 @@ export function orderTimeRefusal(env = {}, tables = {}, { envFile = null, readFi
217
226
  + "nothing has been charged. Please contact whoever administers it.",
218
227
  };
219
228
  }
229
+
230
+ // THE ENV FILE `clearotron start` READ, when it started this runner: the one file a refusal can honestly
231
+ // name for a runner that read none itself (`startFile` above). A command-line flag rather than a variable,
232
+ // for two reasons. A unit's ExecStart is fixed at `--watch` and never carries it, so a runner holding it
233
+ // was started by that command. And a variable read by product code belongs in the environment catalogue,
234
+ // which describes settings an operator makes, and this is not one. `bin/start.mjs` puts it on the
235
+ // worker's command line, and `driver/runner.mjs` reads it off its own.
236
+ export const START_ENV_FILE_FLAG = "--start-env-file=";
237
+
238
+ /** The path `argv` carries in `--start-env-file=<path>`, or null when it carries none or an empty one. PURE. */
239
+ export function startEnvFileOf(argv = []) {
240
+ const flag = argv.find((t) => String(t).startsWith(START_ENV_FILE_FLAG));
241
+ return flag ? flag.slice(START_ENV_FILE_FLAG.length) || null : null;
242
+ }
package/driver/runner.mjs CHANGED
@@ -27,8 +27,8 @@ import { driverDir, ensureDriverDir } from "../shared/driver-dir.mjs"; // —
27
27
  // claim-sidecar names, because a harness check retyped four of them from memory and false-alarmed on the
28
28
  // other nine. Behaviour here is unchanged: the same object and the same three suffixes, sourced.
29
29
  import { isLiveQueueMarker, PROSE_PARTS, CLAIM_SIDECAR_SUFFIXES, TERMINAL_QUEUE_SUFFIXES } from "./queue-markers.mjs";
30
- import { matterLedgerPath } from "./usage-ledger.mjs"; // ONE ledger-path calculation, shared with the portal pre-check
31
- import { orderTimeRefusal } from "./run-requirements.mjs"; // one authority for what a run needs, and when it is asked for
30
+ import { matterLedgerPath, DEFAULT_CLIENT_DAILY_RUNS } from "./usage-ledger.mjs"; // ONE ledger-path calculation, shared with the portal pre-check
31
+ import { orderTimeRefusal, START_ENV_FILE_FLAG, startEnvFileOf } from "./run-requirements.mjs"; // one authority for what a run needs, and when it is asked for
32
32
  import { unitEnvPath, envFileRead } from "../shared/env-local.mjs"; // the file the units read, named by its one author
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { config, preflightDeploymentUrls } from "./driver.config.mjs";
@@ -235,7 +235,11 @@ function recordMatter(qdir, entry) {
235
235
  // which reads as the product being broken rather than as a fair-use wall. The risk this exists to stop is
236
236
  // unbounded spend by an unconfigured account, and 20 stops that just as completely. It is the DEFAULT, so
237
237
  // any account that needs a different number says so in its own runCaps block.
238
- export const DEFAULT_CLIENT_DAILY_RUNS = 20;
238
+ //
239
+ // ONE DECLARATION. The number lives in usage-ledger.mjs, the leaf a request path imports without pulling
240
+ // the runner in, and is re-exported here for the gate and its tests. Two declarations held equal by a
241
+ // test is how the screen comes to advertise one allowance while the gate enforces another.
242
+ export { DEFAULT_CLIENT_DAILY_RUNS };
239
243
 
240
244
  export function checkRunCaps({ account, caps, queueDirs, inHandTagged = true, now = Date.now(), clientRun = false, organisation = null }) {
241
245
  // GENERIC IS CAPPED LIKE ANY COMPANY, one lane per organisation (ruling 2026-09-10: "every
@@ -768,6 +772,7 @@ async function backstopFailureNotice({ res, job, agentId, base, codename, studio
768
772
  // WHAT IT MUST NEVER DO is what F41 did: fail at the first stage and tell the client "Clearotron has
769
773
  // been notified" on a box with no outbox. `failAtIntake` writes the honest refusal and the run never
770
774
  // starts, so nothing is spent and nothing is promised.
775
+
771
776
  let __runTables = null;
772
777
  async function runTables() {
773
778
  // AT CALL TIME, never a static import. `driver/run-requirements.mjs`'s header states the reason and it
@@ -856,7 +861,7 @@ async function claimAndPrep(jsonFile, qdir, agentId) {
856
861
  }
857
862
  // ── IS THIS BOX CONFIGURED TO SEARCH AT ALL? See the header above claimAndPrep.
858
863
  {
859
- const refusal = orderTimeRefusal(process.env, await runTables(), { envFile: unitEnvPath(), readFile: envFileRead() });
864
+ const refusal = orderTimeRefusal(process.env, await runTables(), { envFile: unitEnvPath(), readFile: envFileRead(), startFile: startEnvFileOf(process.argv) });
860
865
  if (refusal) {
861
866
  note(`[runner] ${base} REFUSED at order time — this install is not configured to run a search: ${refusal.names.join(", ")}`);
862
867
  await failAtIntake(procPath, qdir, base, agentId, job,
@@ -2404,7 +2409,7 @@ if (isEntrypoint(import.meta.url)) {
2404
2409
  // the exact silence this whole change exists to remove. (pipeline.mjs's RETIRED_FLAGS table makes the same
2405
2410
  // argument about a switch that degrades quietly into whatever the remaining arguments mean.)
2406
2411
  const argv = process.argv.slice(2);
2407
- const unknown = argv.filter((t) => t !== "--watch");
2412
+ const unknown = argv.filter((t) => t !== "--watch" && !t.startsWith(START_ENV_FILE_FLAG)); // the second is start's own, see startEnvFileOf
2408
2413
  if (unknown.length) {
2409
2414
  console.error(`error: unknown argument ${unknown[0]}\nusage: node runner.mjs [--watch]\n --watch keep polling instead of draining once — the substitute for the systemd .path/.timer units\n on a machine that has none. Without it this drains the queue once and exits, which is what\n systemd invokes.`);
2410
2415
  process.exit(2);
@@ -310,7 +310,7 @@ line, which describes the card and claims nothing about the rating. So:
310
310
  "degraded": null } ] }
311
311
  ```
312
312
 
313
- **The finding record — closed keys, all eight, no others.** A key this list does not name is refused
313
+ **The finding record — closed keys, all nine, no others.** A key this list does not name is refused
314
314
  (the validator is `findings-model.mjs validateKnockoutFinding`, and it runs at the chunk and again on
315
315
  the merged artifact):
316
316