clearotron 0.2.0 → 0.2.1

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 (43) hide show
  1. package/.env.example +52 -0
  2. package/INSTALL.md +4 -3
  3. package/README.md +2 -1
  4. package/bin/example.mjs +88 -27
  5. package/bin/onboard.mjs +40 -2
  6. package/bin/start.mjs +7 -0
  7. package/build-info.json +2 -2
  8. package/docs/RELEASES.md +6 -4
  9. package/docs/architecture/04-configuration-reference.md +1 -1
  10. package/driver/CHANGELOG.md +30 -0
  11. package/driver/ask-ledger.mjs +69 -1
  12. package/driver/package.json +1 -1
  13. package/driver/pipeline.mjs +41 -2
  14. package/driver/predelivery-lint.mjs +1 -1
  15. package/driver/publish/seed-pool.mjs +24 -9
  16. package/driver/record-carry.mjs +139 -0
  17. package/driver/reference-score.mjs +53 -3
  18. package/driver/reference-strip-signatures.mjs +68 -0
  19. package/driver/register-digest-record.mjs +31 -1
  20. package/driver/repairs.mjs +1 -1
  21. package/driver/suite-census.json +46 -16
  22. package/driver/verify.mjs +2 -2
  23. package/mcp-server/CHANGELOG.md +8 -0
  24. package/mcp-server/lib/whatif.mjs +10 -1
  25. package/mcp-server/package.json +1 -1
  26. package/package.json +1 -1
  27. package/portal-ui/package.json +1 -1
  28. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  29. package/providers/oauth-mcp-bridge/package.json +1 -1
  30. package/scripts/ai-page-render-check.mjs +2 -1
  31. package/scripts/clearances-render-check.mjs +2 -1
  32. package/scripts/env-audit.mjs +20 -0
  33. package/scripts/headless-page.mjs +225 -0
  34. package/scripts/home-render-check.mjs +2 -1
  35. package/scripts/mint-reference-strip-backlog.mjs +41 -0
  36. package/scripts/release-await-cut.mjs +95 -7
  37. package/scripts/release-version-pr-checks.mjs +25 -1
  38. package/scripts/report-frame-check.mjs +12 -0
  39. package/scripts/report-screenshot.mjs +62 -2
  40. package/scripts/revisit-render-check.mjs +3 -2
  41. package/scripts/score.mjs +14 -0
  42. package/shared/access-audience.mjs +215 -0
  43. package/shared/tracked-files.mjs +31 -0
package/.env.example CHANGED
@@ -403,3 +403,55 @@ CLEAROTRON_DEMO=
403
403
  # Read by driver/whatif-worker.mjs. Refs tracker issue 1953.
404
404
  # effect: tuning
405
405
  CLEAROTRON_WHATIF_MAX_CONCURRENT=1
406
+
407
+ # The checkout every shipped systemd unit interpolates — and the one name in this file you are most
408
+ # likely to meet before you meet this file. Ten units name it in their `ExecStart`
409
+ # (clearotron-portal, -worker, -deploy, -client-mcp, -mcp-face, prelim-driver, prelim-outbox, the two
410
+ # mcp-server/remote units and the courtlistener bridge), and production's units all depend on it.
411
+ #
412
+ # YOU ALMOST CERTAINLY DO NOT SET THIS. `clearotron connect` writes it into the install's env file, set
413
+ # to the checkout it just installed (bin/connect.mjs, shared/checkout-move.mjs), and it rewrites it when
414
+ # the checkout moves. So this is not a decision waiting for you at install time, and the row does not
415
+ # exist because a deployment cannot be configured without it.
416
+ #
417
+ # IT EXISTS BECAUSE OF WHO READS IT AT 2AM. Somebody whose unit will not start reads
418
+ # `${CLEAROTRON_CHECKOUT_DIR}` in an ExecStart, comes to the file INSTALL.md §8 calls the catalogue, and
419
+ # finds nothing. `shared/names-in-force.mjs` already knew the name; neither governance document did.
420
+ # A row that says "the installer writes this" is the answer to their question — the absence was not.
421
+ #
422
+ # Surfaced by the cut-over overlay control once the withheld `ops/` bucket was laid back over the public
423
+ # tree: the ratchets that would have caught it had had no subject since the cut, so this is a gap of
424
+ # long standing rather than a regression. Refs tracker issue 213.
425
+ #
426
+ # Left EMPTY here on purpose. A path in this file would read as a default; there is none, and the
427
+ # installer supplies the real value.
428
+ # effect: deployment
429
+ CLEAROTRON_CHECKOUT_DIR=
430
+
431
+ # ── The release pipeline's own two names ─────────────────────────────────────────────────────────
432
+ # Both are set by `.github/workflows/release.yml` and read by the release scripts. NEITHER is an
433
+ # operator's decision, and on an install both are unset and the defaults are the right answer. They are
434
+ # catalogued for the same reason CLEAROTRON_CHECKOUT_DIR above is: they are OUR names, read by code that
435
+ # ships, and a reader who meets one in a workflow log needs somewhere to look it up.
436
+ #
437
+ # They are NOT in the system-owned list beside GITHUB_OUTPUT, and the line between them is worth stating:
438
+ # GitHub sets GITHUB_*, so a row would document somebody else's decision. We set these, so a row
439
+ # documents ours.
440
+ #
441
+ # Found by the audit while closing tracker issue 213, not by that issue's diagnosis — they arrived with
442
+ # tonight's release-pipeline work and the catalogue ratchet caught them on the next run, which is the
443
+ # ratchet doing its job. Raised with the release lane rather than silently absorbed.
444
+
445
+ # The commit the cut decision is ABOUT. The workflow sets it to `github.sha` — the push — because the
446
+ # checkout may have moved on since, and a decision made about "whatever HEAD is now" tried to republish
447
+ # an already-released version. Unset falls back to HEAD, which is correct for the cron and for a hand
448
+ # run, where nothing has moved. Read by scripts/release-cut-decision.mjs.
449
+ # effect: deployment
450
+ CLEAROTRON_CUT_REF=
451
+
452
+ # How long publishing waits for a parked version run, in whole milliseconds. Unset uses the built-in
453
+ # budget; the workflow sets it to 0 on a `workflow_dispatch` rehearsal so a dry run does not sit out the
454
+ # full wait. A malformed value REFUSES rather than defaulting — taken as 0 it would skip the wait
455
+ # entirely, which is that script not running at all. Read by scripts/release-await-cut.mjs.
456
+ # effect: tuning
457
+ CLEAROTRON_RELEASE_WAIT_MS=
package/INSTALL.md CHANGED
@@ -115,12 +115,13 @@ Nothing here requires an agent platform or a database — reports are written to
115
115
 
