clearotron 0.3.1-beta.2 → 0.3.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/grant.mjs +21 -22
  2. package/build-info.json +2 -2
  3. package/driver/CHANGELOG.md +33 -0
  4. package/driver/citation-census.json +3 -3
  5. package/driver/common-law-receipts.mjs +11 -1
  6. package/driver/connotation-search.mjs +56 -5
  7. package/driver/contract-e3-backlog.mjs +31 -31
  8. package/driver/contract-vocabulary.mjs +59 -58
  9. package/driver/engine/mcp/gather-config.mjs +1 -1
  10. package/driver/gateway.mjs +29 -2
  11. package/driver/matter-frame-record.mjs +54 -1
  12. package/driver/package.json +1 -1
  13. package/driver/portal-config-view.mjs +26 -1
  14. package/driver/portal-report.mjs +16 -2
  15. package/driver/portal-service.mjs +327 -8
  16. package/driver/publish/render.mjs +0 -45
  17. package/driver/publish/templates/report.css +1 -30
  18. package/driver/skills/knockout-assess/SKILL.md +35 -6
  19. package/driver/skills/knockout-frame/SKILL.md +2 -1
  20. package/driver/skills/prelim-search/risk-framework-triage.md +3 -3
  21. package/driver/stages.mjs +19 -5
  22. package/driver/suite-census.json +112 -16
  23. package/driver/verify-knockout.mjs +58 -0
  24. package/driver/verify.mjs +83 -7
  25. package/mcp-server/CHANGELOG.md +11 -0
  26. package/mcp-server/lib/audit.mjs +96 -3
  27. package/mcp-server/lib/http-handler.mjs +76 -3
  28. package/mcp-server/lib/runs.mjs +42 -2
  29. package/mcp-server/package.json +1 -1
  30. package/mcp-server/packs/client/CONNECT.md +10 -6
  31. package/mcp-server/server.mjs +37 -3
  32. package/package.json +1 -1
  33. package/portal-ui/dist/assets/{index-D8ITW-aD.js → index-ChIQsMYp.js} +1375 -817
  34. package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-DBIs21e4.css} +37 -0
  35. package/portal-ui/dist/index.html +2 -2
  36. package/portal-ui/package.json +1 -1
  37. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  38. package/providers/oauth-mcp-bridge/package.json +1 -1
  39. package/scripts/ask-ai-render-check.mjs +359 -0
  40. package/scripts/citation-line-check.mjs +53 -2
  41. package/scripts/deprecate-below.mjs +146 -0
  42. package/scripts/test-run.mjs +24 -1
  43. package/shared/connect-clients.mjs +52 -16
  44. package/shared/driver-dir.mjs +1 -1
  45. package/shared/grants-edit.mjs +99 -2
package/bin/grant.mjs CHANGED
@@ -64,7 +64,7 @@
64
64
  import "../shared/env-local.mjs";
65
65
  import { readFileSync, existsSync } from "node:fs";
66
66
  import { assertGrantsShape } from "../shared/scope.mjs"; // — one shape check, not a second opinion
67
- import { withPerson, withOrganisation, withCompany } from "../shared/grants-edit.mjs"; // — the People page's own editors
67
+ import { withPerson, withoutPerson, personPoints, withOrganisation, withCompany } from "../shared/grants-edit.mjs"; // — the People page's own editors
68
68
  import { basename } from "node:path";
69
69
  import { atomicWrite } from "../driver/progress.mjs";
70
70
  import { accessView } from "../driver/portal-config-view.mjs";
@@ -287,27 +287,26 @@ if (cmd === "remove") {
287
287
  const email = String(argv[1] ?? "").trim().toLowerCase();
288
288
  const only = flag("tenant");
289
289
  if (!email) usage();
290
- let removed = 0;
291
- for (const [name, t] of Object.entries(grants.tenants)) {
292
- if (only && name !== only) continue;
293
- if (t?.users && email in t.users) { delete t.users[email]; removed++; }
294
- // AN EMPTY `users` MAP IS NOT A DELETED TENANT, and this writes the former deliberately: the tenant
295
- // still exists and still holds its accounts, it simply has nobody on its guest list. Deleting the
296
- // tenant here would destroy configuration the operator never asked to remove, and it round-trips —
297
- // `remove-tenant` is the other verb, and it is explicit.
298
- }
299
- // AND THE PERSON'S OWN ENTRY, when they are removed from everywhere. Their permissions and their access
300
- // to everything live under `people`, not in any tenant, so a removal that left that entry would leave a
301
- // person who sees everything still seeing everything. Removed from one tenant, they may still hold
302
- // access elsewhere, and their entry stays.
303
- const peopleKey = only ? undefined : Object.keys(grants.people ?? {}).find((k) => k.toLowerCase() === email);
304
- if (peopleKey !== undefined) delete grants.people[peopleKey];
305
- if (!removed && peopleKey === undefined) die(`${email} is not on the guest list${only ? ` for "${only}"` : ""}. Nothing written.`);
306
- atomicWrite(FILE, JSON.stringify(grants, null, 2) + "\n");
307
- out(`Removed ${email} from ${removed} tenant(s)${peopleKey !== undefined ? ", and their permissions under people" : ""}. Written to ${basename(FILE)}.`);
308
- const kept = Object.entries(grants.people ?? {}).find(([k]) => k.toLowerCase() === email)?.[1];
309
- if (kept?.everything === true)
310
- out(`${email} still has access to everything through their entry under people. \`grant remove ${email}\` without --tenant takes that away too.`);
290
+ // THROUGH THE SAME EDITOR THE PORTAL USES. This command used to delete the keys itself, and the two
291
+ // paths had drifted: `people` was matched without regard to case here and the tenant guest lists were
292
+ // matched exactly, so a hand-edited file holding `Dana@x` under an organisation kept that row through a
293
+ // removal that reported success. One function now answers for both faces.
294
+ const held = personPoints(grants, email);
295
+ const points = only ? held.points.filter((p) => p.tenant === only) : [];
296
+ if (!held.listed && !held.points.length)
297
+ die(`${email} is not on the guest list. Nothing written.`);
298
+ if (only && !points.length)
299
+ die(`${email} is not on the guest list for "${only}". Nothing written.`);
300
+ let after;
301
+ // A TENANT-SCOPED REMOVAL OF SOMEBODY WHO HOLDS EVERYTHING IS REFUSED, where it used to be done and
302
+ // then warned about. Access to everything lives under `people` and in no organisation, so striking one
303
+ // organisation's row changed nothing the person could see — and the sentence saying they had been
304
+ // removed was printed first, with the correction below it.
305
+ try { after = withoutPerson(grants, { email, points, all: !only }); }
306
+ catch (e) { die(`${String(e?.message ?? e)} Nothing written.`); }
307
+ const orgs = only ? 1 : new Set(held.points.map((p) => p.tenant)).size;
308
+ atomicWrite(FILE, JSON.stringify(after, null, 2) + "\n");
309
+ out(`Removed ${email} from ${orgs} tenant(s)${only ? "" : held.listed ? ", and their permissions under people" : ""}. Written to ${basename(FILE)}.`);
311
310
  process.exit(0);
312
311
  }
