clearotron 0.3.2-beta.7 → 0.3.2-beta.8

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 (85) hide show
  1. package/.env.example +24 -23
  2. package/INSTALL.md +142 -75
  3. package/README.md +3 -3
  4. package/bin/onboard.mjs +637 -216
  5. package/bin/start.mjs +133 -23
  6. package/bin/update.mjs +58 -11
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +26 -11
  9. package/docs/architecture/05-config-governance.md +17 -7
  10. package/driver/CHANGELOG.md +76 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/contract-arm2-baseline.json +1 -3
  14. package/driver/contract-e3-backlog.mjs +26 -26
  15. package/driver/contract-vocabulary.mjs +44 -10
  16. package/driver/door-gates.mjs +41 -7
  17. package/driver/driver.config.mjs +272 -59
  18. package/driver/engine/CONTRACT.md +10 -3
  19. package/driver/engine/README.md +2 -2
  20. package/driver/engine/anthropic-agent.mjs +77 -21
  21. package/driver/engine/auth.mjs +129 -10
  22. package/driver/engine/jx-turn.mjs +7 -6
  23. package/driver/engine/mcp/recording-server.mjs +13 -0
  24. package/driver/engine/openai-agent.mjs +4 -2
  25. package/driver/engine/probe.mjs +110 -23
  26. package/driver/findings-model.mjs +1 -1
  27. package/driver/flag-snapshot.mjs +28 -5
  28. package/driver/gateway.mjs +24 -18
  29. package/driver/jx-lanes.mjs +21 -2
  30. package/driver/jx-units.mjs +6 -3
  31. package/driver/jx.mjs +4 -2
  32. package/driver/matter-frame-record.mjs +90 -1
  33. package/driver/named-band.mjs +34 -2
  34. package/driver/package.json +1 -1
  35. package/driver/pipeline.mjs +200 -23
  36. package/driver/portal-config-view.mjs +30 -1
  37. package/driver/portal-report.mjs +15 -1
  38. package/driver/portal-service.mjs +46 -6
  39. package/driver/predelivery-lint.mjs +12 -2
  40. package/driver/publish/index.mjs +46 -5
  41. package/driver/publish/knockout.mjs +10 -1
  42. package/driver/publish/render-knockout.mjs +69 -7
  43. package/driver/publish/render.mjs +170 -59
  44. package/driver/publish/report-data.mjs +4 -1
  45. package/driver/publish/report-topbar.mjs +58 -0
  46. package/driver/publish/templates/report.css +18 -1
  47. package/driver/publish/xlsx.mjs +13 -1
  48. package/driver/register-availability.mjs +2 -2
  49. package/driver/register-coverage.mjs +94 -1
  50. package/driver/register-digest-record.mjs +236 -11
  51. package/driver/register-plan.mjs +170 -0
  52. package/driver/result-noun-fields.mjs +2 -2
  53. package/driver/run-economics.mjs +41 -10
  54. package/driver/run-requirements.mjs +173 -9
  55. package/driver/runner.mjs +3 -3
  56. package/driver/stages.mjs +12 -8
  57. package/driver/suite-census.json +142 -64
  58. package/driver/systemd/README.md +7 -4
  59. package/driver/terminal-clamp.mjs +107 -1
  60. package/driver/tokens.mjs +169 -3
  61. package/driver/unit-environment.mjs +42 -15
  62. package/driver/unit-inventory.mjs +19 -2
  63. package/driver/verify.mjs +27 -0
  64. package/mcp-server/CHANGELOG.md +4 -0
  65. package/mcp-server/package.json +1 -1
  66. package/mcp-server/server.mjs +15 -1
  67. package/package.json +1 -1
  68. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  69. package/portal-ui/dist/index.html +1 -1
  70. package/portal-ui/package.json +1 -1
  71. package/providers/jx/README.md +2 -1
  72. package/providers/jx/src/turn-envelope.mjs +8 -3
  73. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  74. package/providers/oauth-mcp-bridge/package.json +1 -1
  75. package/providers/uspto-local/README.md +1 -1
  76. package/scripts/authority-boundary-probe.mjs +4 -2
  77. package/scripts/env-audit.mjs +12 -6
  78. package/scripts/freeze-example-run.mjs +49 -16
  79. package/scripts/generated-files-are-current.mjs +69 -4
  80. package/scripts/settings-render-check.mjs +75 -2
  81. package/scripts/test-full.mjs +96 -3
  82. package/scripts/test-run.mjs +10 -0
  83. package/shared/deployment-box.mjs +7 -2
  84. package/shared/driver-dir.mjs +1 -1
  85. package/shared/names-in-force.mjs +1 -1
@@ -362,6 +362,12 @@ const MCP_HOST_RE = /https?:\/\/[A-Za-z0-9.-]+\/mcp/gi;
362
362
  // introduces it and the full stop that closes it — leaving either behind puts a stray break or an orphan
363
363
  // "." in the footer of every report.
364
364
  const RATED_UNDER_RE = /<br\s*\/?>Rated under:\s*<span class="mono">[^<]*<\/span>\./gi;
