clearotron 0.4.0 → 0.4.1-beta.0

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 (96) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/build-info.json +2 -2
  3. package/docs/SECURITY-OWASP.md +21 -13
  4. package/docs/SECURITY.md +20 -14
  5. package/docs/architecture/04-configuration-reference.md +3 -2
  6. package/docs/architecture/05-config-governance.md +1 -1
  7. package/docs/architecture/06-operations-runbook.md +1 -1
  8. package/docs/architecture/09-security-and-data.md +16 -11
  9. package/driver/CHANGELOG.md +18 -0
  10. package/driver/binding-layers.mjs +28 -6
  11. package/driver/citation-census.json +3 -3
  12. package/driver/common-law-coverage-status.mjs +52 -0
  13. package/driver/common-law-receipts.mjs +1 -1
  14. package/driver/connotation-search.mjs +5 -5
  15. package/driver/contract-dictation-registry.mjs +1 -1
  16. package/driver/contract-e3-backlog.mjs +1 -1
  17. package/driver/contract-vocabulary.mjs +18 -18
  18. package/driver/degraded-parts.mjs +10 -3
  19. package/driver/dispatch-record.mjs +19 -0
  20. package/driver/doubt-closure-tool.mjs +2 -2
  21. package/driver/driver.config.mjs +39 -6
  22. package/driver/engagement-receipt.mjs +1 -1
  23. package/driver/engine/CONTRACT.md +41 -1
  24. package/driver/engine/anthropic-agent.mjs +56 -4
  25. package/driver/engine/mcp/codex-config.mjs +1 -1
  26. package/driver/engine/mcp/coverage-server.mjs +1 -1
  27. package/driver/engine/mcp/gather-config.mjs +1 -1
  28. package/driver/engine/mcp/recording-server.mjs +31 -4
  29. package/driver/engine/mcp/stdio-server.mjs +45 -7
  30. package/driver/engine/openai-agent.mjs +100 -17
  31. package/driver/engine/session-record.mjs +223 -0
  32. package/driver/enqueue-schema.mjs +2 -2
  33. package/driver/findings-model.mjs +3 -3
  34. package/driver/form-neighbourhood.mjs +7 -7
  35. package/driver/frame-diff-model.mjs +3 -3
  36. package/driver/framework.mjs +22 -0
  37. package/driver/gateway.mjs +94 -12
  38. package/driver/named-band.mjs +2 -0
  39. package/driver/package.json +1 -1
  40. package/driver/phase0.mjs +13 -2
  41. package/driver/pipeline-knockout.mjs +1 -1
  42. package/driver/pipeline.mjs +46 -33
  43. package/driver/portal-families.mjs +3 -3
  44. package/driver/portal-service.mjs +1 -1
  45. package/driver/predelivery-lint.mjs +19 -19
  46. package/driver/publish/index.mjs +1 -1
  47. package/driver/publish/knockout.mjs +5 -4
  48. package/driver/publish/office-record-links.mjs +17 -2
  49. package/driver/publish/publish-inputs.mjs +1 -1
  50. package/driver/publish/render-knockout.mjs +17 -5
  51. package/driver/publish/render.mjs +4 -2
  52. package/driver/record-fold.mjs +177 -0
  53. package/driver/register-count.mjs +18 -1
  54. package/driver/register-plan.mjs +71 -17
  55. package/driver/register-records.mjs +7 -0
  56. package/driver/registry-fidelity.mjs +2 -2
  57. package/driver/repairs.mjs +1 -1
  58. package/driver/report-card-record.mjs +4 -4
  59. package/driver/scope-facts.mjs +11 -3
  60. package/driver/search-policy.mjs +20 -1
  61. package/driver/skills/clearance-common-law/SKILL.md +22 -22
  62. package/driver/skills/clearance-register/digest.md +3 -3
  63. package/driver/skills/clearance-register/register-recipes.md +11 -11
  64. package/driver/skills/clearance-register/status-rules.md +1 -1
  65. package/driver/skills/clearance-register/unit.md +1 -1
  66. package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
  67. package/driver/skills/clearance-search/phase2-execution.md +6 -5
  68. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  69. package/driver/skills/clearance-search/worked-examples.md +39 -39
  70. package/driver/skills/clearance-variants/SKILL.md +16 -16
  71. package/driver/skills/frame-diff/SKILL.md +2 -2
  72. package/driver/stage-context.mjs +27 -1
  73. package/driver/stages.mjs +12 -12
  74. package/driver/suite-census.json +175 -31
  75. package/driver/synthesis-record.mjs +5 -0
  76. package/driver/unit-inventory.mjs +17 -28
  77. package/driver/verify.mjs +6 -6
  78. package/mcp-server/CHANGELOG.md +4 -0
  79. package/mcp-server/lib/evidence.mjs +8 -8
  80. package/mcp-server/lib/knockout.mjs +1 -1
  81. package/mcp-server/package.json +2 -2
  82. package/package.json +2 -2
  83. package/portal-ui/package.json +1 -1
  84. package/providers/_shared/count.mjs +4 -1
  85. package/providers/_shared/enumerate.mjs +61 -22
  86. package/providers/_shared/execute-plan.mjs +2 -0
  87. package/providers/corsearch/src/capabilities.js +1 -1
  88. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  89. package/providers/oauth-mcp-bridge/package.json +2 -2
  90. package/providers/signa/src/capabilities.js +17 -2
  91. package/providers/signa/src/core.js +234 -37
  92. package/scripts/band-shape-probe.mjs +2 -2
  93. package/scripts/deprecate-below.mjs +28 -4
  94. package/scripts/e2e-first-time.mjs +16 -1
  95. package/scripts/freeze-example-run.mjs +2 -2
  96. package/shared/scope.mjs +18 -2