313
312
 
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "3e6266f5960e7990e767edbdb95ca50831156b55",
3
- "version": "0.3.1-beta.2"
2
+ "commit": "770229d7131d7aee6f8fb32e2f90c02b44e08d2a",
3
+ "version": "0.3.1-beta.4"
4
4
  }
@@ -1,5 +1,38 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.1-beta.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 6d0c8f5: Fixed: The portal now tells your browser not to store the data its screens read. Those responses carry people's names, company access and run lists, and nothing previously said how long a browser could keep them.
8
+
9
+ For operators: every JSON response from the portal now sends `Cache-Control: no-store` and `Vary: Accept`. A route that sets a stricter policy of its own keeps it.
10
+
11
+ ## 0.3.1-beta.3
12
+
13
+ ### Patch Changes
14
+
15
+ - d0d42f7: Fixed: A name in a knockout batch is no longer rated above every conflict found against it. Its rating now follows from the conflicts on its own page.
16
+
17
+ Fixed: Before, a rule forced any name made of everyday words off the lowest band, whatever the search found. That rule is gone for every client.
18
+ - 56a760f: Fixed: Giving somebody access to everything on the installation now works from the access form. It used to refuse. The message it refused with said you can only give access to what you hold yourself, which was not true of the person seeing it.
19
+ - 8c3bc3b: New: Ask AI on a report now opens Claude or ChatGPT with a question about that report already typed in. One press, in a new tab, and nothing is sent until you send it.
20
+
21
+ The button used to hand over a connector address and a question carrying the run's internal code, with no indication of which one you needed. The address belongs on the Use your own AI page, where you set the connector up once. It is no longer shown on reports at all.
22
+
23
+ If you have not connected an assistant yet, the button explains that in a line and offers to take you there.
24
+
25
+ For operators: the report's own "Ask your AI" band is gone, so there is one Ask AI control rather than two. Reports rendered before this upgrade keep the band in their own file, and it is hidden when the portal serves them.
26
+ - 56a760f: Fixed: `clearotron grant remove --tenant` now refuses when the person has access to everything on the installation. It used to remove the organisation and then warn that nothing they could see had changed.
27
+
28
+ Fixed: Removing somebody whose address is spelled with different capitalisation in different parts of the access file now removes all of them. Half of the entry used to survive, and the command reported success.
29
+ - 56a760f: New: Access can now be narrowed and taken away, not only added to. Somebody who manages one organisation removes that organisation alone. Somebody who can see all of a person removes their access to the installation, and withdraws the keys their AI assistant was using.
30
+
31
+ Fixed: A removal now says plainly when the connector cannot be told about it yet, instead of implying the assistant lost access too.
32
+ - f1c5925: Fixed: Where a search covers two ratified forms of a name, the report now reasons each form and says which conflicts differ between them.
33
+
34
+ Fixed: Before, both forms were searched but one combined read came back. When the forms read alike the report now says so, rather than leaving it unsaid.
35
+
3
36
  ## 0.3.1-beta.2
4
37
 
5
38
  ### Patch Changes
@@ -1,5 +1,5 @@
1
1
  {
2
- "citations": 621,
3
- "files": 1892,
4
- "checkable": 102
2
+ "citations": 545,
3
+ "files": 3878,
4
+ "checkable": 105
5
5
  }
@@ -21,7 +21,17 @@ import { driverDir } from "../shared/driver-dir.mjs"; // — one definition of
21
21
 
22
22
  export const MIN_CELLS_PER_VARIANT = 7;
23
23
 