116
116
  ```
117
117
  npm install -g clearotron # stable — the tested one
118
- npm install -g clearotron@beta # newest — every merge, minutes after it lands
118
+ npm install -g clearotron@beta # newest — cut when there is something worth testing
119
119
  ```
120
120
 
121
121
  A **stable** has run a real clearance end to end and had a from-scratch install driven by somebody who has
122
- never seen the product, before it was published. A **beta** is published automatically on every merge to
123
- `main`; it built and the suite passed, and nothing has driven a live register through it.
122
+ never seen the product, before it was published. A **beta** is published when a change lands that is worth
123
+ testing, or while a stable is being prepared — days apart, not on every merge; it built and the suite
124
+ passed, and nothing has driven a live register through it.
124
125
 
125
126
  **Upgrade production to stables only.** What each channel promises and how often one is cut:
126
127
  **[docs/RELEASES.md](docs/RELEASES.md)**.
package/README.md CHANGED
@@ -39,7 +39,8 @@ Node 22 or newer, on macOS, Linux, or Windows via WSL2. That puts `clearotron` o
39
39
  command below works in that short form.
40
40
 
41
41
  That command gives you the **stable** release — the one that has run a real clearance end to end before it
42
- was published. If you want the newest code instead, every merge publishes a beta within minutes:
42
+ was published. If you want the newest code instead, a beta is published whenever there is something worth
43
+ testing — deliberately, days apart, not on every merge:
43
44
 