365
+ // THE PROJECT LINE IS THE SAME CLASS AND WAS MISSING ITS STRIP. "Run under project: <name>" is
366
+ // internal provenance — an end-to-end arm reads it out of the published internal report — and it sat
367
+ // beside "Rated under" in the footer with nothing removing it here, so it reached every embedded
368
+ // reader exactly as written: the engine's phrase for the folder a job was filed in, and the folder's
369
+ // name. Same shape, same removal, one line apart.
370
+ const RUN_UNDER_PROJECT_RE = /<br\s*\/?>Run under project:\s*<span class="mono">[^<]*<\/span>\./gi;
365
371
 
366
372
  const CONNECTOR_TOKEN_RE = /([?&])token=v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(&)?/g;
367
373
 
@@ -796,8 +802,16 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
796
802
 
797
803
  // The footer fingerprint. Counted like the rest so a renderer change that moves it shows up as a zero
798
804
  // rather than as a line that quietly starts shipping again.
805
+ //
806
+ // ONE COUNTER PER LINE, AND THAT IS THE WHOLE POINT OF COUNTING. These two strips shared a counter, so
807
+ // the check that exists to catch a renderer moving this markup could not tell WHICH line moved: the
808
+ // renderer could rename one of them, that strip would match nothing, the other would still fire, and
809
+ // the count would read 1 exactly as it does when both work. A count that cannot fall to zero for one
810
+ // of the two things it counts is not a measurement of either.
799
811
  let ratedUnderDropped = 0;
812
+ let runUnderProjectDropped = 0;
800
813
  out = out.replace(RATED_UNDER_RE, () => { ratedUnderDropped += 1; return ""; });
814
+ out = out.replace(RUN_UNDER_PROJECT_RE, () => { runUnderProjectDropped += 1; return ""; });
801
815
 
802
816
  // ── THE ASK-AI BAND COMES OUT FOR EVERY READER ──────────────────────────────────────────────────
803
817
  //
@@ -854,7 +868,7 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
854
868
  out = injectEmbedLayer(out, { feedback });
855
869
 
856
870
  return {
857
- html: out, strippedNav, neutralised, mcpLeaks, tokensDropped, ratedUnderDropped,
871
+ html: out, strippedNav, neutralised, mcpLeaks, tokensDropped, ratedUnderDropped, runUnderProjectDropped,
858
872
  internalTailsDropped, reviewerCodesDropped, unbalanced, missingCss,
859
873
  };
860
874
  }
@@ -98,6 +98,24 @@ import { fileURLToPath } from "node:url";
98
98
  // re-declared: one number, one count, and every existing importer of this module keeps working.
99
99
  export { DEFAULT_CLIENT_DAILY_RUNS, accountUsage };
100
100
 
101
+ /**
102
+ * What a requester is told when the day's allowance is gone.
103
+ *
104
+ * ONE SENTENCE, TWO PLACES. The server refuses with it and the screen warns with it, and a reader who
105
+ * meets both must not meet two different sentences about the same fact. The browser cannot import this
106
+ * module, so the portal composes its own copy and `portal-ui/test/allowanceParity.test.ts` pins the two
107
+ * together — the same arrangement the effort model already has.
108
+ *
109
+ * NAMED, NOT ADDRESSED. It read "ask your <name> contact", which puts a possessive and a noun around a
110
+ * name that is already the answer: on a deployment that has configured no brand the portal's fallback is
111
+ * the words "the operator", and the sentence rendered "ask your the operator contact to run this one for
112
+ * you". The line beside it on the same screen had always said "ask <name>", and that is the one the
113
+ * approved design draws.
114
+ */
115
+ export const allowanceExhaustedLine = (cap, who) =>
116
+ `You have used all ${cap} of this account's searches for today. `
117
+ + `The allowance resets at midnight UTC — or ask ${who} to run this one for you.`;
118
+
101
119
  // One sentence, two places (the capability payload and the route that refuses). Never names the
102
120
  // missing credential: this reaches a client screen, and our plumbing is not their business.
103
121
  //
@@ -130,7 +148,7 @@ import { productRows, productRow, baseTurnaroundFor } from "./product-rows.mjs";
130
148
  import { resolveForDoor, gateResolvedRequest } from "./door-gates.mjs";
131
149
  import { resolveEffectiveScope } from "./effective-scope.mjs";
132
150
  import { quoteForJob } from "./run-quote.mjs";
133
- import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor, caseLawReadyFor } from "./flag-snapshot.mjs";
151
+ import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor, registerLabelFor, caseLawReadyFor } from "./flag-snapshot.mjs";
134
152
  import { isDemo, demoPostureLine } from "./demo-posture.mjs";
135
153
  import { triggerCapGap, triggerCapWarning } from "./trigger-cap.mjs"; // F51 — one answer, three surfaces
136
154
  import { makeUpstream } from "./portal-upstream.mjs";
@@ -468,7 +486,16 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
468
486
  // zombie face this state exists to end. pausedKind "operator" tells the UI which words to use.
469
487
  const paused = s.state === "postponed" || s.state === "recovering" || s.state === "parked-for-human";