24
- const norm = (s) => (s || "").trim().replace(/^["'`]+|["'`]+$/g, "").toLowerCase();
24
+ // THE GRID CELL KEY, and it is the same class as the connotation join: one side is a term and platform
25
+ // WE dictated, the other is what a provider echoed into the ledger. It already stripped wrapping quotes
26
+ // and lowercased; it did not fold a CURLY quote, an em dash, a non-breaking space or a compatibility
27
+ // form, so the failure that stopped a production clearance on 2026-09-16 was available here too — one
28
+ // gate over, with the same shape and no arm on it.
29
+ //
30
+ // It composes `queryKey`, which is the one author of what "the same text" means across a provider
31
+ // boundary, and keeps its own wrapping-quote strip because a term arrives quoted here and does not there.
32
+ import { queryKey } from "./connotation-search.mjs";
33
+
34
+ const norm = (s) => queryKey(String(s || "").trim().replace(/^["'`\u2018\u2019\u201c\u201d]+|["'`\u2018\u2019\u201c\u201d]+$/g, ""));
25
35
 
26
36
  // A table data row's cells, or null if the line isn't one.
27
37
  function rowCells(ln) {
@@ -45,7 +45,10 @@ export function pickConnotationTerms(terms, maxTerms = 6) {
45
45
  for (const t of (terms ?? [])) {
46
46
  const v = String(t ?? "").trim();
47
47
  if (!v) continue;
48
- const k = v.toLowerCase();
48
+ // KEYED THE SAME WAY THE GATES COMPARE. A producer that folds less than its consumer dictates two
49
+ // terms the gate can only see as one, and a seat recording either satisfies it for both — the gate
50
+ // then passes a query that never ran, which is the fault it exists to catch, inverted.
51
+ const k = queryKey(v);
49
52
  if (seen.has(k)) continue;
50
53
  seen.add(k);
51
54
  picked.push(v);
@@ -83,7 +86,7 @@ const NON_LATIN_LETTER_RE = /(?![\p{Script=Latin}])\p{L}/u;
83
86
  * Returns ["<form> <shape>", …] like buildConnotationQueries. PURE.
84
87
  */
85
88
  export function buildTranslitConnotationQueries(modelVariants, { shapes = CONNOTATION_SHAPES_TRANSLIT, maxTerms = 8, coreTerms = [] } = {}) {
86
- const seen = new Set((coreTerms ?? []).map((t) => String(t ?? "").trim().toLowerCase()).filter(Boolean));
89
+ const seen = new Set((coreTerms ?? []).map((t) => queryKey(t)).filter(Boolean));
87
90
  const picked = [];
88
91
  for (const v of (modelVariants ?? [])) {
89
92
  const value = String(v?.value ?? "").trim();
@@ -93,7 +96,7 @@ export function buildTranslitConnotationQueries(modelVariants, { shapes = CONNOT
93
96
  for (const form of value.split(" / ")) {
94
97
  const f = form.trim();
95
98
  if (!f) continue;
96
- const k = f.toLowerCase();
99
+ const k = queryKey(f);
97
100
  if (seen.has(k)) continue;
98
101
  seen.add(k);
99
102
  picked.push(f);
@@ -177,7 +180,7 @@ export function meaningAnglesFromMatterContext(md, { alreadyQueried = [], maxAng
177
180
  if (!m) return [];
178
181
  const value = m[1].trim();
179
182
  if (/^none\b/i.test(value)) return [];
180
- const seen = new Set((alreadyQueried ?? []).map((q) => String(q ?? "").trim().toLowerCase()).filter(Boolean));
183
+ const seen = new Set((alreadyQueried ?? []).map((q) => queryKey(q)).filter(Boolean));
181
184
  const picked = [];
182
185
  for (const part of value.split(";")) {
183
186
  const q = sanitizeMeaningAngle(part);
@@ -186,7 +189,7 @@ export function meaningAnglesFromMatterContext(md, { alreadyQueried = [], maxAng
186
189
  // keeps it, and a lone `"` dictated as a query is a search nobody asked for that no receipt can ever
187
190
  // match — the failure this change exists to stop. Dropped explicitly rather than as a side effect.
188
191
  if (!q || !/[\p{L}\p{N}]/u.test(q) || q.length > maxLen) continue;
189
- const k = q.toLowerCase();
192
+ const k = queryKey(q);
190
193
  if (seen.has(k)) continue;
191
194
  seen.add(k);
192
195
  picked.push(q);
@@ -225,6 +228,54 @@ const SECTION_RE = /reputational|connotation/i;
225
228
  * in common-law-receipts.mjs (parsePrRiskQueries); this entries parser lives here so the P2-A-owned file is
226
229
  * untouched. Never throws; an unparseable ledger reads as no recorded entries. PURE.
227
230
  */
231
+ /**
232
+ * The key two spellings of one meaning query are compared on. ONE AUTHOR, here, beside the parser that
233
+ * reads the ledger — the gate in verify.mjs imports it rather than carrying a second copy.
234
+ *
235
+ * WHY THE RAW STRINGS CANNOT BE COMPARED. The dictated queries are written by the driver; the recorded
236
+ * ones come back through a provider, which returns the text it echoes with typographic punctuation. On a
237
+ * production clearance, 2026-09-16, exactly one of sixty-one queries differed — a single character, a
238
+ * right single quotation mark where the driver wrote an apostrophe, at the same length. The grid had run
239
+ * every query and recorded every one. The join said a query was unrecorded, the stage failed four times
240
+ * byte-identically, and the clearance stopped.
241
+ *
242
+ * THE ORIGINAL TEXT IS NOT TOUCHED. This is a comparison key and nothing else: the ledger keeps what the
243
+ * provider returned and the refusal quotes what the driver dictated, because a reader chasing a genuinely
244
+ * missing query needs the spelling that was asked for, not a flattened one.
245
+ *
246
+ * WHAT IS FOLDED, and nothing beyond it: the four curly quotes to their straight forms, a non-breaking
247
+ * space to a space, runs of whitespace to one, and case. Deliberately NOT accents or punctuation in
248
+ * general — two queries differing by a letter are two queries, and a key that folded them would hide the
249
+ * skipped-query fault this gate exists to catch.
250
+ *
251
+ * ONE HALF OF THAT SENTENCE NEEDS SAYING OUT LOUD, because omitting it cost a fail-open. NFKC folds the
252
+ * two ENCODINGS of one accented letter — composed U+00E9 and decomposed e + U+0301 — while leaving the
253
+ * accented letter distinct from its unaccented form. Mixed-source transliterations are exactly where
254
+ * both encodings arrive, so two producer rows that look different fold to one key here. EVERY producer
255
+ * that dedups a dictated list must therefore key on THIS function: a producer folding less than the gate
256
+ * dictates queries the gate cannot tell apart, and the gate then passes on a seat recording one of them.
257
+ */
258
+ export const queryKey = (s) => String(s ?? "")
259
+ // NFKC FIRST, and it does the largest share of the work: it folds the compatibility forms a provider
260
+ // can return for characters we wrote plainly — full-width Latin, ligatures, the non-breaking space in
261
+ // some sources, composed accents to a single canonical form. Doing it first also means the explicit
262
+ // folds below only have to name what Unicode does NOT unify, which is the punctuation classes.
263
+ .normalize("NFKC")
264
+ .replace(/[\u2018\u2019\u201a\u201b\u2032\u00b4\u0060]/g, "'") // single quotes, primes, backtick
265
+ .replace(/[\u201c\u201d\u201e\u201f\u2033\u00ab\u00bb]/g, '"') // double quotes and guillemets
266
+ .replace(/[\u2010-\u2015\u2212\u2043]/g, "-") // every dash and minus form
267
+ .replace(/\u2026/g, "...") // an ellipsis is three dots
268
+ .replace(/[\u200b\u200c\u200d\u2060\ufeff]/g, "") // zero-width: invisible, never meaning
269
+ .replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ") // every space form NFKC left alone
270
+ .replace(/\s+/g, " ")
271
+ .trim()
272
+ // TRAILING PUNCTUATION ONLY, never leading and never internal. A provider commonly returns a question
273
+ // it was handed with a full stop or question mark appended; it does not commonly remove one from the
274
+ // front, and stripping internal punctuation would fold two genuinely different queries together.
275
+ .replace(/[.,;:!?]+$/, "")
276
+ .trim()
277
+ .toLowerCase();
278
+
228
279
  export function parsePrRiskResults(ledgerRaw) {
229
280
  let parsed;
230
281
  try { parsed = JSON.parse(ledgerRaw); } catch { return []; }
@@ -185,7 +185,7 @@ export const E3_BACKLOG = [
185
185
  // it one: authored once in renderConnotationObligations, and reaching the seat through the perplexity
186
186
  // MCP server (driver/engine/mcp/perplexity-server.mjs:111) rather than a stage message. Three rows
187
187
  // pointing at one block would fabricate two authored sites the surface does not have.
188
- where: "driver/connotation-search.mjs:1384-1397 (renderConnotationObligations; delivered to the seat via driver/engine/mcp/perplexity-server.mjs:111 — a TOOL RESPONSE, no longer a stage message)",
188
+ where: "driver/connotation-search.mjs:1435-1448 (renderConnotationObligations; delivered to the seat via driver/engine/mcp/perplexity-server.mjs:111 — a TOOL RESPONSE, no longer a stage message)",
189
189
  surface: "tool-response",
190
190
  // ── (a) THE ORIGINAL SUBJECT WAS DELETED BY DESIGN — DISCHARGED, NOT FAILED ────────────────────
191
191
  // The old rows dictated `receipt_id` (an 8-char token the seat copied) and `quote`. Both are gone:
@@ -256,7 +256,7 @@ export const E3_BACKLOG = [
256
256
  {
257
257
  stage: "register-unit",
258
258
  kind: "literal-json-skeleton",
259
- where: "driver/stages.mjs:2243 (the non-supplemental-lane branch)",
259
+ where: "driver/stages.mjs:2253 (the non-supplemental-lane branch)",
260
260
  surface: "stage-message",
261
261
  evidence: "BAND ARTIFACT (MANDATORY): ALSO write the COMPLETE NAMED BAND for this axis to <path> — a JSON ARRAY, one block per register_enumerate / count-probe call, in the named-band contract: {\"state\":\"enumerated\",\"query\":\"<what was searched>\",\"total_hits\":N,\"records\":[{record_id, mark_text, classes, status,",
262
262
  reparsedBy: "driver/named-band.mjs parseNamedBand / bandRecords / bandCrowds / mergeNamedBands (named in driver/skills/prelim-register/unit.md:60-62); validators.registerUnit",
@@ -265,7 +265,7 @@ export const E3_BACKLOG = [
265
265
  {
266
266
  stage: "register-unit",
267
267
  kind: "exactly-these-keys",
268
- where: "driver/stages.mjs:2207",
268
+ where: "driver/stages.mjs:2217",
269
269
  surface: "stage-message",
270
270
  evidence: "Every block you append MUST carry \"state\":\"enumerated\" (ONLY if you paged it to has_more:false) or \"state\":\"incomplete\" — EXACTLY those two strings; there is no \"verified\"/\"checked\"/\"complete\"/\"clean\" state, and any other value fails the stage.",
271
271
  reparsedBy: "driver/named-band.mjs parseNamedBand (off-enum state fails validators.registerUnit)",
@@ -283,7 +283,7 @@ export const E3_BACKLOG = [
283
283
  {
284
284
  stage: "register-unit",
285
285
  kind: "exactly-these-keys",
286
- where: "driver/stages.mjs:4177 (the frame-reopen / scoped-retry message builder). A second number stood here and had been stale for some time: it pointed at a contract-element description rather than a builder, at its old line and at every mechanical shift of it. Two candidate builders sit beside 4157 and picking one would be a guess, so the wrong pointer is removed rather than moved a third time — one accurate citation beats one accurate and one invented.",
286
+ where: "driver/stages.mjs:4191 (the frame-reopen / scoped-retry message builder). A second number stood here and had been stale for some time: it pointed at a contract-element description rather than a builder, at its old line and at every mechanical shift of it. Two candidate builders sit beside 4157 and picking one would be a guess, so the wrong pointer is removed rather than moved a third time — one accurate citation beats one accurate and one invented.",
287
287
  surface: "stage-message",
288
288
  evidence: "Every block you append MUST carry \"state\":\"enumerated\" (ONLY if paged to has_more:false) or \"state\":\"incomplete\" — EXACTLY those two strings … (re-dispatch builders, which REPLACE def.message)",
289
289
  reparsedBy: "driver/named-band.mjs parseNamedBand. Scope warning: these builders replace def.message on every escalation / envelope-close / frame-reopen dispatch, so an E3 lint that walks STAGES[*].message only never sees them",
@@ -301,7 +301,7 @@ export const E3_BACKLOG = [
301
301
  {
302
302
  stage: "placement-inquiry",
303
303
  kind: "exactly-these-keys",
304
- where: "driver/stages.mjs:2403",
304
+ where: "driver/stages.mjs:2413",
305
305
  surface: "stage-message",
306
306
  evidence: "· tier EXACTLY one of headline-candidate / sheet-2 / watchlist-annex / out-of-scope-filtered.",
307
307
  reparsedBy: "driver/placement-form.mjs / driver/placement-model.mjs via validators.placement",
@@ -346,7 +346,7 @@ export const E3_BACKLOG = [
346
346
  // (coverage-form.mjs coverageFormBrief) and the tool schema, with every row receiver-validated at
347
347
  // call time. Same dictated vocabulary, same authored site, new route — this row's evidence follows
348
348
  // the dictation so the row keeps describing something that exists.
349
- where: "driver/stages.mjs:2608 (the digest message) + driver/coverage-form.mjs (coverageFormBrief — the dispatch block carrying the enum and the row shape)",
349
+ where: "driver/stages.mjs:2618 (the digest message) + driver/coverage-form.mjs (coverageFormBrief — the dispatch block carrying the enum and the row shape)",
350
350
  surface: "stage-message",
351
351
  evidence: "Record a \"status\" and a \"reason\" on EVERY row ONLY by calling the … tool — the driver validates each row as it arrives, holds the record itself, and renders both the ## Coverage ledger table and the coverage JSON from it",
352
352
  reparsedBy: "driver/coverage-call.mjs validateCoverageCall (receiver-validated at call time, the same predicates the gate judges with) + driver/coverage-form.mjs rowIsSettled via validators.registerFindings over the _driver/ accumulator",
@@ -429,7 +429,7 @@ export const E3_BACKLOG = [
429
429
  {
430
430
  stage: "frame-diff",
431
431
  kind: "exactly-these-keys",
432
- where: "driver/stages.mjs:2788",
432
+ where: "driver/stages.mjs:2798",
433
433
  surface: "stage-message",
434
434
  evidence: "For each blind-model variant / field / source the run did NOT scope or search, emit one directive {layer, item, observation, severity} … severity = dominant-element (the omission is ON the spine) | material (a real omission worth a targeted sweep) | minor (already covered, or presentation only).",
435
435
  reparsedBy: "driver/verify.mjs validators.frameDiff + driver/pipeline.mjs runSupplementalSweeps (the parser REFUSES a firing variant directive that dictates nothing dispatchable)",
@@ -453,7 +453,7 @@ export const E3_BACKLOG = [
453
453
  {
454
454
  stage: "synthesis",
455
455
  kind: "literal-json-skeleton",
456
- where: "driver/stages.mjs:3164",
456
+ where: "driver/stages.mjs:3178",
457
457
  surface: "stage-message",
458
458
  evidence: "MACHINE FINDINGS (MANDATORY): … a JSON OBJECT {\"schema_version\":<FINDINGS_SCHEMA_VERSION>,\"rated_under_framework\":\"…\",\"findings\":[...],\"coverage\":[...],\"context_notes\":[...],\"actions\":[...],\"ask_answers\":[...]} … Each finding object has EXACTLY these keys: {\"ordinal\",\"mark\",\"owner\",\"band\",\"net\",\"bor",
459
459
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson via validators.narrative",
@@ -467,7 +467,7 @@ export const E3_BACKLOG = [
467
467
  {
468
468
  stage: "synthesis",
469
469
  kind: "exactly-these-keys",
470
- where: "driver/stages.mjs:3200",
470
+ where: "driver/stages.mjs:3214",
471
471
  surface: "stage-message",
472
472
  evidence: "- off_field_ground (MANDATORY on every off-field finding, FORBIDDEN on every other disposition): EXACTLY one bare token of: ${OFF_FIELD_GROUNDS.join(\" / \")}",
473
473
  reparsedBy: "driver/findings-model.mjs validateOffFieldGround — the enum is imported from findings-model.mjs and interpolated back into the prompt, so code already holds the list it asks the model to type",
@@ -476,7 +476,7 @@ export const E3_BACKLOG = [
476
476
  {
477
477
  stage: "synthesis",
478
478
  kind: "literal-json-skeleton",
479
- where: "driver/stages.mjs:3201",
479
+ where: "driver/stages.mjs:3215",
480
480
  surface: "stage-message",
481
481
  evidence: "- manageable …: {\"category\":\"<EXACTLY one of large-competitor / commercial-partner / troll / well-known-enforcer>\",\"reason\":\"<one-two lines…>\"}",
482
482
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
@@ -485,16 +485,16 @@ export const E3_BACKLOG = [
485
485
  {
486
486
  stage: "synthesis",
487
487
  kind: "literal-json-skeleton",
488
- where: "driver/stages.mjs:3217",
488
+ where: "driver/stages.mjs:3231",
489
489
  surface: "stage-message",
490
490
  evidence: "- meters: {\"mark_similarity\":{...},\"goods_proximity\":{...},\"use\":{...},\"enforcer\":{...}} — all four present, each {\"token\",\"basis\",\"source\"}. … mark_similarity = high | medium | low. goods_proximity = high | medium | low. enforcer = high | medium | low | unknown. use = confirmed | not-confirmed | un",
491
- reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; driver/verify.mjs:1066 checkFindingsSibling gates meters.*.source; finding_basis_source_missing",
491
+ reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; driver/verify.mjs:1108 checkFindingsSibling gates meters.*.source; finding_basis_source_missing",
492
492
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
493
493
  },
494
494
  {
495
495
  stage: "synthesis",
496
496
  kind: "literal-json-skeleton",
497
- where: "driver/stages.mjs:3218",
497
+ where: "driver/stages.mjs:3232",
498
498
  surface: "stage-message",
499
499
  evidence: "- quadrant: {\"x\",\"y\"} numbers in [0,1]. x = goods/services proximity (0 = distant, 1 = identical). y = mark similarity (0 = distinct, 1 = identical).",
500
500
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
@@ -503,7 +503,7 @@ export const E3_BACKLOG = [
503
503
  {
504
504
  stage: "synthesis",
505
505
  kind: "literal-json-skeleton",
506
- where: "driver/stages.mjs:3219",
506
+ where: "driver/stages.mjs:3233",
507
507
  surface: "stage-message",
508
508
  evidence: "- source: {\"source_type\",\"resolved_link\"}. source_type EXACTLY one of: register-vendor / register-euipo / common-law-marketplace / common-law-web / case-law",
509
509
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
@@ -512,7 +512,7 @@ export const E3_BACKLOG = [
512
512
  {
513
513
  stage: "synthesis",
514
514
  kind: "exactly-these-keys",
515
- where: "driver/stages.mjs:3220",
515
+ where: "driver/stages.mjs:3234",
516
516
  surface: "stage-message",
517
517
  evidence: "coverage[]: ONE object per coverage AREA, EXACTLY {\"area\",\"state\",\"note\"}. … state EXACTLY one of: confirmed-clean / coverage-limited / open / not-searched / note.",
518
518
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; the render owns the coverage panel from these typed states",
@@ -521,7 +521,7 @@ export const E3_BACKLOG = [
521
521
  {
522
522
  stage: "synthesis",
523
523
  kind: "literal-json-skeleton",
524
- where: "driver/stages.mjs:3229",
524
+ where: "driver/stages.mjs:3243",
525
525
  surface: "stage-message",
526
526
  evidence: "use_check = {\"source\",\"quality\"}: … quality: OPTIONAL, EXACTLY one of owner-site / independent / register-mirror … own_rights = {\"source\"}",
527
527
  reparsedBy: "driver/verify.mjs:988 checkFindingsSibling (finding_use_check_missing); driver/own-rights.mjs:19-22",
@@ -530,7 +530,7 @@ export const E3_BACKLOG = [
530
530
  {
531
531
  stage: "synthesis",
532
532
  kind: "literal-json-skeleton",
533
- where: "driver/stages.mjs:3286",
533
+ where: "driver/stages.mjs:3300",
534
534
  surface: "stage-message",
535
535
  evidence: "MARK ASSESSMENT … STRUCTURED FORM …: either field may instead be an OBJECT {\"read\":\"…\",\"spectrum\":\"…\",\"per_class\":[{\"class\":\"5\",\"note\":\"…\"}],\"per_market\":[{\"market\":\"CN\",\"note\":\"…\"}],\"counter_registrations\":[{\"mark\":\"…\",\"uri\":\"/mark/…\",\"note\":\"…\"}],\"acquired\":\"<optional>\",\"note\":\"<optional residual>",
536
536
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson; the report collapses the rows behind toggles and the audit workbook renders them",
@@ -539,7 +539,7 @@ export const E3_BACKLOG = [
539
539
  {
540
540
  stage: "synthesis",
541
541
  kind: "literal-json-skeleton",
542
- where: "driver/stages.mjs:3292",
542
+ where: "driver/stages.mjs:3306",
543
543
  surface: "stage-message",
544
544
  evidence: "FOUR ANSWERS …: \"four_answers\": {\"third_party_rights\":{...},\"objection_likelihood\":{...},\"registrability\":{...},\"client_enforceability\":{...}} … Each answer … is {\"read\":\"…\",\"token\":\"…\",\"basis\":\"…\",\"ordinals\":[…]}. Tokens (closed enums …): third_party_rights = strong|moderate|weak; objection_likelih",
545
545
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
@@ -557,7 +557,7 @@ export const E3_BACKLOG = [
557
557
  {
558
558
  stage: "synthesis",
559
559
  kind: "exactly-these-keys",
560
- where: "driver/stages.mjs:3299",
560
+ where: "driver/stages.mjs:3313",
561
561
  surface: "stage-message",
562
562
  evidence: "COVERAGE JUDGMENT …: emit \"coverage_judgment\": {\"sufficient\":<bool>, \"reason\":\"<one line…>\"} — EXACTLY those two keys. Do NOT emit \"rows\": the driver writes that register itself … anything you type there is replaced wholesale.",
563
563
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson. The \"Do NOT emit rows\" clause is the purest E3 case in the tree — the prompt names a field, dictates its shape and states in the same breath that code overwrites it",
@@ -566,7 +566,7 @@ export const E3_BACKLOG = [
566
566
  {
567
567
  stage: "synthesis",
568
568
  kind: "literal-json-skeleton",
569
- where: "driver/stages.mjs:3142",
569
+ where: "driver/stages.mjs:3156",
570
570
  surface: "stage-message",
571
571
  // RE-QUOTED, NOT PARKED. The writer's conversion reworded this dictation — the
572
572
  // ask answers ride the findings RECORD now and the driver renders the labelled line into both the
@@ -590,7 +590,7 @@ export const E3_BACKLOG = [
590
590
  {
591
591
  stage: "synthesis",
592
592
  kind: "dictated-line-shape",
593
- where: "driver/stages.mjs:3063 (restated at driver/skills/prelim-search/synthesis-rules.md:428)",
593
+ where: "driver/stages.mjs:3077 (restated at driver/skills/prelim-search/synthesis-rules.md:428)",
594
594
  surface: "stage-message",
595
595
  evidence: "END that finding's actual-use line with a literal \"- **Use-check source:** <result URL | \"perplexity_research — no result\">\" line",
596
596
  reparsedBy: "driver/verify.mjs validators.narrative (spec-11 hard reject); the repair hint re-dictates the literal at driver/gateway.mjs:2153",
@@ -599,7 +599,7 @@ export const E3_BACKLOG = [
599
599
  {
600
600
  stage: "synthesis",
601
601
  kind: "dictated-line-shape",
602
- where: "driver/stages.mjs:3076 (restated at driver/skills/prelim-search/synthesis-rules.md:475)",
602
+ where: "driver/stages.mjs:3090 (restated at driver/skills/prelim-search/synthesis-rules.md:475)",
603
603
  surface: "stage-message",
604
604
  evidence: "END that finding's reasoning with a literal \"- **Own-rights source:** <record URI(s) | \"no applicant-owned registrations in the searched register material\">\" line",
605
605
  reparsedBy: "driver/own-rights.mjs:19-22 — \"This module only requires the 'Own-rights source:' line to exist\"; repair hint at driver/gateway.mjs:2361 (the `own_rights_missing` branch; re-verified 2026-08-29 — the old :1736 predated this branch and pointed into the A4 repeat-signature block)",
@@ -608,7 +608,7 @@ export const E3_BACKLOG = [
608
608
  {
609
609
  stage: "synthesis",
610
610
  kind: "exactly-these-keys",
611
- where: "driver/stages.mjs:3278",
611
+ where: "driver/stages.mjs:3292",
612
612
  surface: "stage-message",
613
613
  evidence: "add it to the top-level \"context_notes\" array — each object EXACTLY {\"type\":\"famous-neighbour-ungrounded\",\"mark\",\"owner\",\"context\"}",
614
614
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson",
@@ -617,7 +617,7 @@ export const E3_BACKLOG = [
617
617
  {
618
618
  stage: "synthesis",
619
619
  kind: "literal-json-skeleton",
620
- where: "driver/stages.mjs:3166",
620
+ where: "driver/stages.mjs:3180",
621
621
  surface: "stage-message",
622
622
  evidence: "- owner: {\"name\",\"country\",\"registrations\":[...]}. … Each registration: {\"uri\", optionally \"classes\":[\"9\",\"41\"],\"status\",\"filed\",\"expiry\",\"jurisdiction\"}. The \"uri\" is the ONLY field that matters: the driver BINDS classes/status/filed/expiry/jurisdiction AND the owner name from the FETCHED record ke",
623
623
  reparsedBy: "driver/findings-model.mjs:813 parseFindingsJson + the record-binding join. Six of the seven keys are stated in the prompt and overwritten by code in the same sentence",
@@ -626,16 +626,16 @@ export const E3_BACKLOG = [
626
626
  {
627
627
  stage: "case-law",
628
628
  kind: "literal-json-skeleton",
629
- where: "driver/stages.mjs:3448",
629
+ where: "driver/stages.mjs:3462",
630
630
  surface: "stage-message",
631
631
  evidence: "ALSO write the RETRIEVAL RECORD to <path> — a JSON OBJECT with EXACTLY these keys: {\"schema_version\":1,\"queries\":[{\"query\":\"<the search you dispatched, verbatim>\",\"jurisdiction\":\"…\",\"results\":<how many hits it returned>}, …],\"citations\":[{\"proceeding\":\"…\",\"forum\":\"…\",\"jurisdiction\":\"…\",\"decided\":\"…\"",
632
- reparsedBy: "driver/verify.mjs validators.caseLaw — the ledger arm, armed by the stage-contract marker `citations` (stages.mjs:1810)",
632
+ reparsedBy: "driver/verify.mjs validators.caseLaw — the ledger arm, armed by the stage-contract marker `citations` (stages.mjs:1820)",
633
633
  removedByMove: "M5 moves `queries[]` to the call log (and is itself blocked: tool-calls.jsonl records no arguments); the envelope, schema_version, `read` enum and `citations[]` skeleton survive M5 entirely",
634
634
  },
635
635
  {
636
636
  stage: "case-law",
637
637
  kind: "dictated-line-shape",
638
- where: "driver/stages.mjs:3456",
638
+ where: "driver/stages.mjs:3470",
639
639
  surface: "stage-message",
640
640
  evidence: "EVERY \"Grounded profile\" section MUST start its body with the line \"- ord: <N>\" naming which finding it grounds (use the ordinal from this list; a profile that grounds no listed finding omits the line)",
641
641
  reparsedBy: "driver/publish/parse.mjs:339 parseCaseLawProfiles (\"the optional '- ord: <N>' first body line … gives an EXACT join\"); driver/findings-model.mjs:273 /^-\\s*ord:\\s*(\\d+)\\s*$/m; driver/publish/index.mjs:776",
@@ -669,7 +669,7 @@ export const E3_BACKLOG = [
669
669
  {
670
670
  stage: "narrative-refutation",
671
671
  kind: "dictated-line-shape",
672
- where: "driver/stages.mjs:3588",
672
+ where: "driver/stages.mjs:3602",
673
673
  surface: "stage-message",
674
674
  // RE-QUOTED BY CONVERSION 9. The LINE-TOKEN half is gone — no "anywhere on the line", no "[kind: …]
675
675
  // token", no "a line with no token is treated as fact", because a kind is a typed field now and an
@@ -684,7 +684,7 @@ export const E3_BACKLOG = [
684
684
  // because an un-anchorable row lands in the NOT-CHECKED slice, and that is coverage lost rather than
685
685
  // a pass.
686
686
  evidence: "is ONE of coverage-disposition | fact | rating | narrative — pick the one your own legal read says the correction IS: coverage-disposition (a coverage row / disposition placement is wrong or dishonest)",
687
- reparsedBy: "driver/verify.mjs:742 CORRECTION_KIND_RE = /\\[kind:\\s*([a-z][a-z-]*)\\s*\\]/i → parseCorrectionKinds (verify.mjs:965), consumed in pipelineInner() in pipeline.mjs for the run.jsonl `correction-kinds` histogram, which since #1558 also carries `kindChannelOk` — the counts are DERIVED from the parsed rows, and that key states whether the reviewer's kind channel produced anything at all rather than leaving a reader to infer it by comparing untyped against total. Telemetry only today",
687
+ reparsedBy: "driver/verify.mjs:784 CORRECTION_KIND_RE = /\\[kind:\\s*([a-z][a-z-]*)\\s*\\]/i → parseCorrectionKinds (verify.mjs:1007), consumed in pipelineInner() in pipeline.mjs for the run.jsonl `correction-kinds` histogram, which since #1558 also carries `kindChannelOk` — the counts are DERIVED from the parsed rows, and that key states whether the reviewer's kind channel produced anything at all rather than leaving a reader to infer it by comparing untyped against total. Telemetry only today",
688
688
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
689
689
  },
690
690
  {
@@ -697,7 +697,7 @@ export const E3_BACKLOG = [
697
697
  // `[on: -]` case went from a value to an ABSENCE — you omit the field — which is the one part a
698
698
  // reader could get wrong from the old wording, since there is no value meaning "no finding".
699
699
  evidence: "**AND EVERY FLAG CARRIES WHICH FINDING IT IS ABOUT** — the `on` field, an array of ordinals. Same rule as `kind`: you send the values, the driver renders the token.",
700
- reparsedBy: "driver/verify.mjs:759 CORRECTION_ON_RE = /\\[on:\\s*([0-9,\\s-]*?)\\s*\\]/i. SKILL-FILE ONLY — the stage message at stages.mjs:1842-1877 never mentions `[on:]`. This is #850's \"the element shape is in the skill file, not the stage message\" in its purest form: an E3 lint reading stages.mjs alone sees the [kind:] token and misses its twin",
700
+ reparsedBy: "driver/verify.mjs:801 CORRECTION_ON_RE = /\\[on:\\s*([0-9,\\s-]*?)\\s*\\]/i. SKILL-FILE ONLY — the stage message at stages.mjs:1842-1877 never mentions `[on:]`. This is #850's \"the element shape is in the skill file, not the stage message\" in its purest form: an E3 lint reading stages.mjs alone sees the [kind:] token and misses its twin",
701
701
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
702
702
  },
703
703
  {
@@ -768,7 +768,7 @@ export const E3_BACKLOG = [
768
768
  {
769
769
  stage: "report-card",
770
770
  kind: "literal-json-skeleton",
771
- where: "driver/stages.mjs:3897",
771
+ where: "driver/stages.mjs:3911",
772
772
  surface: "stage-message",
773
773
  evidence: "The finding's OWN record — the ONLY source for this card …:\\n```json\\n<JSON.stringify(finding, null, 2)>\\n```",
774
774
  reparsedBy: "none — this is the INPUT side, and that is why it belongs in the survey: a full JSON object rendered into the prompt is exactly the mechanism #850 proves produced R-RECEIPT (the model pattern-matches a shown shape). E3's clause 1 as written (\"a code fence or inline example showing the exact object shape the model must emit\") does not reach an injected record, so the lint needs an explicit rule for shown-but-not-owed structure",