@@ -83,8 +83,24 @@ export function refToOffice(ref) {
83
83
  // providers/_shared/answer-memory.mjs. The question is the base address, the method, the path and the
84
84
  // whole body, and never the credential; the base is in it so a test server and the register never share
85
85
  // an answer. With no memory for the run (the default), this is the plain fetch it always was.
86
+ // ── WHAT EACH CALL COST, AS THE REGISTER STATES IT ──────────────────────────────────────────────
87
+ //
88
+ // The register charges by the credit and says how many on every response, `X-Credits-Charged`, errors
89
+ // included (0). A search page and a record fetch differ tenfold, and the ledger's `tool` names the caller,
90
+ // not the endpoint, so a run's spend could not be added up from the ledger: every figure was a guess. The
91
+ // header is copied onto the ledger row with the endpoint it answered. NULL when the header is absent or
92
+ // unreadable, never 0 — a missing figure is not a free call. A call answered from the run memory sent
93
+ // nothing, so it is a real 0.
94
+ const creditsCharged = (resp) => {
95
+ const v = resp?.headers?.get?.("x-credits-charged");
96
+ const n = v == null || String(v).trim() === "" ? NaN : Number(v);
97
+ return Number.isFinite(n) ? n : null;
98
+ };
99
+ const endpointOf = (method, path) => `${method} ${String(path).split("?")[0].replace(/^(\/v1\/trademarks\/)[^/]+/, "$1{id}")}`;
100
+
86
101
  export async function signaFetch(apiKey, base, path, { method = "GET", body = null, retries = 1, tctx = null } = {}) {
87
102
  const url = `${base}${path}`;
103
+ const endpoint = endpointOf(method, path);
88
104
  const headers = { Authorization: `Bearer ${apiKey}`, Accept: "application/json" };
89
105
  const init = { method, headers };
90
106
  if (body !== null) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); }