470
488
  out.push({ ...(liveRetired ? { retired: true } : {}),
471
- runId: s.runId, account: owner, ...(owner === "generic" ? { organisation: p.organisation ?? null } : {}), title: s.markName ?? s.slug, kind: s.lane === "knockout" ? "knockout-batch" : "clearance",
489
+ runId: s.runId, account: owner, ...(owner === "generic" ? { organisation: p.organisation ?? null } : {}), title: s.markName ?? s.slug,
490
+ // THE RUN’S OWN PIPELINE, off the frozen policy sidecar this branch already reads — the same
491
+ // source the `product` field two lines down takes, so a row cannot contradict its own product.
492
+ // `s.lane` is the runner’s queue directory and it is not that: a knockout claimed from any other
493
+ // lane arrived here as a clearance, and Home then quoted it the clearance turnaround — hours,
494
+ // against a search that finishes in minutes. The queued rows below were fixed for exactly this
495
+ // and this row was left on the weaker field. The lane stays as the fallback for a run whose
496
+ // sidecar predates the stamp, and an unplaceable product stays a clearance, which is what every
497
+ // listing has always shown for something it could not identify.
498
+ kind: (policyFor(sp?.level)?.pipeline ?? (s.lane === "knockout" ? "knockout" : null)) === "knockout" ? "knockout-batch" : "clearance",
472
499
  markName: typeof s.markName === "string" ? s.markName : null,
473
500
  // The project, straight off the frozen sidecar this branch already reads as `p`. A LIVE run needs
474
501
  // no publish stamp and no back-fill — the sidecar is right there, and freezeProfile has written
@@ -684,7 +711,7 @@ export function scanAccountRuns({ poolRoot, workspaceRoot, account = null, gener
684
711
  // A failure `reason` is NOT client-safe, and the listing must not hand it to a browser.
685
712
  //
686
713
  // The engine writes `reason = String(e?.stack ?? e)` truncated to 200 characters
687
- // (pipeline.mjs:5366, pipeline-knockout.mjs:311). That is a raw stack trace: absolute filesystem
714
+ // (pipeline.mjs, pipeline-knockout.mjs). That is a raw stack trace: absolute filesystem
688
715
  // paths, internal module names, provider error text. Useful to staff, and none of a client's business
689
716
  // — and redacting it in the UI would be theatre, because the field still travels over the wire and
690
717
  // sits in devtools whatever React chooses to render.
@@ -1230,6 +1257,10 @@ export function makePortalService({
1230
1257
  // THREE answers survive the read: null (unrestricted), an array, or undefined (the snapshot does not
1231
1258
  // say — fail open). registerTerritoriesFor is what keeps them apart.
1232
1259
  readTerritories = () => registerTerritoriesFor(readFlagSnapshot(poolRoot)),
1260
+ // What to CALL the wired register on screen. Beside `readTerritories` and injected for the same
1261
+ // reason: the covered set says a territory cannot be searched, and this says by what — the screen
1262
+ // cannot name the register from the key without printing an engine identifier at a client.
1263
+ readRegisterLabel = () => registerLabelFor(readFlagSnapshot(poolRoot)),
1233
1264
  // — whether this deployment has a case-law source enrolled at all. Same
1234
1265
  // injection and same snapshot as the two above, and the same tri-state: null is "the snapshot does not
1235
1266
  // say", which must not render as a warning on a working box.
@@ -1280,6 +1311,7 @@ export function makePortalService({
1280
1311
  const built = readBuilt();
1281
1312
  const canCount = readCanCount();
1282
1313
  const territories = readTerritories();
1314
+ const registerLabel = readRegisterLabel();
1283
1315
  const caseLawReady = readCaseLaw();
1284
1316
  return {
1285
1317
  // Every product is LISTED whatever its state. A product that vanishes when unavailable leaves a
@@ -1335,6 +1367,16 @@ export function makePortalService({
1335
1367
  // not told me", which fails open. A `covered ?? []` anywhere on this path offers zero territories
1336
1368
  // on a production box.
1337
1369
  ...(territories === undefined ? {} : { territories }),
1370
+ // THE REGISTER'S NAME, sent whenever there is one. The covered set alone lets the screen mark a
1371
+ // territory as unreachable; it cannot say by WHAT, and the sentence the door refuses with names
1372
+ // the register ("China is not available with Signa, the register configured here"). A screen that
1373
+ // marked a chip without naming the register would be a second, vaguer wording for one fact.
1374
+ //
1375
+ // OMITTED, never nulled, exactly as `territories` above: absent means this deployment did not say,
1376
+ // and the screen must then mark the territory without naming a register rather than invent one.
1377
+ // It is the register's own display label, never the provider key — the key is an engine
1378
+ // identifier and has no place on a client's screen.
1379
+ ...(registerLabel ? { registerLabel } : {}),
1338
1380
  // `base` and `nativeLanguage` ride the LIST row, not just the record: the composer has to say what
1339
1381
  // geography a saved search accepts while the row is being clicked, and fetching the record per
1340
1382
  // selection would put that answer one round trip behind the Review button. Only `true` travels for
@@ -1842,9 +1884,7 @@ export function makePortalService({
1842
1884
  return null;
1843
1885
  }
1844
1886
  if (used.today + 1 <= limit) return null;
1845
- return { status: 429, json: { ok: false, errors: [
1846
- `You have used all ${limit} of this account's searches for today. The allowance resets at midnight UTC — or ask your ${BRAND.name} contact to run this one for you.`,
1847
- ] } };
1887
+ return { status: 429, json: { ok: false, errors: [allowanceExhaustedLine(limit, BRAND.name)] } };
1848
1888
  };
1849
1889
 
1850
1890
  // /portal/api/run/plan — the confirmation gate (no spend)
@@ -32,7 +32,7 @@ import { writeUpViolations, writeUpMessage } from "./narrative-write-ups.mjs";
32
32
  import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } from "./registry-fidelity.mjs";
33
33
  import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
34
34
  import { normalizeBand } from "./framework.mjs";
35
- import { clientConditions, ENGINE_TOKEN_RE } from "./terminal-clamp.mjs"; // the reader's clause per condition, and the token shape it may never carry
35
+ import { clientConditions, unrenderableConditions, ENGINE_TOKEN_RE } from "./terminal-clamp.mjs"; // the reader's clause per condition, and the token shape it may never carry
36
36
  import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
37
37
  import { isEngineAppendedCaveat } from "./verify-knockout.mjs"; // ONE derivation for "the engine appended this caveat, not a seat"
38
38
 
@@ -1976,10 +1976,20 @@ export function clientConditionVoiceChecks({ verdictDoc }) {
1976
1976
  const carrying = clientConditions(verdictDoc)
1977
1977
  .map((c) => ({ c, m: String(c).match(ENGINE_TOKEN_RE) }))
1978
1978
  .filter((x) => x.m);
1979
+ // THE SECOND CHECK IS WHAT KEEPS THE FIRST ONE HONEST. The fallback now composes the reader's
1980
+ // sentence from a pre-split reason's own counts, and DROPS a token-bearing reason it cannot compose
1981
+ // one for. Both outcomes leave the first check passing, and they mean opposite things: one is the
1982
+ // condition reaching the client in a lawyer's nouns, the other is the condition reaching nobody. A
1983
+ // single check reporting a clean page for both would be an absence read as a pass.
1984
+ const dropped = unrenderableConditions(verdictDoc);
1979
1985
  return [check("client-condition-voice", "verdict", "report", carrying.length === 0,
1980
1986
  carrying.length
1981
1987
  ? `${carrying.length} delivered condition${carrying.length === 1 ? "" : "s"} carr${carrying.length === 1 ? "ies" : "y"} an engine identifier, so the client's "conditional on:" list reads the run record rather than the reader's sentence: ${carrying.slice(0, 3).map((x) => `"${x.m[0]}"`).join(", ")}${carrying.length > 3 ? ` (+${carrying.length - 3} more)` : ""}. The clause exists at the clamp site; persist it and this list takes it. A sidecar written before clauses were persisted flags until the run is re-generated.`
1982
- : "")];
1988
+ : ""),
1989
+ check("client-condition-dropped", "verdict", "report", dropped.length === 0,
1990
+ dropped.length
1991
+ ? `${dropped.length} condition${dropped.length === 1 ? " was" : "s were"} recorded for this run and reach no client surface: the run-record reason carries an engine identifier and no reader's sentence was stored or could be composed from it, so it is dropped rather than printed in engine voice. The report ships without it and the run record keeps it: ${dropped.slice(0, 2).map((r) => `"${r.slice(0, 80)}…"`).join(", ")}${dropped.length > 2 ? ` (+${dropped.length - 2} more)` : ""}. Add the defect's sentence to the clause authority in terminal-clamp.mjs, or persist a clause at the clamp site.`
1992
+ : "")];
1983
1993
  }
1984
1994
 
1985
1995
  export function statementCoherenceChecks({ verdictDoc }) {
@@ -18,10 +18,12 @@ import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.
18
18
  import { clearanceReportData } from './report-data.mjs';
19
19
  import { searchDepthRecord } from './search-depth.mjs'; // how much was read to reach the answer, as counts and tokens
20
20
  import { parseFrameworkManifest } from '../framework.mjs';
21
- import { rollupTokens } from '../tokens.mjs';
21
+ import { rollupTokens, servedModels } from '../tokens.mjs';
22
22
  import { reportIdentityFor, productCoverageNote, isRegisterOnly } from '../search-policy.mjs';
23
23
  import { readRecordArtifacts, bindFindingsToRecords, joinEvidenceStatus } from '../registry-fidelity.mjs';
24
24
  import { deliveryFlagLines } from '../predelivery-lint.mjs';
25
+ import { unrenderableConditions } from '../terminal-clamp.mjs'; // — the conditions that reach no client surface
26
+ import { runLog } from '../log.mjs'; // — one line in the run record when one does
25
27
  import { PROVIDERS, config } from '../driver.config.mjs';
26
28
  import { declaredRecordOrigins } from '../record-origins.mjs';
27
29
  import { NEUTRAL_DELIVERY, loadProfiles } from '../profiles.mjs';
@@ -791,6 +793,30 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
791
793
  // the narrative, never in a status line). Best-effort; legacy runs have no sidecar.
792
794
  let verdictInfo = null;
793
795
  try { verdictInfo = JSON.parse(readFileSync(driverDir(runDir, 'verdict.json'), 'utf8')); } catch { /* legacy */ }
796
+ // ── THE CONDITIONS THAT REACH NO PAGE, RECORDED RATHER THAN LOST ────────────────────────────────
797
+ //
798
+ // A condition whose reader-facing sentence was never stored and cannot be composed from the run
799
+ // record's own counts is dropped instead of printed in the engine's words. That is right for the
800
+ // reader and it is a silent loss for everyone else, so the drop reports itself twice: an ordinary
801
+ // row on the workbook's gaps sheet, and one line in the run record here.
802
+ //
803
+ // THE WORDS ARE THE RUN RECORD'S OWN. The reason is what the clamp site wrote; the state word and the
804
+ // column headings are the gaps sheet's. Nothing on this path composes a sentence.
805
+ //
806
+ // A FRESH RUN NEVER REACHES THIS. Every clamp site stores its clause, so the list is empty and the
807
+ // sheet grows no row and the log gains no line. It bites on a republished archive, which is exactly
808
+ // where the operator has no other way to learn the page is short of a point.
809
+ const droppedConditions = unrenderableConditions(verdictInfo || {}).map((reason) => ({
810
+ area: 'Conditions', state: 'open', note: reason,
811
+ }));
812
+ // Best-effort, in the house pattern: a publish never fails for want of a log line, and a republish
813
+ // from a directory this process cannot write is still a publish.
814
+ if (droppedConditions.length && runDir) {
815
+ try {
816
+ runLog(runDir, { event: 'client-condition-dropped', n: droppedConditions.length,
817
+ reasons: droppedConditions.map((c) => c.note) });
818
+ } catch { /* the workbook row is the record that matters; this line is the second copy */ }
819
+ }
794
820
  // doc 50 — the run's FROZEN framework manifest (band vocabulary). Present on band-doctrine runs;
795
821
  // absent on every archived run (they render byte-identically on the legacy paths).
796
822
  let framework = null;
@@ -902,7 +928,14 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
902
928
  const reviewReceipts = {
903
929
  // `family` rides along for deliveryFlagLines' fallback: a check whose id is not in the projection
904
930
  // table still projects to its family's sentence rather than to anything the engine wrote.
905
- lint: (lintSink?.checks ?? []).filter(c => !c.pass).map(c => ({ id: c.id ?? '', family: c.family ?? '', detail: c.detail ?? '' })),
931
+ // ONE CHECK IS DELIBERATELY NOT PROJECTED. `client-condition-dropped` has no sentence of its own
932
+ // in the projection table, so it fell through to its family's — "the delivered outcome and the
933
+ // report's own text do not agree" — which describes a contradiction. This is an omission: the
934
+ // outcome and the text agree perfectly and both are short of a point. It says what happened on the
935
+ // gaps sheet and in the run record now, in the run record's own words, so the borrowed sentence
936
+ // comes off rather than being replaced by a better one (ruled 2026-09-17).
937
+ lint: (lintSink?.checks ?? []).filter(c => !c.pass && c.id !== 'client-condition-dropped')
938
+ .map(c => ({ id: c.id ?? '', family: c.family ?? '', detail: c.detail ?? '' })),
906
939
  engagement: integritySink?.engagement ?? null,
907
940
  registryCorrections: Array.isArray(integritySink?.registryCorrections)
908
941
  ? integritySink.registryCorrections.filter((c) => c && c.from != null && c.to != null) : [],
@@ -1032,7 +1065,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1032
1065
  // the same rule (the workbook's own BANNED gate had already started firing on the raw detail —
1033
1066
  // advisory, so CI stayed green). reviewReceipts.lint keeps its raw detail for the internal
1034
1067
  // readers above (fetchState reads registry-record-coverage's URIs out of it).
1035
- counts = await buildAudit({ findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1068
+ counts = await buildAudit({ droppedConditions, findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
1036
1069
  grpRead(join(poolRunDir, auditFile), 0o640);
1037
1070
  if (counts?.gateViolations?.length) console.warn(`[audit-workbook] advisory: ${counts.gateViolations.join(' | ')}`);
1038
1071
  } catch (e) {
@@ -1053,6 +1086,11 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1053
1086
  // rather than wire the overlay in: client names saturate privileged report prose — a partial blur is a
1054
1087
  // worse demo than no toggle.
1055
1088
  const reportNav = siteNav(poolRoot, 'report', null, '../', { anon: false });
1089
+ // The models that served this run, named as a client may read them (tokens.mjs servedModels). The closing
1090
+ // line of the report's scope section, the data file and the meta below all take this one read; a read
1091
+ // that throws records nothing rather than failing the publish.
1092
+ let served = null;
1093
+ try { served = runDir ? servedModels(runDir) : null; } catch { served = null; }
1056
1094
  // `demoData` is resolved above the report.md write — one answer, every surface.
1057
1095
  // ── HOW MUCH WAS READ TO REACH THE ANSWER ──────────────────────────────────────────────────────
1058
1096
  // Derived here rather than in a stage so a REPUBLISH of an archived run picks the fields up with no
@@ -1076,7 +1114,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1076
1114
  writeRO('search-depth.json', JSON.stringify(searchDepth, null, 2));
1077
1115
  } catch { /* the depth record is additive — a publish never fails for want of it */ }
1078
1116
 
1079
- writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion, searchDepth }));
1117
+ writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, servedModels: served, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion, searchDepth }));
1080
1118
  // ONE report (spec 2026-07-30 §5): report.client.html is no longer written. The knockout lane's own
1081
1119
  // collapse note is the precedent: "two renderings of one run is how the wrong link gets sent". The
1082
1120
  // client host serves the same report.html through the portal's readReport() (cleaning built in) — its
@@ -1150,7 +1188,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1150
1188
  // is the LAST resort (a level today's registry no longer knows). meta.json below keeps the frozen
1151
1189
  // stamp BY DESIGN — its readers re-derive.
1152
1190
  auditFile, searchLevel: searchPolicy?.level ?? null, stageLabel: stageLabel ?? searchPolicy?.stageLabel ?? null,
1153
- engineCommit: engineCommit(),
1191
+ engineCommit: engineCommit(), servedModels: served,
1154
1192
  framework, verdictInfo, findings, coverage, contextNotes, markAssessment, fourAnswers, askAnswers,
1155
1193
  actions: actionsRegister, jurisdiction, searchedJurisdictions, scopeBasis, caption: fm.overall_caption ?? null,
1156
1194
  });
@@ -1175,6 +1213,9 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1175
1213
  issuedAt,
1176
1214
  // WHICH BUILD produced this. null off a git checkout — a provenance stamp never fails a publish.
1177
1215
  engineCommit: engineCommit(),
1216
+ // The models that served the run, named as a client may read them (tokens.mjs servedModels). Absent when
1217
+ // nothing was read, so a meta from before the record keeps its shape; [] when turns ran and named none a client may read.
1218
+ servedModels: served ?? undefined,
1178
1219
  kind: 'clearance', recordLinks: officeLinks?.tally ?? undefined, // per office: linked, or cited by number and why; only where the register has no record pages
1179
1220
  searchLevel: searchPolicy?.level ?? undefined,
1180
1221
  // Display-only face of the level ("Depth 4"), frozen alongside it so the list can show which reads
@@ -21,6 +21,7 @@ import { batchMarkName } from '../mark-name.mjs';
21
21
  import { renderKnockoutHtml, knockoutReportData } from './render-knockout.mjs';
22
22
  import { resolveDemoData, demoBannerMd } from './demo-marking.mjs'; //
23
23
  import { engineCommit } from '../engine-build.mjs';
24
+ import { servedModels } from '../tokens.mjs';
24
25
  import { knockoutFindingViews, knockoutFindingRange } from '../findings-model.mjs';
25
26
  import { knockoutReceipts, worstBand } from '../verify-knockout.mjs';
26
27
 
@@ -518,6 +519,10 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
518
519
  // — resolved once for the whole publish, off the same two sources the clearance
519
520
  // publisher uses (frozen sidecar, then roster; either marks, neither un-marks).
520
521
  const demoData = resolveDemoData({ runDir, customerKey });
522
+ // The models that served this batch, named as a client may read them — read once, like the demo answer,
523
+ // because it is a property of the run. A read that throws records nothing rather than failing a publish.
524
+ let served = null;
525
+ try { served = runDir ? servedModels(runDir) : null; } catch { served = null; }
521
526
  const reports = markRows.map((m) => {
522
527
  const slug = kebab(m.name);
523
528
  const file = single ? 'report.html' : `report-${slug}.html`;
@@ -558,12 +563,13 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
558
563
  // document says it did not search is what the run was instructed not to search.
559
564
  searchPolicy: searchPolicy ?? null,
560
565
  delivery,
566
+ servedModels: served,
561
567
  }));
562
568
  // The run as data — what the assistant drafts client-facing mail from, and the shape the portal's
563
569
  // native-render path has been reading for since before anything wrote it. One per report, so the
564
570
  // native render of a per-mark document is that mark's data and not the batch's.
565
571
  writeRO(dataFile, JSON.stringify(knockoutReportData(one, framework, {
566
- runId, codename, overall: markBand, issued, registerCounts, registerRecords, ownerChecks, auditFile, customerKey, matter: runId,
572
+ runId, codename, overall: markBand, issued, registerCounts, registerRecords, ownerChecks, auditFile, customerKey, matter: runId, servedModels: served,
567
573
  identity: { ...identity, level: searchPolicy?.level ?? null },
568
574
  url: reportUrlFor(slug),
569
575
  }), null, 2));
@@ -625,6 +631,9 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
625
631
  .filter(Boolean).join(' — '),
626
632
  markName: batchMarkName(markNames) ?? undefined,
627
633
  engineCommit: engineCommit(),
634
+ // The models that served the batch, named as a client may read them (tokens.mjs servedModels). Absent when
635
+ // nothing was read, so a meta from before the record keeps its shape; [] when turns ran and named none a client may read.
636
+ servedModels: served ?? undefined,
628
637
  client: null, customerKey: customerKey || 'generic',
629
638
  // WHICH ORGANISATION'S GENERIC this batch was filed under, read from the frozen sidecar exactly as
630
639
  // the clearance publisher reads it. Absent on a company's batch and on one filed before
@@ -47,7 +47,8 @@ import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLi
47
47
  import { RECORD_BASIS, recordsForMark, recordsLine } from '../register-records.mjs';
48
48
  import { officeLinkSentences } from './office-record-links.mjs';
49
49
  import { knockoutFindingViews, splitKnockoutNotes } from '../findings-model.mjs';
50
- import { demoBannerHtml } from './render.mjs'; // — the SAME banner the clearance template renders, not a second wording
50
+ import { demoBannerHtml, servedModelsLine } from './render.mjs'; // — the SAME banner the clearance template renders, not a second wording
51
+ import { EXPORT_TOGGLE, exportPopover, EXPORT_MENU_JS } from './report-topbar.mjs'; // — the export menu's shell and behaviour, shared with the clearance template // — the SAME banner the clearance template renders, not a second wording
51
52
  // — the two facts the register card is allowed to read off a raw record, and NEITHER is minted
52
53
  // here. `makeClassifyStatus` and `isAllClass` are the screening lane's own, already shipped, already
53
54
  // fail-open; re-deriving either in a renderer would be this file starting a second status vocabulary,
@@ -1658,6 +1659,10 @@ export function renderKnockoutHtml(findings, framework, {
1658
1659
  // renders the same plain "Privileged & Confidential" this template always printed — so the ~15 unit
1659
1660
  // fixtures and both render-check scripts, none of which pass one, are unchanged by its arrival.
1660
1661
  delivery = null,
1662
+ // The models that served the batch, as tokens.mjs servedModels names them for a client: the scope
1663
+ // section's closing line. Defaults to null, so a fixture or an archived run that passes none renders
1664
+ // as it always did.
1665
+ servedModels = null,
1661
1666
  // — IS THIS AN INVENTED MARK? This template had no demo handling of any kind, so
1662
1667
  // every demo knockout shipped looking real: an invented mark, a real register basis, a real-looking
1663
1668
  // risk assessment, a published URL, and nothing saying the matter is fiction. The clearance template
@@ -1668,6 +1673,11 @@ export function renderKnockoutHtml(findings, framework, {
1668
1673
  const marks = findings?.marks ?? [];
1669
1674
  const title = batchTitle(marks);
1670
1675
  const productName = identity?.identity ?? null;
1676
+ // A DATE, NOT A TIMESTAMP. `issued` is composed as `<date> · <time>` at publish, and the time is the
1677
+ // minute the file was written — it tells a reader nothing and implies a precision the work does not
1678
+ // have. Every mock issues the date alone. Taken by pattern rather than by splitting on the
1679
+ // separator, so a format that changes falls through whole instead of being cut in the wrong place.
1680
+ const issuedDate = (String(issued ?? '').match(/^\d{4}-\d{2}-\d{2}/) || [])[0] ?? issued;
1671
1681
  const overallStop = bandStop(framework, overall);
1672
1682
  const summary = findings?.batch?.executiveSummary ?? '';
1673
1683
  const caveats = findings?.batch?.standardCaveats ?? [];
@@ -1776,9 +1786,44 @@ ${filings}`
1776
1786
  ${homeHref ? `<a class="homebtn tb-back no-print" href="${escAttr(homeHref)}" title="All reports"><span aria-hidden="true">←</span> <span class="tb-back-lbl">All reports</span></a>` : ''}
1777
1787
  ${logoLockup({ mark: 20, tag: '' })}<span class="sp"></span>
1778
1788
  <span class="tb-risk" style="background:var(${overallStop})">${esc(overall ?? '—')}</span>
1779
- <span class="mono tb-matter" style="font-size:11px;color:var(--faint)">${esc(matter || runId || '')}</span>
1780
- ${issued ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued on ${esc(issued)}</span>` : ''}
1781
- <button type="button" class="tbbtn tb-ask no-print">\u2726 <span class="tb-lbl">Ask AI</span></button>
1789
+ ${/* THE IDENTITY LINE NAMES THE MARK AND THE SEARCH, NOT THE RUN. It printed `matter || runId`,
1790
+ and the caller passes the runId AS the matter, so what a client read was the engine's own run
1791
+ identifier — on a real run a temporary-directory name carrying a capture suffix. It is ours, it
1792
+ means nothing to them, and it is the one string on the page that could not be shown to anyone.
1793
+ The mock reads mark then search type, and both are already in hand here.
1794
+ There is NO fall back to the identifier: with no product name the mark stands alone, and with
1795
+ neither the line does not render. A run whose registry identity is missing is exactly the case
1796
+ that used to print the raw name, so falling back would keep the defect for the runs that have
1797
+ it. */''}
1798
+ ${title || productName ? `<span class="mono tb-matter" style="font-size:11px;color:var(--faint)">${
1799
+ [title, productName].filter(Boolean).map(esc).join(' / ')}</span>` : ''}
1800
+ ${issuedDate ? `<span class="mono tb-issued"><span aria-hidden="true">🗓 </span>Issued on ${esc(issuedDate)}</span>` : ''}
1801
+ ${/* THE EXPORT CONTROL, which this template had never emitted. The approved header carries it and the
1802
+ clearance report has it; the knockout offered a reader who opens the file no route to a PDF at
1803
+ all, while its own stylesheet still described the utility buttons as "used by the topbar Export
1804
+ popover" — the styling for a control that was not there.
1805
+ NO TICK WORDING AND NO SELECT-ALL. The clearance's entry reads "Export PDF (ticked findings)"
1806
+ and its popover offers Select all / Select none, because its exportPDF filters to the ticked
1807
+ ones. This template has no pickbox anywhere, so `pickAll` is deliberately not defined here and a
1808
+ tick verb would name a control that cannot exist. What prints is the whole document.
1809
+ EVERY WORD IN IT IS ALREADY THE PRODUCT'S. The board draws the Export button and its caret and
1810
+ stops there — it does not draw what the menu contains — so the entries are the clearance
1811
+ report's own, verbatim, minus the tick wording that names a control this template does not have.
1812
+ The heading this first carried ("Export") was written here and is gone: a word on a page a
1813
+ client reads is the owner's to choose, and the board does not ask for one.
1814
+ THE MARKUP IS THE CLEARANCE'S, RE-EMITTED RATHER THAN SHARED, and that is a debt this change
1815
+ takes on knowingly: `render.mjs` is frozen at a content hash, and extracting its top bar is its
1816
+ own change with its own byte-comparison against a real archived run. Filed as a follow-up. The
1817
+ class names are the shared vocabulary this file already emits, so report.css styles both. */''}
1818
+ <div class="tb-menu">
1819
+ <button type="button" class="tbbtn tb-ask no-print">\u2726 <span class="tb-lbl">Ask AI</span></button>
1820
+ ${EXPORT_TOGGLE}
1821
+ ${exportPopover(`
1822
+ <button class="util primary" onclick="exportPDF()">\u2b07 Export PDF</button>
1823
+ <div class="tb-sep"></div>
1824
+ <div class="tb-row"><button class="util" onclick="openAll(true)">Expand all</button><button class="util" onclick="openAll(false)">Collapse all</button></div>
1825
+ `)}
1826
+ </div>
1782
1827
  </div>
1783
1828
  </div>
1784
1829
  <div class="fab-stack">${themeButton()}</div>
@@ -1799,7 +1844,10 @@ function openAll(v){document.querySelectorAll('details.ko-full').forEach(functio
1799
1844
  function exportPDF(){window.print();}
1800
1845
  (function(){var o=function(){openAll(true);};
1801
1846
  if(window.matchMedia){var m=window.matchMedia('print');if(m.addEventListener)m.addEventListener('change',function(e){if(e.matches)o();});}
1802
- window.addEventListener('beforeprint',o);})();</script>
1847
+ window.addEventListener('beforeprint',o);})();
1848
+ /* The popover opens on its own button, closes on a click outside it and on Escape. Same two listeners
1849
+ the clearance template carries, for the same markup; they move together when the top bar is shared. */
1850
+ ${EXPORT_MENU_JS}</script>
1803
1851
  <div class="wrap">
1804
1852
  <header class="hero">
1805
1853
  ${demoBannerHtml(demoData === true)}
@@ -1839,7 +1887,18 @@ window.addEventListener('beforeprint',o);})();</script>
1839
1887
  newCaveats.map((c) => inlineMd(c)).join('<br>')}</p></div>` : ''}
1840
1888
 
1841
1889
  <footer>
1842
- <span>${productName ? `${esc(productName)}. ` : ''}<br>Matter ${esc(matter || runId || '')}.${issued ? ` Issued ${esc(issued)}.` : ''}</span>
1890
+ ${/* THE FOOTER NAMES THE SAME THREE THINGS THE HEADER DOES. It read "<product>. / Matter
1891
+ <runId>. Issued <date · time>." — the identifier again, under a label that calls it the
1892
+ client's matter when it is the engine's run directory, and the publish clock again. The mock
1893
+ reads mark · search · issued on date. Built from the same parts as the identity line so the
1894
+ two cannot drift, and with the same rule: no part of it falls back to the identifier. */''}
1895
+ <!-- WHICH MODELS SERVED THIS RUN — the same line, from the same function, as the clearance
1896
+ report's footer. It used to ride the end of the scope block, which the 2026-09-16 redesign took
1897
+ off this page; the line is provenance rather than the narration that was ruled out, so it moved
1898
+ here. Owner ruling, 2026-09-17. Adjacent on purpose, as on the clearance side. -->${servedModelsLine(servedModels)}
1899
+ <span>${[title, productName].filter(Boolean).map(esc).join(' \u00b7 ')}${
1900
+ issuedDate ? `${title || productName ? ' \u00b7 ' : ''}issued on ${esc(issuedDate)}` : ''}${
1901
+ title || productName || issuedDate ? '.' : ''}</span>
1843
1902
  ${logoLockup({ mark: 16 })}
1844
1903
  </footer>
1845
1904
  </div>
@@ -1859,7 +1918,7 @@ window.addEventListener('beforeprint',o);})();</script>
1859
1918
  * where the working notes live, and a data file that quietly re-admitted them would reopen exactly the
1860
1919
  * leak this series closed.
1861
1920
  */
1862
- export function knockoutReportData(findings, framework, { runId, codename, overall, issued, identity, registerCounts, registerRecords = null, ownerChecks = [], url, auditFile, customerKey, matter }) {
1921
+ export function knockoutReportData(findings, framework, { runId, codename, overall, issued, identity, registerCounts, registerRecords = null, ownerChecks = [], url, auditFile, customerKey, matter, servedModels = null }) {
1863
1922
  const marks = findings?.marks ?? [];
1864
1923
  return {
1865
1924
  schema: 'report-data/1',
@@ -1870,6 +1929,9 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
1870
1929
  issued: issued || null,
1871
1930
  url: url || null,
1872
1931
  auditFile: auditFile || null,
1932
+ // The models that served the run, in first-use order, as tokens.mjs servedModels names them for a
1933
+ // client: a deployment's own name never, its tier instead. null when nothing was read.
1934
+ servedModels: Array.isArray(servedModels) ? servedModels : null,
1873
1935
  level: {
1874
1936
  searchLevel: identity?.level ?? null,
1875
1937
  stageLabel: identity?.stageLabel ?? null,