44
45
  ```bash
45
46
  npm install -g clearotron@beta
package/bin/example.mjs CHANGED
@@ -40,7 +40,7 @@ import "../shared/env-local.mjs"; // step 4 / — FIRST: this program read a
40
40
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync } from "node:fs";
41
41
  import { homedir, tmpdir } from "node:os";
42
42
  import { invoke } from "../shared/invocation.mjs"; // — the printed command is resolved once, for the reader who is actually standing there
43
- import { join, resolve, dirname, sep } from "node:path";
43
+ import { basename, dirname, join, resolve, sep } from "node:path";
44
44
  import { fileURLToPath } from "node:url";
45
45
  import { spawn } from "node:child_process";
46
46
  import { BRAND } from "../shared/brand.mjs"; // — the installer's own name, from the tenant seam
@@ -100,11 +100,26 @@ const DEMO_ROOT = join(REPO, "demo");
100
100
  // name is PRINTED below rather than assumed, because "the demo" is about to mean one of several.
101
101
  const wanted = flag("--product");
102
102
  const children = demoChildren(DEMO_ROOT);
103
- const sampleDir = resolve(
104
- flag("--run-dir")
105
- ?? (wanted ? join(DEMO_ROOT, wanted) : (children[0] ? join(DEMO_ROOT, children[0]) : DEMO_ROOT)),
106
- );
107
- if (!isFrozen(sampleDir)) {
103
+
104
+ // ── ALL OF THEM, UNLESS THE CALLER NARROWED IT (tracker issue 277) ──────────────────────────────────
105
+ //
106
+ // This used to replay `children[0]` and stop. The package ships one finished report per product, and a
107
+ // first-time reader met one of them with nothing on screen saying the other three existed — so three
108
+ // quarters of what was shipped was reachable only by someone who already knew to ask for it. The owner's
109
+ // ruling is that they auto-load: "it should auto-load since it is NOT obvious these runs are there unless
110
+ // you know about it."
111
+ //
112
+ // `--product` and `--run-dir` still narrow to one, because "show me just this one" is a real thing to
113
+ // want. Neither given now means every product this tree ships, in the container's own order.
114
+ const sampleDirs = flag("--run-dir")
115
+ ? [resolve(flag("--run-dir"))]
116
+ : wanted
117
+ ? [resolve(join(DEMO_ROOT, wanted))]
118
+ : children.map((c) => resolve(join(DEMO_ROOT, c)));
119
+ // The refusal below is about ONE directory, and with nothing shipped there is no directory to name — so
120
+ // the container itself is what it looks at, which is what it always did when `demo/` was empty.
121
+ const sampleDir = sampleDirs[0] ?? resolve(DEMO_ROOT);
122
+ if (!sampleDirs.length || !isFrozen(sampleDir)) {
108
123
  // AN ABSENCE IS A FINDING, AND IT NAMES WHAT IT LOOKED AT. This exits 1 and always has; tracker issue
109
124
  // 2193 reported it exiting 0, which did not reproduce at v0.1.0 or at main's tip. An arm pins it.
110
125
  die(
@@ -128,10 +143,17 @@ if (!isFrozen(sampleDir)) {
128
143
  // PUBLISHING WRITES A RECEIPT INTO THE RUN DIRECTORY, and `demo/` is tracked — so a reader who only READ
129
144
  // the demo came back to a dirty checkout. `publishSource` is the one definition of that rule, shared with
130
145
  // the launcher, which seeds the pool from the same container on every `--demo` start.
131
- const publishFrom = publishSource(sampleDir, { repoRoot: REPO });
132
-
133
- const meta = JSON.parse(readFileSync(join(sampleDir, "meta.json"), "utf8"));
134
- if (!meta?.runId) die(`example: ${join(sampleDir, "meta.json")} names no runId — it is not a frozen example manifest.`);
146
+ // EVERY ONE THAT WAS ASKED FOR IS READ BEFORE ANY IS PUBLISHED, so a manifest missing a runId is a
147
+ // refusal about that demo by name rather than a partial pool nobody can account for.
148
+ const samples = sampleDirs.map((dir) => {
149
+ const manifest = join(dir, "meta.json");
150
+ if (!isFrozen(dir)) die(`demo: ${dir} is not a frozen demo — it holds no meta.json and lane entry file.`);
151
+ const m = JSON.parse(readFileSync(manifest, "utf8"));
152
+ if (!m?.runId) die(`example: ${manifest} names no runId — it is not a frozen example manifest.`);
153
+ return { dir, meta: m, publishFrom: publishSource(dir, { repoRoot: REPO }), name: basename(dir) };
154
+ });
155
+ const publishFrom = samples[0].publishFrom;
156
+ const meta = samples[0].meta;
135
157
 
136
158
  // ── 2. the pool guard ────────────────────────────────────────────────────────────────────────────────
137
159
  // Resolve through symlinks. A $HOME that resolves inside the archive is exactly the shape a `===` test
@@ -192,19 +214,38 @@ if (existsSync(poolRoot) && !statSync(poolRoot).isDirectory()) die(`demo: ${pool
192
214
 
193
215
  // ── 3. replay ────────────────────────────────────────────────────────────────────────────────────────
194
216
  console.log(`\n ${BRAND.name} ${BRAND.product.toLowerCase()} — demo\n`);
195
- console.log(` sample: ${sampleDir}`);
217
+ console.log(samples.length === 1
218
+ ? ` sample: ${samples[0].dir}`
219
+ : ` samples: ${samples.length} — ${samples.map((x) => x.name).join(", ")}`);
196
220
  console.log(` pool: ${poolRoot}\n`);
197
221
 
198
222
  mkdirSync(poolRoot, { recursive: true });
199
223
  const { republishRun } = await import(join(REPO, "driver", "publish", "report-registry.mjs"));
200
- let published;
201
- try {
202
- // poolUrl "" on purpose: the report's own link block is for a deployment that serves the pool at a
203
- // public URL. This one is served from this process, at a port picked below.
204
- published = await republishRun({ runId: meta.runId, meta, pool: poolRoot, poolUrl: "", runDir: join(publishFrom, "run") });
205
- } catch (e) {
206
- die(`demo: replaying the sample failed: ${String(e?.message ?? e)}`);
224
+
225
+ // ── ONE FAILURE MUST NOT COST THE OTHERS, AND MUST NOT BE A QUIET COUNT (tracker issue 277) ─────────
226
+ //
227
+ // Publishing stopped at the first error, which was right when there was one demo and is wrong now: a
228
+ // reader whose knockout capture is unreadable should still get the other three, and should be TOLD which
229
+ // one is missing. "3 published" with no other line is the shape this repository calls a silent count —
230
+ // the number is true and the reader cannot tell it is short.
231
+ //
232
+ // The failures are collected and reported together at the end, and the process exits non-zero, because a
233
+ // demo that came up missing a quarter of itself is not a success however good the three look.
234
+ const results = [];
235
+ const failures = [];
236
+ for (const s0 of samples) {
237
+ try {
238
+ // poolUrl "" on purpose: the report's own link block is for a deployment that serves the pool at a
239
+ // public URL. This one is served from this process, at a port picked below.
240
+ results.push({ ...s0, published: await republishRun({ runId: s0.meta.runId, meta: s0.meta, pool: poolRoot, poolUrl: "", runDir: join(s0.publishFrom, "run") }) });
241
+ } catch (e) {
242
+ failures.push({ name: s0.name, why: String(e?.message ?? e) });
243
+ }
244
+ }
245
+ if (!results.length) {
246
+ die(`demo: no demo could be replayed.`, "", ...failures.map((f) => ` ${f.name}: ${f.why}`));
207
247
  }
248
+ const published = results[0].published;
208
249
 
209
250
  // THE LABEL. The reader is about to look at a document that reads like advice about a real mark. It is
210
251
  // not, and the demo says so before the browser opens rather than in a footnote nobody reaches.
@@ -227,18 +268,34 @@ console.log(" re-rendered from its artifacts. It is an example, not advice.\n")
227
268
  //
228
269
  // The third branch is the point: a lane whose publisher reports no count says so. This line printed a
229
270
  // bare "?" to the knockout — a could-not-look wearing the costume of a number.
230
- const spine =
231
- Number.isFinite(published.counts?.findings)
232
- ? `${published.counts.findings} finding(s) recorded in the run's audit spine; the report shows
271
+ // PER LANE, FOR EVERY DEMO — not for the first one with the rest reduced to a product name. The three
272
+ // branches below are the whole point of this sentence: two lanes count different populations and a third
273
+ // counts none, and printing one lane's number beside four reports would state the wrong population three
274
+ // times.
275
+ const spineOf = (pub) =>
276
+ Number.isFinite(pub.counts?.findings)
277
+ ? `${pub.counts.findings} finding(s) recorded in the run's audit spine; the report shows
233
278
  the ones it retains`
234
- : Number.isFinite(published.receipts?.findings)
235
- ? `${published.receipts.findings} finding(s) with citations traced to this run's own held
236
- evidence, on ${published.receipts.citing}/${published.receipts.marks} mark(s)`
279
+ : Number.isFinite(pub.receipts?.findings)
280
+ ? `${pub.receipts.findings} finding(s) with citations traced to this run's own held
281
+ evidence, on ${pub.receipts.citing}/${pub.receipts.marks} mark(s)`
237
282
  : `this lane's publisher reported no finding count — the report itself is the record`;
238
- console.log(` published: ${published.runId} (${spine})`);
283
+ for (const r of results) console.log(` published: ${r.published.runId}\n ${r.name} — ${spineOf(r.published)}`);
284
+ if (results.length > 1) console.log(`\n ${results.length} demo reports are published and listed — one per product.`);
285
+ // LOUD, AND ON STDERR, AND NON-ZERO. Said after the successes so a reader sees what they DID get first,
286
+ // and cannot mistake the run for a clean one.
287
+ if (failures.length) {
288
+ console.error(`\n ${failures.length} of ${samples.length} demo(s) could NOT be replayed:`);
289
+ for (const f of failures) console.error(` ${f.name}: ${f.why}`);
290
+ console.error(` The portal below lists the ${results.length} that published. This exits non-zero.`);
291
+ process.exitCode = 1;
292
+ }
239
293
 
240
294
  if (has("--once")) {
241
- console.log(`\n report: ${join(poolRoot, meta.runId, "report.html")}`);
295
+ // EVERY REPORT THAT WAS PUBLISHED, not the first one. Naming one of four here is the same defect as
296
+ // publishing one of four: the reader is handed a path and has no way to learn the others exist.
297
+ console.log("");
298
+ for (const r of results) console.log(` report: ${join(poolRoot, r.published.runId, "report.html")}`);
242
299
  //, criterion 5 — SAY WHAT WAS CREATED, ON EVERY PATH THAT CREATES SOMETHING.
243
300
  //
244
301
  // The portal path below prints this and `--once` did not, so the one invocation a reader is most
@@ -251,7 +308,11 @@ if (has("--once")) {
251
308
  // does — worse than silence, because it reads as an answer.
252
309
  const created = flag("--pool") ? poolRoot : demoBase;
253
310
  console.log(` Removing it later is one directory: rm -rf ${created}\n`);
254
- process.exit(0);
311
+ // NOT A BARE ZERO. A demo that failed to replay one of its four sets `exitCode` above, and exiting 0
312
+ // here would discard it — printing the failure and then reporting success, which is the shape this
313
+ // change exists to remove. `--once` is also the invocation a script is most likely to use, so it is
314
+ // the one where a swallowed code does the most damage.
315
+ process.exit(process.exitCode ?? 0);
255
316
  }
256
317
 
257
318
  /**
package/bin/onboard.mjs CHANGED
@@ -2311,7 +2311,12 @@ export async function runCheck() {
2311
2311
  // rather than beside the unit, because the unit running and the address being reachable are
2312
2312
  // different facts and the second is the one a client depends on.
2313
2313
  const { clientDoorReachability } = await import(join(REPO, "shared", "client-door.mjs"));
2314
- const published = process.env.CLEAROTRON_CLIENT_MCP_URL ?? null;
2314
+ // FROM THE FILE THE UNITS LOAD, NOT THE SHELL THIS COMMAND WAS TYPED IN (the tracker issue 226
2315
+ // family, met again here). On a hosted box the published address lives in the units' environment,
2316
+ // and reading `process.env` reported "no client connector address is published — that is correct
2317
+ // for a local install" about a deployment that publishes one. Every verdict below rests on this
2318
+ // value, including the audience comparison, so a wrong reading here is not one wrong line.
2319
+ const published = effectiveForService("CLEAROTRON_CLIENT_MCP_URL")?.v ?? null;
2315
2320
  // A SHORT, CHEAP ASK, and a failure to reach IS the finding rather than a failure to look — the
2316
2321
  // property under test is reachability itself. What would be dishonest is calling an address green
2317
2322
  // because nothing asked, which is why `probe` stays null when there is nothing to ask about.
@@ -2327,8 +2332,13 @@ export async function runCheck() {
2327
2332
  // returns null when the header is absent — a looked-and-none answer, not a did-not-look — and
2328
2333
  // the readers separate those, so a probe that omits the field reads as never-looked rather
2329
2334
  // than silently as "no challenge".
2335
+ // THE REDIRECT TARGET TRAVELS WITH IT (tracker issue 241). This request is already made with
2336
+ // `redirect: "manual"`, so the Location an unauthenticated caller is handed is right here —
2337
+ // and it is where Cloudflare Access puts the audience. Reading it from THIS response rather
2338
+ // than asking a second time keeps `doctor` to one request per address, and keeps both answers
2339
+ // about the same moment.
2330
2340
  probe = { ok: res.status < 500, status: res.status, error: null,
2331
- challenge: res.headers.get("www-authenticate") };
2341
+ challenge: res.headers.get("www-authenticate"), location: res.headers.get("location") };
2332
2342
  } catch (e) { probe = { ok: false, status: null, error: String(e?.cause?.code ?? e?.name ?? e?.message ?? e) }; }
2333
2343
  }
2334
2344
  const reach = clientDoorReachability({ url: published, probe });
@@ -2336,6 +2346,34 @@ export async function runCheck() {
2336
2346
  else if (reach.state === "fail") problem(reach.message);
2337
2347
  else info(reach.message);
2338
2348
 
2349
+ // ── AND IS IT THE AUDIENCE THIS INSTALL CHECKS AGAINST? (tracker issue 241) ───────────────────
2350
+ //
2351
+ // The other half of the recreation trap, and the half with no symptom of its own. Deleting and
2352
+ // recreating an Access application changes the audience; the existing warning fires on the
2353
+ // CHALLENGE being wrong, so on a box where somebody recreated the application and then fixed the
2354
+ // sign-in, everything above reads healthy and the stale audience stays invisible until a real
2355
+ // request is rejected.
2356
+ //
2357
+ // No new credential and no provider API: the audience is in the redirect handed to a caller with
2358
+ // no session at all, which is the request just made.
2359
+ {
2360
+ const { readAudience, audienceVerdict } = await import(join(REPO, "shared", "access-audience.mjs"));
2361
+ const configuredAud = effectiveForService("CLEAROTRON_OIDC_AUDIENCE")?.v ?? "";
2362
+ const read = readAudience(probe ?? { error: "the published address was never asked" });
2363
+ // NOTHING IS CLAIMED ABOUT A BOX THAT USES NO EDGE. With no audience configured AND no Access
2364
+ // challenge in front of the address, there is no question here — a local install is not a
2365
+ // misconfigured hosted one, and saying so every time is how a reader learns to skim this.
2366
+ if (configuredAud || read.kind !== "not-fronted") {
2367
+ const v = audienceVerdict({ configured: configuredAud, read });
2368
+ if (v.ok) ok(v.message);
2369
+ // A COULD-NOT-LOOK IS SAID, NOT RAISED. The line above has already reported the address as
2370
+ // unreachable; repeating it as a second failure teaches the reader that this section
2371
+ // double-counts. It still states what was NOT established, which is the whole job.
2372
+ else if (v.kind === "could-not-look") info(v.message);
2373
+ else problem(v.message);
2374
+ }
2375
+ }
2376
+
2339
2377
  // ── CAN AN ASSISTANT ACTUALLY SIGN IN? (tracker issue 149, opt-in) ────────────────────────────
2340
2378
  //
2341
2379
  // The check above reads the CHALLENGE FORM, which is the first of two provider settings that decide
package/bin/start.mjs CHANGED
@@ -1057,6 +1057,12 @@ if (isMain) {
1057
1057
  // — `clearotron demo` hands over to this — so fixing the player alone left the defect where it was.
1058
1058
  const { publishSource } = await import("../driver/demo-container.mjs");
1059
1059
  const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
1060
+ // WHAT WAS ALREADY THERE IS SAID TOO (tracker issue 277). This branch used to run only when the pool
1061
+ // was empty; it now tops a stale pool up to the package's set, so "seeded 1" on an upgrade is a fact
1062
+ // about what was MISSING and says nothing on its own about how many are now listed.
1063
+ if (seed.already?.length) {
1064
+ say(` archive ${seed.already.length} example report(s) already published here`);
1065
+ }
1060
1066
  if (seed.seeded.length) {
1061
1067
  say(` seeded ${seed.seeded.length} example report(s) into ${paths.pool}`);
1062
1068
  // THE LABEL. is delivered: the report now carries the owner's own sample sentence on its
@@ -1070,6 +1076,7 @@ if (isMain) {
1070
1076
  }
1071
1077
  // Never a silent nothing. "The archive is empty" and "the archive is empty and nobody noticed why"
1072
1078
  // look identical in the browser, so both other outcomes are said out loud.
1079
+ if (seed.skipped) say(` archive ${seed.skipped}`);
1073
1080
  for (const p of seed.problems) err(` WARNING: sample seeding — ${p}`);
1074
1081
  } catch (e) {
1075
1082
  err(` WARNING: the example report could not be seeded (${String(e?.message ?? e)}) — the archive will come up empty. Everything else works; \`npm run example\` shows a sample without touching this install.`);
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "1054d6ef379db87612033acbaa73bdb737b5128c",
3
- "version": "0.2.0"
2
+ "commit": "df42f25966148479a5a6d34aba3f82c110a1ba43",
3
+ "version": "0.2.1"
4
4
  }
package/docs/RELEASES.md CHANGED
@@ -8,7 +8,7 @@ nothing else.
8
8
 
9
9
  ```bash
10
10
  npm install -g clearotron # stable — the tested one
11
- npm install -g clearotron@beta # newest — every merge, minutes after it lands
11
+ npm install -g clearotron@beta # newest — cut when there is something worth testing
12
12
  ```
13
13
 
14
14
  ## What each channel promises
@@ -16,7 +16,7 @@ npm install -g clearotron@beta # newest — every merge, minutes after it la
16
16
  | | `latest` (stable) | `beta` |
17
17
  |---|---|---|
18
18
  | **Version looks like** | `0.2.0` | `0.2.1-beta.4` |
19
- | **Cut when** | a beta has passed a full clearance run and a from-scratch install by somebody who has never seen the product, and the owner says go | every merge to `main`, automatically |
19
+ | **Cut when** | a beta has passed a full clearance run and a from-scratch install by somebody who has never seen the product, and the owner says go | when a change lands that is worth testing, or while a stable is being prepared |
20
20
  | **Promises** | it installed and ran a real clearance end to end before it was published | it built, and the automated suite passed |
21
21
  | **Use it if** | you are running this for real work | you want a fix that landed today, or you are helping test |
22
22
 
@@ -35,8 +35,10 @@ suggest for yours.
35
35
 
36
36
  ## How often
37
37
 
38
- A stable is cut when a beta earns it, which we aim at roughly weekly. Betas arrive whenever something
39
- mergesseveral a day when a round is running, none for a day when nothing lands.
38
+ A stable is cut when a beta earns it, which we aim at roughly weekly. **Betas are cut deliberately, days
39
+ apart**when a change lands that somebody should test, or while a stable is being prepared. They are not
40
+ published on every merge, so `beta` is a version somebody chose to publish rather than whatever merged
41
+ last. Some stables have several betas before them and some have none.
40
42
 
41
43
  Numbering follows the change, not the calendar: `0.2.x` for fixes, `0.3.0` when a feature lands, `1.0`
42
44
  when the hit-list redesign ships.
@@ -200,7 +200,7 @@ deployment may override (verify live values per deployment).
200
200
  | `CLEAROTRON_OAUTH_BRIDGE` | module-relative `providers/oauth-mcp-bridge/bridge.mjs` | Case-law MCP bridge script. (Portable since the module-relative default; set explicitly only for a bridge outside the repo tree.) |
201
201
  | `CLEAROTRON_REGISTER_CALL_LOG` | `~/trademark/telemetry/register-calls.jsonl`, or the existing file wherever it already is | Billing-grade provider-call ledger, shared by whichever ONE register provider is wired — not a vendor artifact. Every read site derives the default from`homedir()` at call time (2026-07-19: two sites had hardcoded a literal account home, splitting the ledger under any other service account — guarded by `test/deployment-hostnames.test.mjs`). |
202
202
  | `CLEAROTRON_REGISTER_RECORD_LOG` | **runtime-injected per run**: `<runDir>/_driver/register-record-bodies.jsonl` | Citation-fidelity log: the BODY of every fetched official record. ** moved it INTO the run** — created with the run, unioned into the run's`_records/`, archived and purged with it. There is no retention setting and no cleanup job, because it no longer grows on the box: held globally it reached 432 MB in 61 days on production and needed a rotation timer on every install. **Do not set this by hand** — a fixed value pins every run's bodies to one file and restores the problem. A box upgraded across still holds its old global file; nothing writes or reads it, the driver names it once per process on stderr, and archiving it is one`mv`. An empty log cannot read as verified: the run's successful `record_fetch` rows in the (still global) call ledger are compared against the assembled record set, and a gap is reported as a failure. |
203
- | `CORSEARCH_CALL_LOG` / `CORSEARCH_RECORD_LOG` | — | **Deprecated, honoured for one release.** The pre- names. Unset on every deployed box (all three ran the homedir default), so what actually protects an upgrade is the filename fallback: a`corsearch-calls.jsonl` / `corsearch-records.jsonl` already on disk keeps being read where it sits. Resolution order is in `providers/_shared/ledger-path.mjs`. |
203
+ | `CORSEARCH_CALL_LOG` / `CORSEARCH_RECORD_LOG` | — | **Deprecated, honoured for one release.** These are the names these two variables carried before the rename. Unset on every deployed box (all three ran the homedir default), so what actually protects an upgrade is the filename fallback: a`corsearch-calls.jsonl` / `corsearch-records.jsonl` already on disk keeps being read where it sits. Resolution order is in `providers/_shared/ledger-path.mjs`. |
204
204
  | `CLEAROTRON_BAND_RUN_DIR` | set per dispatch | The run dir the band MCP server writes into, injected per stage — unset means the server has no run to write to and says so rather than guessing one. |
205
205
  | `CLEAROTRON_FEEDBACK_DIR` | `<poolRoot>/_feedback` | Where report feedback flags are stored. Beside the pool by default so a deployment that moves the pool moves the flags with it. |
206
206
  | `USPTO_LOCAL_DB` | **none — set it to use `uspto-local`** | The local USPTO index (`node:sqlite` + FTS5) that `bin/uspto-sync.mjs` builds and the free US register reads. Named in `.env.example`; this is the reference row. |
@@ -1,5 +1,35 @@
1
1
  # prelim-driver
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3cc154a: Fixed: A report now keeps a mark the search confirmed, instead of dropping it because it was already noted on an internal working sheet. Where a mark is still missing, the run records it by name rather than closing the question.
8
+ - 3cc154a: Fixed: Asking a what-if question about a delivered report now returns a memo, instead of failing to find the run it was asked about.
9
+ - 0f7b44a: New: `clearotron demo` now publishes all four example reports — one per product — instead of only the first.
10
+ - 2138a3c: For operators: The `beta` channel now gets a release when there is something worth testing, days apart, instead of one on every merge.
11
+ - 8b5ab91: Fixed: `doctor` no longer reports a working Cloudflare Access door as unprotected. An API-style door and a failing origin are now told apart, each with its own message. Neither is reported as a pass.
12
+ - 77cf56d: For operators: The configuration reference now explains the two deprecated search-log variables in full, instead of stopping mid-sentence.
13
+ - 0dcd05a: Fixed: the settings catalogue now lists `CLEAROTRON_CHECKOUT_DIR`, the path every service file points at. The installer still fills it in for you. It is written down so that anyone whose service will not start can look it up.
14
+
15
+ ## 0.2.1-beta.2
16
+
17
+ ### Patch Changes
18
+
19
+ - 8b5ab91: Fixed: `doctor` no longer reports a working Cloudflare Access door as unprotected. An API-style door and a failing origin are now told apart, each with its own message. Neither is reported as a pass.
20
+
21
+ ## 0.2.1-beta.1
22
+
23
+ ### Patch Changes
24
+
25
+ - 0dcd05a: Fixed: the settings catalogue now lists `CLEAROTRON_CHECKOUT_DIR`, the path every service file points at. The installer still fills it in for you. It is written down so that anyone whose service will not start can look it up.
26
+
27
+ ## 0.2.1-beta.0
28
+
29
+ ### Patch Changes
30
+
31
+ - 77cf56d: For operators: The configuration reference now explains the two deprecated search-log variables in full, instead of stopping mid-sentence.
32
+
3
33
  ## 0.2.0
4
34
 
5
35
  ### Minor Changes
@@ -574,6 +574,64 @@ export function parseAskClosureLines(text) {
574
574
  return out;
575
575
  }
576
576
 
577
+ /**
578
+ * The marks the CLIENT was actually shown — `findings[].mark` from findings.json, normalized.
579
+ *
580
+ * Returns null when the file is absent or unparseable, and null is NOT an empty set: an absence is a
581
+ * could-not-look, and the caller below fails toward leaving the ask OPEN rather than closing it on a
582
+ * file it could not read.
583
+ *
584
+ * ✕ NEVER a substring search of the serialized document. `"DELFIN" in JSON.stringify(findings)` is true
585
+ * when the findings name DELFIN TECHNOLOGIES OY and nothing else — a membership test that matches every
586
+ * longer name inflates whatever it is counting and reads as a clean result. The field, or nothing.
587
+ * PURE.
588
+ */
589
+ export function deliveredMarks(findingsJsonText) {
590
+ let doc;
591
+ try { doc = JSON.parse(String(findingsJsonText ?? "")); } catch { return null; }
592
+ const rows = Array.isArray(doc?.findings) ? doc.findings : null;
593
+ if (!rows) return null;
594
+ return new Set(rows.map((f) => normMark(f?.mark)).filter(Boolean));
595
+ }
596
+
597
+ const normMark = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
598
+
599
+ /**
600
+ * Owner ruling 2026-09-07 (tracker issue 246), Option A, worded ABOUT THE MARK: a recall ask may close
601
+ * as immaterial only when the mark it went looking for is in the client's findings. Otherwise the mark
602
+ * is owed a finding and the ask stays open.
603
+ *
604
+ * The defect this answers: a recall probe found OSLER DELPHI, the closure stage pointed at
605
+ * `register-findings.md` — the run's own working sheet, where the mark genuinely IS reasoned — and the
606
+ * ask closed. The sheet is a citable file and the citation was true. It is simply not the document the
607
+ * client reads, and nothing checked that.
608
+ *
609
+ * ✕ SCOPED TO `ask:recall:` ASKS CARRYING A `mark_text`, deliberately, and this is the whole difference
610
+ * between the two readings of the ruling. `ask:recall-overflow:` rows carry a `term` — an owner name, a
611
+ * law firm, a probe never dispatched (Lewis Silkin LLP, NORDWEST Handel AG). Measured across three runs
612
+ * they are 28-29 per run and constant whether the run was healthy or not; reopening them would put
613
+ * names no lawyer asked for into the findings. A `mark_text` is the test because a mark is what a
614
+ * finding is about.
615
+ *
616
+ * Returns the owed row, or null when the ask is out of scope or the mark was delivered. PURE.
617
+ */
618
+ export function recallMarkOwed(ask, delivered) {
619
+ if (!String(ask?.ask_id ?? "").startsWith("ask:recall:")) return null;
620
+ const st = ask?.ask?.structured ?? {};
621
+ const mark = String(st.mark_text ?? "").trim();
622
+ if (!mark) return null; // an owner/term probe is not a mark ask
623
+ if (delivered instanceof Set && delivered.has(normMark(mark))) return null;
624
+ return {
625
+ ask_id: ask.ask_id,
626
+ mark,
627
+ owner: String(st.owner ?? "").trim() || null,
628
+ uri: String(st.uri ?? "").trim() || null,
629
+ // null delivered = findings.json unreadable. Recorded so a reader can tell "the client was not shown
630
+ // this mark" from "nobody could tell what the client was shown" — they need different repairs.
631
+ basis: delivered instanceof Set ? "absent-from-findings" : "findings-unreadable",
632
+ };
633
+ }
634
+
577
635
  /**
578
636
  * The anti-confabulation guard, ask side (doubt-ledger applyClosure reused shape-for-shape): an
579
637
  * IMMATERIAL line ends its ask IFF the ask is still open AND the quote appears VERBATIM
@@ -586,7 +644,9 @@ export function applyAskClosure(asks, closureLines, fileTexts = {}, { ts = null
586
644
  const byId = new Map();
587
645
  for (const l of closureLines ?? []) if (l?.id && !byId.has(l.id)) byId.set(l.id, l);
588
646
  const unverified = [];
647
+ const carryIntoFindings = [];
589
648
  let immaterialByStage = 0;
649
+ const delivered = deliveredMarks(fileTexts?.["findings.json"]);
590
650
  const out = (asks ?? []).map((a) => {
591
651
  if (a?.ending) return a; // the stage may never touch an ended ask
592
652
  const l = byId.get(a.ask_id);
@@ -595,13 +655,21 @@ export function applyAskClosure(asks, closureLines, fileTexts = {}, { ts = null
595
655
  const hay = squash(fileTexts?.[l.file]);
596
656
  const q = squash(l.quote);
597
657
  if (q && hay && hay.includes(q)) {
658
+ // THE CITATION IS VERIFIED AND THAT IS NO LONGER ENOUGH FOR A RECALL ASK ABOUT A MARK.
659
+ const owed = recallMarkOwed(a, delivered);
660
+ if (owed) {
661
+ carryIntoFindings.push(owed);
662
+ return { ...a, handoff: clip(`the recall probe found ${owed.mark} and the delivered findings do not name it — `
663
+ + `the closure cited ${l.file}, which is the run's own working sheet, not what the client was shown. `
664
+ + `This mark is owed a finding.`, 300) };
665
+ }
598
666
  immaterialByStage++;
599
667
  return { ...a, ending: mkEnding("judged-immaterial", "doubt-closure-stage", { evidence: `${l.file}: "${clip(l.quote)}"`, reasons: [l.reason], ts }) };
600
668
  }
601
669
  unverified.push({ ask_id: a.ask_id, file: l.file, quote: l.quote });
602
670
  return a;
603
671
  });
604
- return { asks: out, immaterialByStage, unverified };
672
+ return { asks: out, immaterialByStage, unverified, carryIntoFindings };
605
673
  }
606
674
 
607
675
  /** Stamp the default handoff on every still-open ask (after closure) — an OPEN ask always names
@@ -2,7 +2,7 @@
2
2
  "name": "prelim-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.2.0",
5
+ "version": "0.2.1",
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": {
@@ -114,7 +114,7 @@ import { escalatedAxes } from "./skeptic-record.mjs"; // THE escalation parse
114
114
  import { PLACEMENT_CARRY_SCHEMA_VERSION, reconcilePlacementCarry, placementCarryEvent, mintPlacementCarryDoubts, entryUris } from "./placement-carry.mjs";
115
115
  import { FLOOR_DUTY_SCHEMA_VERSION, reconcileFloorDuty, floorDutyEvent, armFloorDuty, floorDutyArmed, floorDutyBlock, floorDutyBlocksSkip } from "./floor-duty.mjs"; // — the era stamp that turns disclosure into a delivery floor
116
116
  import { synthesisDutyForRun } from "./synthesis-record.mjs"; // — the duty checked against the DELIVERED document
117
- import { RECORD_CARRY_SCHEMA_VERSION, traceRecordCarry, parseStageOutcomes, recordCarryEvent, mintRecordCarryDoubts, bandRecordUri, placementIndex, findingUris , silentlyLostFindings } from "./record-carry.mjs";
117
+ import { RECORD_CARRY_SCHEMA_VERSION, traceRecordCarry, parseStageOutcomes, recordCarryEvent, mintRecordCarryDoubts, bandRecordUri, placementIndex, findingUris , silentlyLostFindings, statedDivergenceFindings } from "./record-carry.mjs";
118
118
  import { reconcileSurfaceDuty, surfaceDutyNote } from "./surface-duty.mjs"; // item 3 — silence at the findings surface, read off the rows above
119
119
  import { DISCARD_LEDGER_NAME, seamRows, appendDiscardRows, foldDiscardLedger } from "./record-discard.mjs";
120
120
  import { readDeclinations } from "./declination-tool.mjs"; // — synthesis's own stated declines
@@ -4651,7 +4651,7 @@ export function buildFailurePacket({ runId, agent, job = {}, failedStage, shortR
4651
4651
  // was healthy and answering, re-triggering now hits the same cap, and "overloaded" points the reader
4652
4652
  // at an availability problem they cannot act on.
4653
4653
  //
4654
- // The owner pre-committed the wording watching indigo-falcon: a cap death is reported as a
4654
+ // The owner pre-committed the wording watching a capped run: a cap death is reported as a
4655
4655
  // SUBSCRIPTION OUTCOME, NEVER AS AN ENGINE FINDING. So this names the cap, states how long the run
4656
4656
  // actually held out before giving up, and says plainly that nothing about the run or the mark
4657
4657
  // produced it.
@@ -13215,6 +13215,30 @@ async function pipelineInner(job, opts = {}) {
13215
13215
  note(`recall: ${silent.lost.length} position(s) the digest ended as FINDINGS reached no client `
13216
13216
  + `surface and no step said why — ${silent.lost.map((l) => `${l.mark ?? l.uri} (${l.reason})`).join("; ")}`);
13217
13217
  }
13218
+ // ── AND THE STATED CASE, WHICH IS THE ONE THAT REACHED A CLIENT (tracker issue 248) ────────
13219
+ //
13220
+ // Same inputs, same seam, same best-effort contract — a second call rather than a widened first
13221
+ // one, because the sibling's population boundary is deliberate and correct. It covers a
13222
+ // finding-ending followed by SILENCE; this covers one followed by a SENTENCE.
13223
+ //
13224
+ // On R2 `russet-kestrel` the sibling logged `checked:5 matched:5 lost:0` — correctly — on a
13225
+ // delivery missing two marks from the lawyer's final list, one rated HIGH. They were dropped with
13226
+ // a reason, so they were outside its population by design. A drop with a reason nobody verifies
13227
+ // is not accounted for; it is unexamined with a paper trail, and that delivery carried 66 of them.
13228
+ const stated = statedDivergenceFindings({
13229
+ reconciliation: safeReadJson(P.recallReconciliation),
13230
+ carryRows: safeReadJson(P.recordCarry)?.rows ?? null,
13231
+ digestFindingUris: recordedFindingUris(P.runDir),
13232
+ });
13233
+ runLog(P.runDir, { event: "stated-divergence-findings", computable: stated.computable,
13234
+ reason: stated.reason, population_empty: stated.population_empty === true,
13235
+ checked: stated.checked, matched: stated.matched, diverged: stated.diverged.length,
13236
+ marks: stated.diverged.map((d) => d.mark).filter(Boolean).slice(0, 10) });
13237
+ if (stated.diverged.length) {
13238
+ note(`recall: ${stated.diverged.length} position(s) the digest ended as FINDINGS are absent from `
13239
+ + "the findings and were dropped with a STATED reason pointing at another artifact — "
13240
+ + stated.diverged.map((d) => `${d.mark ?? d.uri} (${d.reason}${d.cites_artifact ? ` → ${d.cites_artifact.join(", ")}` : ""})`).join("; "));
13241
+ }
13218
13242
  } catch (e) { /* never mask a delivery */ }
13219
13243
  // — the common-law path, and the jx zh slice on the same tracer. `consumed` used to read
13220
13244
  // CLEAROTRON_JX_CONSUME, which decided whether a zh candidate could reach a finding at all: with it
@@ -13486,6 +13510,21 @@ async function pipelineInner(job, opts = {}) {
13486
13510
  const askApplied = applyAskClosure(runAsks, acc.ask, fileTexts, { ts: askTs });
13487
13511
  runAsks = askApplied.asks;
13488
13512
  for (const u of askApplied.unverified) runLog(run.runDir, { event: "ask-closure-unverified", ...u });
13513
+ // Option A (tracker issue 246): a recall ask whose mark is not in the delivered findings
13514
+ // did NOT close. Each one is a mark the run found and the client was not shown, so it is
13515
+ // recorded by name rather than left to be inferred from an ask that merely stayed open.
13516
+ // ✕ NOT written into findings.json here. See the sidecar's own note: a row appended after
13517
+ // the drafting stage would carry no band and no reasoning.
13518
+ for (const c of askApplied.carryIntoFindings ?? [])
13519
+ runLog(run.runDir, { event: "ask-closure-mark-owed", ...c });
13520
+ if (askApplied.carryIntoFindings?.length)
13521
+ writeFileSync(driverDir(run.runDir, "marks-owed-a-finding.json"),
13522
+ JSON.stringify({
13523
+ _provenance: "recall asks that could not close because the mark they found is absent from findings.json — owner ruling 2026-09-07, tracker issue 246 Option A, worded about the MARK",
13524
+ _notWritten: "these are NOT appended to findings.json: a finding carries a band, a net line and a legal position, and a row minted here would carry none of them. The repair belongs where the findings are authored.",
13525
+ count: askApplied.carryIntoFindings.length,
13526
+ marks: askApplied.carryIntoFindings,
13527
+ }, null, 2) + "\n");
13489
13528
  }
13490
13529
  } else {
13491
13530
  note(`doubt-closure failed (non-fatal — the open doubts/asks ship OPEN, as without the stage): ${dc.fail}`);
@@ -701,7 +701,7 @@ export function competitorClaimChecks({ text, ownerScreen, recordsByUri, markVoc
701
701
  // ── COVERAGE CLAIMS IN PROSE vs WHAT THE RUN ACTUALLY SEARCHED (tracker issue 134) ──────────────────
702
702
  //
703
703
  // THE DEFECT. `coverage_line:` is code-stamped from scope-facts.json; the narrative is model-written
704
- // prose. Nothing bound them to one searched-territory set. On `amber-summit` the masthead read
704
+ // prose. Nothing bound them to one searched-territory set. On one recorded run the masthead read
705
705
  // `registers: JP, WO` while the narrative said "Register searches covered Japan and Korea" — one of
706
706
  // them was wrong and nothing detected it until a human compared the two surfaces by eye. They agree
707
707
  // on today's runs because a prompt fix stopped the input contradicting itself, which is evidence the