@@ -106,7 +122,7 @@ export async function signaFetch(apiKey, base, path, { method = "GET", body = nu
106
122
  // cache hit, so the run's usage shows the request was made and was not paid for.
107
123
  const { body: parsed, parseError } = parseJsonBody(held.raw);
108
124
  const ok = held.status >= 200 && held.status < 300;
109
- logCall(tctx, { http_status: held.status, ok, attempts: 0, took_ms: Date.now() - t0, bytes: held.raw.length, cache_hit: true });
125
+ logCall(tctx, { http_status: held.status, ok, attempts: 0, took_ms: Date.now() - t0, bytes: held.raw.length, cache_hit: true, endpoint, credits_charged: 0 });
110
126
  note({ held: true, served: true, status: held.status });
111
127
  return { status: held.status, ok, url, body: parsed, raw: held.raw, parseError };
112
128
  }
@@ -119,14 +135,14 @@ export async function signaFetch(apiKey, base, path, { method = "GET", body = nu
119
135
  await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
120
136
  }
121
137
  } catch (err) {
122
- logCall(tctx, { http_status: 0, ok: false, attempts, took_ms: Date.now() - t0, bytes: 0, cache_hit: false });
138
+ logCall(tctx, { http_status: 0, ok: false, attempts, took_ms: Date.now() - t0, bytes: 0, cache_hit: false, endpoint, credits_charged: null });
123
139
  if (mem) note({ held: Boolean(held), status: 0 });
124
140
  throw err;
125
141
  }
126
142
  const raw = await resp.text();
127
143
  // The parse failure travels on `parseError` instead of being swallowed — providers/_shared/http-body.mjs.
128
144
  const { body: parsed, parseError } = parseJsonBody(raw);
129
- logCall(tctx, { http_status: resp.status, ok: resp.ok, attempts, took_ms: Date.now() - t0, bytes: raw.length, cache_hit: false });
145
+ logCall(tctx, { http_status: resp.status, ok: resp.ok, attempts, took_ms: Date.now() - t0, bytes: raw.length, cache_hit: false, endpoint, credits_charged: creditsCharged(resp) });
130
146
  if (mem) {
131
147
  // In `watch` every request reaches this line, held or not, and the line says what the memory would
132
148
  // have done. The FIRST answer is the one kept: a later identical request is compared against it
@@ -180,7 +196,24 @@ export function rememberableAnswer(method, status, body, parseError) {
180
196
  // `match_mode: "starts_with"` for a trailing-`*` wildcard entry, and with no deterministic shape to
181
197
  // put it in, that entry fell through to a plain `exact` — a NARROWER query than the plan asked for,
182
198
  // answering as though it were the one requested.
183
- const DETERMINISTIC_MATCH = new Set(["similar", "exact", "starts_with", "ends_with", "contains"]);
199
+ //
200
+ // ── ON THE WIRE, A DETERMINISTIC MODE IS A TEXT FILTER, NOT `match` ──────────────────────────────
201
+ //
202
+ // The register retired `match` on the same date as `query` and `strategies`, and after that date it
203
+ // refuses the parameter rather than ignoring it. That would take the containing count with it: a
204
+ // refused request is an error, and an error on that band is "not available" on the report. So
205
+ // `match` stays this file's own word for the mode, and the request builder sends the text filter the
206
+ // register maps it to: `filters.mark_text.<op>` with the term as a one-value list, and no `q` and no
207
+ // `similarity`, because a text filter on its own is the plain listing the old `match` was.
208
+ //
209
+ // Compared old form against new for every mode, by record and paged to exhaustion, on neutral terms
210
+ // under one class filter and again with a territory filter: the same records in the same order, and
211
+ // the new form draws no deprecation notice. The register already ran the old form as this filter, so
212
+ // the order it lists in is unchanged; nothing here adds a `sort`. The register's three-character floor
213
+ // on `contains` and `ends_with` refused a shorter term under the old form as well, so a short term
214
+ // fails the same way it always has. `similar` has no filter: the register's word for it is "remove it",
215
+ // because a term on its own is always ranked now, so it is sent as the ranked shape.
216
+ const TEXT_FILTER_FOR = Object.freeze({ exact: "is", starts_with: "starts_with", ends_with: "ends_with", contains: "contains" });
184
217
 
185
218
  // ── THE RANKED STRATEGIES, AS SIMILARITY CHANNELS ────────────────────────────────────────────────
186
219
  //
@@ -273,30 +306,58 @@ export function buildSearchRequest(p) {
273
306
  // serializes away anyway, but writing it conditionally is what makes the owner-only shape legible here
274
307
  // rather than an accident of JSON.stringify.
275
308
  const body = {};
276
- // ── `q` CARRIES ONE TERM, AND AN ARRAY HERE WOULD BE A DIFFERENT SEARCH ────────────────────────
309
+ // ── `q` CARRIES ONE TERM, AND AN ARRAY HERE IS A DIFFERENT SEARCH ──────────────────────────────
277
310
  //
278
- // A scalar `q` is a RANKED query: the similarity channels below apply to it. A LIST in the same field
279
- // is not a wider ranked query — it is an exact-text filter, and the register refuses to combine it with
280
- // similarity at all ("a list is an exact-text filter, not a ranked query"). Measured: the ranked form
281
- // returns a live mark the register itself tiers `identical` via its lookalike channel, and the list form
282
- // does not, because that mark's text does not contain the term. So an array reaching this line would
283
- // silently drop look-alike coverage while answering 200 — a narrowed query wearing a complete answer,
284
- // the failure this connector already refuses a multi-term stack to prevent.
311
+ // A list in `q` is a ranked search over every term, and it does not answer the same question as one
312
+ // request per term. Its results are ranked across the terms, so a page is not complete for any one of
313
+ // them; its total counts the whole list, so the enumerate ceiling cannot read a per-term crowd off it;
314
+ // and the register's own reference says a list skips two single-term ranking boosts and the
315
+ // word-by-word fuzzy retry. Sending several terms in one request is a change to what the engine asks
316
+ // the register, decided on its own and wired through the planner, never reached by an array that
317
+ // happens to arrive here.
285
318
  //
286
319
  // It is refused rather than joined or first-element-picked, for the same reason the stack is.
287
- if (Array.isArray(p.query))
288
- throw new Error("[signa] `query` reached the request builder as a list. A list in `q` is an exact-text "
289
- + "filter on this register and cannot carry the similarity channels a ranked band asks for, so it "
290
- + "would drop look-alike matches while answering 200. Send one term per request.");
291
- if (String(p.query ?? "").trim()) body.q = p.query;
320
+ //
321
+ // THE ONE LIST THAT IS SENT: exact spellings on the exact channels, which `toSignaParams` builds only
322
+ // where `exactListable` holds. Anything else arriving as a list is still refused.
323
+ if (Array.isArray(p.query)) {
324
+ const channels = similarityFor(p.strategies);
325
+ const exactChannels = channels.length === 2 && channels[0] === "identical" && channels[1] === "lookalike";
326
+ const terms = p.query.filter((t) => String(t ?? "").trim());
327
+ if (p.match || !exactChannels || terms.length < 2 || terms.length > CAPABILITIES.exactOrWidth)
328
+ throw new Error("[signa] `query` reached the request builder as a list outside the one shape the register "
329
+ + "answers term for term: exact spellings on the identical and lookalike channels, 2 to "
330
+ + `${CAPABILITIES.exactOrWidth} of them. Send one term per request.`);
331
+ body.q = terms;
332
+ body.similarity = channels;
333
+ const listFilters = buildFilters(p);
334
+ if (Object.keys(listFilters).length) body.filters = listFilters;
335
+ return finishSearchRequest(body, p);
336
+ }
337
+ const term = String(p.query ?? "").trim() ? p.query : null;
292
338
  const match = typeof p.match === "string" ? p.match.trim() : "";
293
- if (match && DETERMINISTIC_MATCH.has(match)) {
294
- body.match = match; // sending similarity alongside is a 4xx, not a preference
295
- } else {
339
+ const filters = buildFilters(p);
340
+ const textOp = TEXT_FILTER_FOR[match];
341
+ if (textOp && term != null) {
342
+ // A text filter alone: no `q`, and no `similarity` — the register refuses channels without a `q`.
343
+ filters.mark_text = { [textOp]: [term] };
344
+ } else if (term != null) {
345
+ body.q = term;
296
346
  body.similarity = similarityFor(p.strategies);
297
347
  }
298
- const filters = buildFilters(p);
348
+ // No term at all is the owner-only shape: the owner filter is the whole question, and `similarity`
349
+ // "requires q" in the register's reference, so none is sent. With no owner either, the request would be
350
+ // an unfiltered listing of the register — a stack of names that could not go as a list arrives here
351
+ // with no term — so it is refused rather than sent.
352
+ if (term == null && !(typeof p.owner === "string" && p.owner.trim()))
353
+ throw new Error("[signa] a search reached the request builder with no term and no owner. A stack of names goes "
354
+ + "to this register whole only as exact spellings on the exact channels; send one term per request.");
299
355
  if (Object.keys(filters).length) body.filters = filters;
356
+ return finishSearchRequest(body, p);
357
+ }
358
+
359
+ /** What every search request carries whatever its term: territory layer, paging and the total. */
360
+ function finishSearchRequest(body, p) {
300
361
  if (Array.isArray(p.jurisdictions) && p.jurisdictions.length && p.territory_match) {
301
362
  // Governs `filters.jurisdictions` ONLY — it has no effect on `filters.offices`, which is always
302
363
  // literal. Sent verbatim rather than defaulted here: the vendor's own default is `protection`, and
@@ -362,6 +423,11 @@ export function normalizeClasses(rec) {
362
423
  return out.length ? Array.from(new Set(out)).sort((a, b) => a - b) : null;
363
424
  }
364
425
 
426
+ const httpsUrlOrNull = (v) => {
427
+ if (typeof v !== "string") return null;
428
+ try { return new URL(v).protocol === "https:" ? v : null; } catch { return null; }
429
+ };
430
+
365
431
  // Map a raw Signa record → the NEUTRAL normalized shape the driver + skill consume. officeHint = the
366
432
  // office of the synthetic ref the caller cited (keeps cited==logged); record's jurisdiction_code is the
367
433
  // fallback (Signa records carry an ISO jurisdiction_code directly, so no office map is needed).
@@ -397,7 +463,14 @@ export function normalizeRecord(rec, officeHint = null) {
397
463
  ownerCountry: owner0?.country_code ?? owner0?.country ?? null,
398
464
  representative: atty0?.name ?? null,
399
465
  imageAvailable: rec.has_media ?? null,
400
- resolved_link: null, // Signa exposes no per-record public URL; renderer shows "verify at office"
466
+ // Signa publishes no page per record of its own, so no record URL is minted here and the model's
467
+ // record links stay refused for this register (hasPublicRecordUrl: false).
468
+ resolved_link: null,
469
+ // THE OFFICE'S PAGE, AS THE REGISTER GIVES IT: `office_url`, a link to the record on the office's own
470
+ // site, absent on some records. Carried as data for publish (office-record-links.mjs), which addresses
471
+ // the office's page from the record's numbers first and falls back to this only where that table
472
+ // gives no address. Only an https address is kept.
473
+ officeUrl: httpsUrlOrNull(rec.office_url),
401
474
  // ── WHICH LAYER THIS RIGHT SITS ON, carried as data ( →) ──────────────────────────
402
475
  // The normalizer read under half the fields a search row carries. Among the ones it dropped were the
403
476
  // four that say what KIND of right a record is — and those are not extras, they are the whole
@@ -488,6 +561,10 @@ function normalizeSearchRow(rec) {
488
561
  application_date: filed,
489
562
  registration_date: toIso(rec.registration_date),
490
563
  relevance_score: Number.isInteger(rec.relevance_score) ? rec.relevance_score : null,
564
+ // Why the register returned this row: `{ tier, via, terms }` — the tier it rates the resemblance,
565
+ // the channels that found it, and the terms or filter values it matched. It replaced the per-row
566
+ // `match_explanation` and `matched_terms`, which the register removed on 2026-10-05.
567
+ match: rec.match && typeof rec.match === "object" ? rec.match : null,
491
568
  raw: rec,
492
569
  };
493
570
  }
@@ -546,8 +623,10 @@ export function normalizeSearchResponse(body, echoQuery) {
546
623
  const { total, approximate, floor } = readTotal(body);
547
624
  return {
548
625
  query: meta.query ?? echoQuery,
549
- strategies_used: meta.strategies_used ?? [],
550
- match: meta.match ?? null,
626
+ // The channels the register says it ran. It removed `strategies_used` and `match` from this object
627
+ // on 2026-10-05 and names this field as their replacement; reading the old two returned `[]` and
628
+ // `null` on every answer after that date while looking like a report of what ran.
629
+ similarity_applied: Array.isArray(meta.similarity_applied) ? meta.similarity_applied : null,
551
630
  search_id: meta.search_id ?? null,
552
631
  // ── THE REGISTER'S OWN WARNINGS, CARRIED WHOLE ──────────────────────────────────────────────
553
632
  //
@@ -571,8 +650,17 @@ export function normalizeSearchResponse(body, echoQuery) {
571
650
  // back one row per RECORD instead of one row per MARK — and the register's own note says that
572
651
  // inflates the total. The total is what the enumerate ceiling reads to call a band a crowd, so a
573
652
  // band can be declared a crowd on a number that counts designations rather than marks. This field
574
- // is what makes that visible; it does not yet make it safe.
653
+ // is what makes that visible; `total_counts` below says it where the total is read.
575
654
  warnings: Array.isArray(meta.warnings) ? meta.warnings : [],
655
+ // ── WHAT THE TOTAL COUNTS, WHEN THE REGISTER SAYS ────────────────────────────────────────────
656
+ //
657
+ // `records` when the register flagged `expanded_fallback` on this answer: the total then counts one
658
+ // row per record, so a mark filed in several countries is counted once per country it covers. The
659
+ // count stays the register's own number and is never presented as a count of marks (ruled
660
+ // 2026-10-02: record only, no extra call). Absent when the register said nothing: its grouped view
661
+ // answered, or the response was not an answer. Keyed on the code the register writes into
662
+ // `search_meta.warnings`, in the shape measured live and kept by the warnings test beside this file.
663
+ ...(answered && (Array.isArray(meta.warnings) ? meta.warnings : []).some(isExpandedFallback) ? { total_counts: "records" } : {}),
576
664
  // The corpus total when the vendor counted it exactly; null when it did not answer, when the
577
665
  // total was not requested, and when the figure it returned is an approximation. NEVER the page
578
666
  // size — `data.length` is `count`, and conflating the two is how a page reads as a corpus.
@@ -722,9 +810,74 @@ export async function doSearch(apiKey, base, params, tctx, { mock = false } = {}
722
810
  if (!isSearchResponseBody(r.body)) {
723
811
  return { type: "text", text: nonAnswerBodyError("signa_search", r, "a search response (no data[] — the key every /v1/trademarks answer carries)", ` ${searchTargetLabel(params).slice(0, 120)}`) };
724
812
  }
813
+ // A 200 that did not run every channel it was asked for answered a narrower question; see skippedChannels.
814
+ const skipped = skippedChannels(r.body, body.similarity);
815
+ if (skipped.length) {
816
+ return { type: "text", text: `ERROR: signa_search — the register skipped the ${skipped.join(", ")} channel`
817
+ + `${skipped.length > 1 ? "s" : ""} this search asked for, so its answer covers a narrower question than the one `
818
+ + `asked and is not taken as this search's answer. The register decides this from the term, so asking the same `
819
+ + `search again will be skipped the same way. ${searchTargetLabel(params).slice(0, 120)}` };
820
+ }
821
+ // A text filter compares letters as written, so a term mixing alphabets matches nothing; see mixedScriptFilters.
822
+ const unreadable = mixedScriptFilters(r.body, body.filters?.mark_text);
823
+ if (unreadable.length) {
824
+ return { type: "text", text: `ERROR: signa_search — the register reports that the term mixes alphabets, and its `
825
+ + `${unreadable.join(", ")} filter compares letters as written, so it can match nothing. Its zero is not a count `
826
+ + `of filings and is not taken as this search's answer. ${searchTargetLabel(params).slice(0, 120)}` };
827
+ }
725
828
  return { type: "text", text: JSON.stringify(normalizeSearchResponse(r.body, params.query), null, 2) };
726
829
  }
727
830
 
831
+ // ── A TEXT FILTER CANNOT READ A TERM THAT MIXES ALPHABETS ─────────────────────────────────────────
832
+ //
833
+ // The text filters compare letters as written. A term mixing Latin with Greek or Cyrillic letters
834
+ // therefore matches no filing, and the register answers 200 with zero rows and an `info`-level
835
+ // `mixed_script` warning naming the filter — in its own words, "a ranked q with the lookalike similarity
836
+ // channel folds look-alike letters". Measured live on an invented term with one Cyrillic letter: the
837
+ // ranked search found 219 filings, the containing filter 0. That zero would be the containing count on the
838
+ // report, an exact zero on a figure the client reads. So the answer is refused, naming the filter, and
839
+ // the count reads as unknown rather than none.
840
+ //
841
+ // Matched on the warning's `affected_filter` (`mark_text_<op>`) against the filter this request sent; a
842
+ // warning that names no filter counts against any text filter the request carried.
843
+ export function mixedScriptFilters(body, markText) {
844
+ const sent = Object.keys(markText && typeof markText === "object" ? markText : {});
845
+ if (!sent.length) return [];
846
+ const warnings = Array.isArray(body?.search_meta?.warnings) ? body.search_meta.warnings : [];
847
+ const out = [];
848
+ for (const w of warnings) {
849
+ if (String(w?.code ?? "") !== "mixed_script") continue;
850
+ const named = String(w?.affected_filter ?? "").replace(/^mark_text_/, "");
851
+ for (const op of named ? sent.filter((o) => o === named) : sent) if (!out.includes(op)) out.push(op);
852
+ }
853
+ return out;
854
+ }
855
+
856
+ // ── A CHANNEL THE REGISTER SKIPPED IS A QUESTION IT DID NOT ANSWER ─────────────────────────────────
857
+ //
858
+ // The register runs a term through the similarity channels a request names, and when one of them cannot
859
+ // run on that term it says so in `search_meta.warnings` — `<channel>_skipped`, with the channel in
860
+ // `channel` — and answers 200 with what the other channels found. Its migration guide states it in those
861
+ // words. A sound-alike search answered with `phonetic_skipped` has been answered by `identical` alone: the
862
+ // same rows an exact search gives, under the sound-alike question's name, and the band would read as
863
+ // searched. That is the failure this connector refuses everywhere else — a narrower query wearing a
864
+ // complete answer — so it is refused here too, and the band is left incomplete with the channel named.
865
+ //
866
+ // Only a channel the request ASKED for counts. A warning about a channel nobody asked for changes nothing
867
+ // about the answer to the question that was asked. A warning carrying no `channel` is read from its code.
868
+ export function skippedChannels(body, requested) {
869
+ const asked = new Set(Array.isArray(requested) ? requested : []);
870
+ const warnings = Array.isArray(body?.search_meta?.warnings) ? body.search_meta.warnings : [];
871
+ const out = [];
872
+ for (const w of warnings) {
873
+ const code = String(w?.code ?? "");
874
+ if (!code.endsWith("_skipped")) continue;
875
+ const channel = String(w?.channel ?? code.slice(0, -"_skipped".length)).trim().toLowerCase();
876
+ if (asked.has(channel) && !out.includes(channel)) out.push(channel);
877
+ }
878
+ return out;
879
+ }
880
+
728
881
  // ── Record fetch ─────────────────────────────────────────────────────────────────────────────────
729
882
  // Accepts a synthetic `/mark/<office>/<id>` ref OR a raw Signa id. Returns the NORMALIZED record and
730
883
  // persists it (keyed by the synthetic ref) for the driver's A1 citation-fidelity gate.
@@ -766,25 +919,57 @@ export async function doRecordFetch(apiKey, base, params, tctx, { mock = false }
766
919
  // speaks the same dialect as the code under test cannot find a dialect mismatch, which is why the one
767
920
  // below builds its params the way the kernel does and not the way doSearch prefers.
768
921
  //
769
- // `names` (an OR-stack) is deliberately NOT accepted: `maxOrWidth: 1` — verified against the live
770
- // specification, which carries no OR array on this endpoint — so a stack must reach here already split
771
- // by the planner. Silently searching only its first term would be a narrowed query wearing a complete
772
- // answer, which is this whole track's failure mode.
922
+ // `names` (an OR-stack) goes to the register whole only as exact spellings, as one ranked list (see
923
+ // `exactListable`); every other stack must reach here already split. Silently searching only its first
924
+ // term would be a narrowed query wearing a complete answer, which is this whole track's failure mode.
925
+ // ── WHEN A STACK OF SPELLINGS GOES AS ONE REQUEST ─────────────────────────────────────────────────
926
+ //
927
+ // An exact question over several spellings, each long enough for the ranked search, with no owner
928
+ // clause: the shape the register answers as one ranked list exactly as it answers each spelling alone
929
+ // (see `exactOrWidth` in capabilities.js for the measurement). The list's terms ride a POST body the
930
+ // register caps at 8 KB, so a stack of long spellings is cut to fit with room for the filters.
931
+ const LIST_BODY_BUDGET = 6000;
932
+ const termBytes = (t) => Buffer.byteLength(JSON.stringify(String(t)), "utf8") + 1;
933
+ function exactStackShape(p) {
934
+ if (String(p?.match_mode ?? "").trim() !== "exact") return null;
935
+ if (typeof p?.owner === "string" && p.owner.trim()) return null;
936
+ if (Array.isArray(p?.owners) && p.owners.length) return null;
937
+ const stack = Array.isArray(p?.names) ? p.names.filter(Boolean) : [];
938
+ if (stack.length < 2) return null;
939
+ if (stack.some((t) => foldedLength(t) < CAPABILITIES.rankedMinLength)) return null;
940
+ return stack;
941
+ }
942
+ /** How many spellings one request carries for this kernel query, or null when it is one per request. */
943
+ export function namesChunkFor(p) {
944
+ const stack = exactStackShape(p);
945
+ if (!stack) return null;
946
+ const widest = Math.max(...stack.map(termBytes));
947
+ return Math.max(1, Math.min(CAPABILITIES.exactOrWidth, Math.floor(LIST_BODY_BUDGET / widest)));
948
+ }
949
+ /** May this stack go to the register whole, as one ranked list? */
950
+ export function exactListable(p) {
951
+ const stack = exactStackShape(p);
952
+ return Boolean(stack) && stack.length <= namesChunkFor(p)
953
+ && stack.reduce((n, t) => n + termBytes(t), 0) <= LIST_BODY_BUDGET;
954
+ }
955
+
773
956
  export function toSignaParams(p = {}) {
774
957
  const out = { ...p };
775
958
  if (p.name != null && p.query == null) out.query = p.name;
776
959
  // ── A ONE-TERM `names` WINDOW IS A TERM, NOT A STACK ────────────────────────────────────────────
777
- // `maxOrWidth: 1` means the kernel CHUNKS a wide names band into windows of one and runs the full
778
- // enumerate contract on each — that is what `namesChunkDefault: 1` is for. Every window therefore
960
+ // Outside an exact spelling stack the kernel CHUNKS a wide names band into windows of one and runs the
961
+ // full enumerate contract on each — that is what `namesChunkDefault: 1` is for. Every window therefore
779
962
  // arrives here as `names: ["one-term"]`, and with no mapping it came back "ERROR: query is
780
963
  // required": a names band could not run on this provider AT ALL, and the failure was reported as
781
964
  // chunk 1 failing rather than as the adapter refusing a shape the kernel had already split.
782
965
  //
783
- // Refusing a MULTI-term stack is still right and is the assertion below — picking names[0] there
784
- // would be a narrowed query wearing a complete answer. One term is not a choice between terms.
785
- if (out.query == null && Array.isArray(p.names) && p.names.filter(Boolean).length === 1) {
786
- out.query = p.names.filter(Boolean)[0];
787
- }
966
+ // A MULTI-term stack is never narrowed to names[0] — that would be a narrowed query wearing a complete
967
+ // answer. It is sent whole, as one ranked list, only where `exactListable` says the register answers
968
+ // the list exactly as it answers each spelling alone; otherwise it reaches the request builder with no
969
+ // term and is refused there.
970
+ const stack = Array.isArray(p.names) ? p.names.filter(Boolean) : [];
971
+ if (out.query == null && stack.length === 1) out.query = stack[0];
972
+ else if (out.query == null && stack.length > 1 && exactListable(p)) out.query = stack;
788
973
  // ── STAGE 2 — A TERRITORY IS A STACK OF RIGHTS, AND THIS PROVIDER CAN SEARCH THE STACK ─────
789
974
  //
790
975
  // The plan's `regions` are the territories the matter ordered, already translated into this vendor's
@@ -846,7 +1031,7 @@ export function toSignaParams(p = {}) {
846
1031
  // identical question relies on. So exact stays ranked from the floor up, and only a term below it
847
1032
  // goes deterministic, where it is answered rather than refused.
848
1033
  const mode = String(p.match_mode ?? "").trim();
849
- if (mode === "exact" && foldedLength(out.query) < CAPABILITIES.rankedMinLength) out.match = "exact";
1034
+ if (mode === "exact" && !Array.isArray(out.query) && foldedLength(out.query) < CAPABILITIES.rankedMinLength) out.match = "exact";
850
1035
  else if (mode === "exact" || mode === "phonetic" || mode === "prefix") out.strategies = [mode];
851
1036
  else if (mode === "starts_with" || mode === "ends_with" || mode === "contains") out.match = mode;
852
1037
  // ── `default` IS THE COUNT LANE'S WORD FOR THE SAME UNANCHORED QUERY THE PLAN LANE CALLS `{}` ────
@@ -987,6 +1172,9 @@ const { enumerate: __enumerate } = makeEnumerate({
987
1172
  missingElementError: MISSING_ELEMENT_ERROR,
988
1173
  capabilities: { ...CAPABILITIES.kernel },
989
1174
  ceilingFor: ownerWindowCeiling,
1175
+ // Exact spelling stacks go as one ranked list of up to `exactOrWidth`; every other names band stays at
1176
+ // one term per request (`namesChunkDefault`).
1177
+ namesChunkFor,
990
1178
  // The kernel's default cheap-probe params are CORSEARCH'S — `{limit:1, fields:["uri"]}` — and `uri`
991
1179
  // is not a field this vendor has. Its `fields` projection rejects unknown names, so inheriting the
992
1180
  // default would 400 every count-first rescue while the ordinary search beside it worked. `limit: 1`
@@ -1098,3 +1286,12 @@ export async function doCountHits(apiKey, base, params, tctx, { mock = false } =
1098
1286
  }, null, 2),
1099
1287
  };
1100
1288
  }
1289
+
1290
+ /**
1291
+ * The register's own flag that a total counts records rather than marks: the `expanded_fallback` warning,
1292
+ * which it writes into `search_meta.warnings` when its grouped view cannot serve a filter. Matched on the
1293
+ * code, or on a message that opens with it, so a warning reshaped either way is still read. PURE.
1294
+ */
1295
+ export function isExpandedFallback(w) {
1296
+ return w?.code === "expanded_fallback" || /^expanded_fallback\b/.test(String(w?.message ?? ""));
1297
+ }
@@ -64,7 +64,7 @@ if (!/^\d+$/.test(String(opts.seed)) || Number(opts.seed) <= 0)
64
64
 
65
65
  // ── the inputs deriveBandShape uses, read the same way it reads them ─────────────────────────────────
66
66
  //
67
- // Deliberately mirrors driver/pipeline.mjs:441-490 rather than inventing a gathering step. If the two
67
+ // Deliberately mirrors driver/pipeline.mjs:441-490 mergeRegisterBands rather than inventing a gathering step. If the two
68
68
  // diverge, this probe measures a shape the run never had.
69
69
 
70
70
  const runDir = opts.run;
@@ -243,7 +243,7 @@ if (membershipBroken.length) {
243
243
 
244
244
  console.log(`\n${"═".repeat(78)}`);
245
245
  console.log(`This measures the BAND SHAPE only, and it is free. It is NOT the noise floor #217 needs.`);
246
- console.log(`That floor lives in the model-authored placement tier (placement-model.mjs:46) and needs`);
246
+ console.log(`That floor lives in the model-authored placement tier (placement-model.mjs:46 PLACEMENT_TIERS) and needs`);
247
247
  console.log(`repeat paid arms on a named run dir with approved spend. Nothing here substitutes for it.`);
248
248
  console.log(`Nothing was written. Exit code is 0 either way — this records, it does not judge.\n`);
249
249
  process.exit(0);
@@ -114,9 +114,26 @@ export function selectRange(all, { from, through, except }, distTags = {}) {
114
114
  if (!inRange(e)) throw new Error(`the exception ${e} lies outside ${from}..${through}`);
115
115
  }
116
116
  const picked = all.filter((v) => inRange(v) && !except.includes(v)).sort(compareVersions);
117
+ return refuseTagged(picked, distTags, "the range");
118
+ }
119
+
120
+ /**
121
+ * The published versions below a stable. Throws, before anything is written, when one of them is a
122
+ * version a dist-tag points at: the same refusal the range mode makes, because the hazard is the same.
123
+ */
124
+ export function selectBelow(all, below, distTags = {}) {
125
+ return refuseTagged(all.filter((v) => compareVersions(v, below) < 0), distTags, `the sweep below ${below}`);
126
+ }
127
+
128
+ /**
129
+ * ONE CHECK FOR EVERY MODE. `latest` and `beta` are what `npm install` resolves, so deprecating the version
130
+ * a tag points at warns every installer about the version they were just given. Each mode passes its
131
+ * selection through here, so a mode added later cannot select versions without it.
132
+ */
133
+ export function refuseTagged(picked, distTags = {}, what = "the selection") {
117
134
  const tagged = Object.entries(distTags).filter(([, v]) => picked.includes(v));
118
135
  if (tagged.length) {
119
- throw new Error(`the range takes ${tagged.map(([t, v]) => `${v} (${t})`).join(", ")}, which a dist-tag points at; `
136
+ throw new Error(`${what} takes ${tagged.map(([t, v]) => `${v} (${t})`).join(", ")}, which a dist-tag points at; `
120
137
  + "move the tag or hold the version out");
121
138
  }
122
139
  return picked;
@@ -124,6 +141,9 @@ export function selectRange(all, { from, through, except }, distTags = {}) {
124
141
 
125
142
  const npm = (...args) => execFileSync("npm", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
126
143
 
144
+ /** The registry's dist-tags, read in one place so every mode is refused on the same facts. */
145
+ const readDistTags = () => JSON.parse(npm("view", PKG, "dist-tags", "--json"));
146
+
127
147
  /** Every published version, and the deprecation message each carries. Read once, as a list. */
128
148
  function published() {
129
149
  const versions = JSON.parse(npm("view", PKG, "versions", "--json"));
@@ -202,7 +222,12 @@ async function main() {
202
222
  return 2;
203
223
  }
204
224
 
205
- const candidates = all.filter((v) => compareVersions(v, below) < 0);
225
+ let candidates;
226
+ try { candidates = selectBelow(all, below, readDistTags()); }
227
+ catch (e) {
228
+ console.error(`deprecate-below: refusing — ${e.message}. Nothing was written.`);
229
+ return 2;
230
+ }
206
231
  console.log(`deprecate-below: ${all.length} published version(s); ${candidates.length} below ${below}.`);
207
232
 
208
233
  let done = 0, skipped = 0, failed = 0;
@@ -262,8 +287,7 @@ async function deprecateRange(spec, DRY) {
262
287
  const parsed = parseRangeSpec(spec);
263
288
  const all = published();
264
289
  if (!all.length) throw new Error("the registry listed no versions");
265
- const distTags = JSON.parse(npm("view", PKG, "dist-tags", "--json"));
266
- picked = selectRange(all, parsed, distTags);
290
+ picked = selectRange(all, parsed, readDistTags());
267
291
  console.log(`deprecate-below: ${all.length} published version(s); ${picked.length} in ${spec}.`);
268
292
  } catch (e) {
269
293
  console.error(`deprecate-below: refusing — ${e.message}. Nothing was written.`);
@@ -36,6 +36,18 @@ import { driverDir, labelOfDriverFile } from "../shared/driver-dir.mjs";
36
36
  // What no file of the run record holds, stated on every answer. The register's own HTTP retries are
37
37
  // written to the provider call log, which is per machine, not per run; and a tool result whose text reports a
38
38
  // failure while its call settles ok carries no marker the call log keeps.
39
+ /** One line for what interrupted a session, from its record: counts only, never the program's words. */
40
+ function interruptionOf(session) {
41
+ const parts = [];
42
+ const cuts = session?.classifierCuts?.length ?? 0, refusals = session?.refusals?.length ?? 0;
43
+ const errors = (session?.results ?? []).filter((r) => r?.isError).length, starts = Number(session?.starts) || 0;
44
+ if (cuts) parts.push(`${cuts} classifier cut${cuts === 1 ? "" : "s"}`);
45
+ if (refusals) parts.push(`${refusals} refusal${refusals === 1 ? "" : "s"}`);
46
+ if (errors) parts.push(`${errors} error result${errors === 1 ? "" : "s"}`);
47
+ if (starts > 1) parts.push(`${starts - 1} restart${starts === 2 ? "" : "s"}`);
48
+ return parts.length ? `the session went on to deliver after ${parts.join(", ")}` : "the session's record marks it interrupted";
49
+ }
50
+
39
51
  export const NOT_RECORDED = [
40
52
  "register calls the provider retried inside one tool call (the call log is per machine, not per run)",
41
53
  "a tool result that reports a failure in its text while the call settles ok",
@@ -347,7 +359,10 @@ export function firstTimeRows({ attempts = [], runLog = [], status = {}, markers
347
359
  if (prev === undefined || n <= prev) cycles.set(stage, [...(cycles.get(stage) ?? []), { start: n, after: prev ?? null, row }]);
348
360
  prevAttempt.set(stage, n);
349
361
  }
350
- if (row.ok === false) add("failed attempt", stage, n, row.fail ?? "no cause recorded", row);
362
+ // A session that was cut, refused or restarted and still delivered is not ok and did not fail: it is
363
+ // named for what happened to it (engine/session-record.mjs), never as a failure with no cause.
364
+ if (row.ok === false && !row.fail && row.session?.interrupted) add("interrupted session", stage, n, interruptionOf(row.session), row);
365
+ else if (row.ok === false) add("failed attempt", stage, n, row.fail ?? "no cause recorded", row);
351
366
  else if (Number.isFinite(n) && n > 1) add("retry", stage, n, `attempt ${n} succeeded`, row);
352
367
  if (row.rescued) add("rescue", stage, n, `rescued: ${row.rescued}`, row);
353
368
  if (row.selfReportContradicted === true && row.ok !== false) add("self-report contradicted", stage, n, "the model reported done and its file said otherwise", row);
@@ -100,8 +100,8 @@ for (const path of Object.keys(PUBLISH_INPUTS)) {
100
100
  const KNOCKOUT_FILES = [
101
101
  { path: "knockout-findings.json", required: true, why: "report-registry.mjs:66 the batch findings ARE the report source" },
102
102
  { path: "knockout-plan.json", why: "report-registry.mjs:77 plan" },
103
- { path: "knockout-assessment.md", why: "the merged prose the lane writes (gateway.mjs:184)" },
104
- { path: "knockout-frame.md", why: "the batch scope note (gateway.mjs:65)" },
103
+ { path: "knockout-assessment.md", why: "the merged prose the lane writes (gateway.mjs:184 TOOL_WRITTEN_PATTERNS)" },
104
+ { path: "knockout-frame.md", why: "the batch scope note (gateway.mjs:65 TOOL_WRITTEN_ARTIFACTS)" },
105
105
  { path: "email-body.md", why: "the delivery prose the lane writes beside the assessment" },
106
106
  { path: "status.json", why: "publish/index.mjs:913 machineLedgerNote + markName" },
107
107
  { path: "audit.md", why: "publish/index.mjs:1022 auditMd, the audit workbook source" },
package/shared/scope.mjs CHANGED
@@ -792,6 +792,12 @@ function tenantStamp(scope, asked) {
792
792
  throw new Error(`name the organisation whose Generic this is (tenant) — your access covers ${orgs.length}`);
793
793
  }
794
794
 
795
+ // The install's organisation when the grants file holds exactly one; null with several, none, or no file.
796
+ function soleOrganisation() {
797
+ const orgs = Object.keys(loadGrants()?.tenants ?? {});
798
+ return orgs.length === 1 ? orgs[0] : null;
799
+ }
800
+
795
801
  // True iff `email`'s domain (the part after the final '@') is one of firmDomains. PURE (no jose), so the HTTP
796
802
  // handler can derive firm-staff-ness from the already-verified CF identity without pulling the auth lib. The
797
803
  // `internal` (read-all) grant rests on THIS positive check — NOT on which CF app admitted the caller — so a
@@ -1016,8 +1022,18 @@ export function authorize(scope, toolName, args = {}) {
1016
1022
  // paid run whose delivery packet routes nowhere. A refusal naming the missing field is the cheaper
1017
1023
  // failure by far, and it is not the "accepted then quietly narrower" shape: the preview describes a
1018
1024
  // job stamped from the verified identity, and start_run says plainly which field to add.
1019
- if (toolName === "plan_run") return stampForwarder(scope, args);
1020
- return args;
1025
+ //
1026
+ // WHICH ORGANISATION'S GENERIC. An ops token names no person, so there is nobody's reach to read the
1027
+ // organisation from — and passed through bare, every Generic run a connector started was filed under
1028
+ // no organisation, where the Generic filter on Clearances can never find it. On an install that holds
1029
+ // exactly one organisation there is nothing to guess, so the job takes it: the same answer the
1030
+ // person-scoped branch gives a person who sees that one organisation. A named tenant passes as it
1031
+ // stands, and with several organisations (or none) the job is left as it was.
1032
+ const placed = (toolName === "start_run" || toolName === "plan_run") && (args?.profileKey ?? "generic") === "generic"
1033
+ && !(typeof args?.tenant === "string" && args.tenant.trim()) ? soleOrganisation() : null;
1034
+ const routed = placed ? { ...args, tenant: placed } : args;
1035
+ if (toolName === "plan_run") return stampForwarder(scope, routed);
1036
+ return routed;
1021
1037
  }
1022
1038
  // kind === "account": a signed-in CLIENT, across their OWN account(s). Reads the client layer on any of
1023
1039
  // their runs, and drives the run lifecycle for them. Run OWNERSHIP is not checked here — it is enforced