clearotron 0.4.1-beta.3 → 0.4.1-beta.5

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 (52) hide show
  1. package/README.md +1 -0
  2. package/build-info.json +2 -2
  3. package/driver/CHANGELOG.md +14 -0
  4. package/driver/band-shape.mjs +390 -21
  5. package/driver/clearance-variants-record.mjs +5 -1
  6. package/driver/config-inventory.mjs +8 -16
  7. package/driver/contract-vocabulary.mjs +4 -2
  8. package/driver/degraded-parts.mjs +7 -0
  9. package/driver/effective-scope.mjs +27 -1
  10. package/driver/engine/anthropic-agent.mjs +23 -1
  11. package/driver/engine/mcp/band-server.mjs +128 -28
  12. package/driver/engine/mcp/codex-config.mjs +8 -0
  13. package/driver/engine/mcp/recording-server.mjs +8 -0
  14. package/driver/engine/openai-agent.mjs +87 -2
  15. package/driver/findings-model.mjs +7 -3
  16. package/driver/gateway.mjs +23 -5
  17. package/driver/matter-frame-record.mjs +12 -2
  18. package/driver/package.json +1 -1
  19. package/driver/partial-payload-baseline.json +2 -1
  20. package/driver/pipeline.mjs +50 -14
  21. package/driver/placement-diff.mjs +8 -3
  22. package/driver/placement-form-io.mjs +31 -0
  23. package/driver/placement-form.mjs +231 -31
  24. package/driver/placement-model.mjs +6 -0
  25. package/driver/placement-union.mjs +134 -10
  26. package/driver/predelivery-lint.mjs +2 -2
  27. package/driver/publish/index.mjs +4 -1
  28. package/driver/record-goods.mjs +261 -0
  29. package/driver/skills/clearance-variants/SKILL.md +2 -2
  30. package/driver/skills/matter-frame/SKILL.md +2 -2
  31. package/driver/skills/placement-inquiry/SKILL.md +17 -17
  32. package/driver/stage-context.mjs +6 -2
  33. package/driver/stages.mjs +8 -8
  34. package/driver/suite-census.json +136 -16
  35. package/driver/terminal-clamp.mjs +39 -5
  36. package/driver/unit-inventory.mjs +7 -0
  37. package/driver/variant-manifest-model.mjs +26 -1
  38. package/driver/verify.mjs +79 -7
  39. package/mcp-server/CHANGELOG.md +8 -0
  40. package/mcp-server/package.json +1 -1
  41. package/package.json +1 -1
  42. package/portal-ui/dist/assets/{index-BPAUjcI0.js → index-DcLbvFmQ.js} +1 -2
  43. package/portal-ui/dist/index.html +1 -1
  44. package/portal-ui/package.json +2 -2
  45. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  46. package/providers/oauth-mcp-bridge/package.json +1 -1
  47. package/scripts/band-shape-probe.mjs +2 -1
  48. package/scripts/bench-replay-request.mjs +111 -0
  49. package/scripts/engine-pins.mjs +117 -0
  50. package/scripts/freeze-example-run.mjs +2 -2
  51. package/scripts/release-bench-replay-check.mjs +87 -0
  52. package/scripts/settings-render-check.mjs +7 -8
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  <p align="center">
9
9
  <a href="LICENSE"><img src="https://img.shields.io/badge/licence-AGPL--3.0--only-860F09?style=flat-square" alt="Licence: AGPL-3.0-only"></a>
10
10
  <a href=".nvmrc"><img src="https://img.shields.io/badge/node-%E2%89%A5%2022.13-250902?style=flat-square" alt="Node 22.13+"></a>
11
+ <a href="https://scorecard.dev/viewer/?uri=github.com/CordilleraSarl/clearotron"><img src="https://img.shields.io/ossf-scorecard/github.com/CordilleraSarl/clearotron?label=openssf%20scorecard&style=flat-square" alt="OpenSSF Scorecard"></a>
11
12
  </p>
12
13
 
13
14
  Before your company commits to a name, find out what stands in its way. Give Clearotron the name, the
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "38d43f7a215c87092fd2cc233812b3aff17e1e82",
3
- "version": "0.4.1-beta.3"
2
+ "commit": "d407cad37cded0141194ed22bbcaf698232202c8",
3
+ "version": "0.4.1-beta.5"
4
4
  }
@@ -1,5 +1,19 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.1-beta.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: A report's conditions no longer include an internal note about the search's own checking steps.
8
+ - For operators: A stopped run no longer leaves behind a working folder linked to the Codex sign-in; leftovers are removed the next time Codex runs.
9
+ - Fixed: Every live identical or near-identical mark a register search finds is now placed in the report or set aside with its reason.
10
+
11
+ ## 0.4.1-beta.4
12
+
13
+ ### Patch Changes
14
+
15
+ - Fixed: Every clearance uses the same web search whichever engine runs it; the engines' own web search is switched off.
16
+
3
17
  ## 0.4.1-beta.3
4
18
 
5
19
  ### Patch Changes
@@ -5,8 +5,8 @@
5
5
  // The 2026-07-28 postmortem's lesson: the merged band is megabytes (median 5.2MB across the archive; Read hard-refuses
6
6
  // >256KB), so judgment never read it whole — every stage sliced it with improvised, unreviewed shell
7
7
  // (digest#2: 71 ad-hoc Bash calls). This module is the reading layer's frozen-plan equivalent: a PURE,
8
- // deterministic projection of the band that fits in one Read (<256KB by construction for everything
9
- // except the unconditional floors), derived by the driver after EVERY named-band re-merge.
8
+ // deterministic projection of the band, served in parts once it outgrows one answer (the groups section grows
9
+ // with the band; the parts banner says how many), derived by the driver after EVERY named-band re-merge.
10
10
  //
11
11
  // What it is NOT: a relevance/sufficiency/materiality judgment. Tiers are MECHANICAL string classes
12
12
  // (the same pure utils the form floor is built from — normalizers + doubleMetaphone); whether a
@@ -26,6 +26,10 @@
26
26
  import { normalizeElement, editNeighbourhood, consonantSkeleton, confusableSkeleton, foldDiacritics } from "./form-neighbourhood.mjs";
27
27
  import { doubleMetaphone } from "./phonetic-key.mjs";
28
28
  import { probeOrder } from "./order-probe.mjs"; // item 11 — inert unless CLEAROTRON_ORDER_PROBE_SEED is set
29
+ import { normKey } from "./placement-diff.mjs";
30
+ import { canonicalJurisdictionCode, KNOWN_JURISDICTION_CODES } from "./jurisdiction-codes.mjs"; // — one spelling per register: a band's "EM" and a market's "EU" are one office
31
+ import { bindingLayersFor } from "./binding-layers.mjs";
32
+ import { territoryKey } from "./territory-tiers.mjs"; // — a territory written as a name reads as its code // — the registers that bind an ordered territory: its own, the EU's for a member, every member's for the EU, Benelux, Madrid // — the set-aside key's owner fold: an owner is grouped the way a set-aside row covers it
29
33
 
30
34
  export const BAND_SHAPE_SCHEMA_VERSION = 1;
31
35
 
@@ -206,9 +210,40 @@ export function classifyRecord(markText, targets) {
206
210
  if (cons && cons === t.cons) return { tier: "same-family", target: t.text, basis: "consonant-skeleton" };
207
211
  if (meta.length && t.metaphone.length && meta.some((m) => t.metaphone.includes(m))) return { tier: "same-family", target: t.text, basis: "double-metaphone" };
208
212
  }
213
+ // ── A MARK TWO EDITS FROM A VARIANT OF SIX LETTERS OR MORE IS OF ITS FAMILY (ruled 2026-10-13) ────────
214
+ //
215
+ // The variants stage renders the mark's meaning in some of a market's languages, and the same word in another
216
+ // language is often two edits from one of them. None of the rungs above reads that: one edit, a whole matching
217
+ // word, the consonant skeleton, the sound code. Measured on a saved band, a register entry a reviewing lawyer
218
+ // listed sat two edits from such a meaning form and read as "other". Each pair is compared, never enumerated:
219
+ // the form floor's radius stays one edit (form-neighbourhood.mjs). The rung makes no floor, which takes the
220
+ // identical and near-identical tiers only, and runs last, so no tier above it moves.
221
+ for (const t of prepared) {
222
+ if (t.norm.length >= EDIT2_MIN_LEN && withinTwoEdits(norm, t.norm)) return { tier: "same-family", target: t.text, basis: "edit-2" };
223
+ }
209
224
  return { tier: "other", target: null, basis: "no-mechanical-match" };
210
225
  }
211
226
 
227
+ /** The shortest target the two-edit rung reads: below six letters two edits reach too many unrelated words. */
228
+ export const EDIT2_MIN_LEN = 6;
229
+
230
+ /** Is `a` within two insertions, deletions or substitutions of `b`? Stops at the first row past two. PURE. */
231
+ function withinTwoEdits(a, b) {
232
+ if (Math.abs(a.length - b.length) > 2) return false;
233
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
234
+ for (let i = 1; i <= a.length; i++) {
235
+ const cur = [i];
236
+ let best = i;
237
+ for (let j = 1; j <= b.length; j++) {
238
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
239
+ if (cur[j] < best) best = cur[j];
240
+ }
241
+ if (best > 2) return false;
242
+ prev = cur;
243
+ }
244
+ return prev[b.length] <= 2;
245
+ }
246
+
212
247
  // ── dominant-element composites (P2-A, the recall spine) ────────────────────────────────────────────
213
248
  // The candidate set of the retrieved→judgment reconciliation: every LIVE, in-scope, SCREEN-SURFACED
214
249
  // record whose mark carries the manifest's dominant element — as a standalone token
@@ -258,7 +293,7 @@ export function dominantElementBasis(markText, prep) {
258
293
  export function dominantElementComposites(band, { dominantElement, inScopeClasses = [] } = {}) {
259
294
  const prep = prepareDominantElement(dominantElement);
260
295
  if (!prep) return null; // no comparable dominant element ⇒ not computable
261
- const scope = new Set((inScopeClasses ?? []).map((c) => String(c).trim()).filter(Boolean));
296
+ const scope = new Set((inScopeClasses ?? []).map(niceClassKey).filter(Boolean));
262
297
  const inScope = (r) => {
263
298
  const cls = classesOf(r);
264
299
  if (!scope.size) return true;
@@ -531,7 +566,9 @@ export function deriveRegisterPositions(records, detailByUri = new Map()) {
531
566
  }
532
567
 
533
568
  const inc = (obj, key, by = 1) => { const k = String(key); obj[k] = (obj[k] ?? 0) + by; };
534
- const classesOf = (r) => (Array.isArray(r?.classes) ? r.classes : r?.classes != null ? [r.classes] : []).map((c) => String(c).trim()).filter(Boolean);
569
+ /** A Nice class as one spelling: "09", "9" and 9 are class 9. PURE. */
570
+ export const niceClassKey = (c) => { const t = String(c ?? "").trim(); return /^\d+$/.test(t) ? String(Number(t)) : t; };
571
+ const classesOf = (r) => (Array.isArray(r?.classes) ? r.classes : r?.classes != null ? [r.classes] : []).map(niceClassKey).filter(Boolean);
535
572
  const registryOf = (r) => String(r?.screen?.registry ?? (Array.isArray(r?.jurisdictions) ? r.jurisdictions[0] : r?.jurisdictions) ?? "").trim() || "unknown";
536
573
  const yearOf = (r) => {
537
574
  const m = String(r?.application_date ?? r?.screen?.applicationDate ?? "").match(/\b(19|20)\d{2}\b/);
@@ -572,6 +609,203 @@ const floorRow = (r, cls) => ({
572
609
  _query: r._query ?? null,
573
610
  });
574
611
 
612
+ // ── THE BAND IN THE LAWYER'S ORDER ──────────────────────────────────────────────────────────────────
613
+ //
614
+ // The crowded-field method, as ruled 2026-09-21: identical marks in the class;
615
+ // identical marks for the particular goods; the dominant word for the particular goods; then the rest,
616
+ // counted, with its rule. "Fifty? Read them all. A few hundred? Sort by the client's goods and markets,
617
+ // read the near ones first, list the rest. A thousand or more? The field is crowded: read the identical
618
+ // and live ones for the client's goods, tell the client it is crowded, and do not write up the rest."
619
+ //
620
+ // Code sorts on fields the records carry: the mark's tier against the targets, a goods-word match, class
621
+ // and status. It judges nothing.
622
+ export const BAND_GROUPS = ["a", "b", "c", "d", "rest"];
623
+ // The method's "a thousand or more": a group this size is served as its count and its first page.
624
+ export const CROWDED_GROUP_RECORDS = 1000;
625
+ const TIER_RANK = { identical: 0, "near-identical": 1, "same-family": 2, other: 3, unclassifiable: 4 };
626
+
627
+ /**
628
+ * One record's group. PURE. `hits` is how many of the client's goods words its goods contain, or null
629
+ * when the run holds no goods for it: unknown passes the goods test, so a record is never set aside for
630
+ * what the run does not hold.
631
+ * (a) identical or near-identical to a target, live in the ordered classes, for the client's goods;
632
+ * (b) identical or near-identical to a target, in the ordered classes;
633
+ * (c) the same family as a target, or carrying the searched dominant word, for the client's goods;
634
+ * (d) every other live mark in the ordered classes, for the client's goods;
635
+ * the rest: everything else.
636
+ */
637
+ export function recordGroup({ tier, live, inScope, hits, dominant = false }) {
638
+ const ident = tier === "identical" || tier === "near-identical";
639
+ const forGoods = hits !== 0;
640
+ // (a) reads the goods of live records in the ordered classes only (ruled 2026-10-12): a dead or
641
+ // out-of-class record's goods never put a mark in (a).
642
+ if (ident && live && inScope && forGoods) return "a";
643
+ if (ident && inScope) return "b";
644
+ if ((tier === "same-family" || dominant) && forGoods) return "c";
645
+ if (live && inScope && forGoods) return "d";
646
+ return "rest";
647
+ }
648
+
649
+ /**
650
+ * The band in the ruled order: ONE LINE PER MARK OF AN OWNER, in one collated list per group. PURE. The unit is
651
+ * the mark, not the owner: an owner shown on one line under its closest mark hid a different mark of the
652
+ * same owner, and a ground written about the shown mark set the other aside unseen.
653
+ *
654
+ * THE LIST IS THE ORDER'S SCOPE (ruled 2026-10-12). A worldwide order lists the world; an order naming its
655
+ * territories lists every register that binds them (`bindingLayersFor`): a member state's own register,
656
+ * the EU's and Madrid's for it, every member's for an EU order, Benelux for its three. A line held only in
657
+ * registers outside them goes to the rest, counted with that reason and still open to a lookup. A record
658
+ * whose register cannot be read stays in.
659
+ *
660
+ * "FOR THE CLIENT'S GOODS" MEANS THE PARTICULAR GOODS: the words that name the client's product, never
661
+ * the class words the variants stage adds for the register search. `goods.hits` and `goods.matched`
662
+ * answer for those words.
663
+ *
664
+ * EACH GROUP RUNS IN THE ORDER THE OWNER RULED (2026-10-12): a line with the client's goods on a live record
665
+ * in the ordered classes first; then more live records in those classes; then more goods words; then a
666
+ * client market; then more records. Code sorts on fields the records carry and judges nothing. A line
667
+ * carries the goods words it matched, never the goods text, which is one lookup away.
668
+ *
669
+ * A line sits in the best group its records in scope reach. `keysOf` gives a record's line and its owner's
670
+ * set-aside key, both read through the positions fold, so a line covers exactly what a row naming one of
671
+ * its records reaches. A line carries the words matched, so nothing on it is cut; the one line that cannot
672
+ * be judged from itself is one the goods test placed while its records carry no goods on file.
673
+ *
674
+ * @param records the band's enumerated records
675
+ * @param classify record → { tier, target } (the shape's own classifier over its targets)
676
+ * @param inScope record → boolean (the shape's own class test)
677
+ * @param goods { of, hits, matched } (record-goods.mjs), or null when the run holds none
678
+ * @param dominantTerm the word the register's goods-narrowed search asked for, or ""
679
+ * @param keysOf record → { line, owner }, or null
680
+ * @param markets the client's markets, as territory codes
681
+ * @param territories the order's territories, as codes (effective-scope.mjs `orderTerritories`); empty for a worldwide order
682
+ */
683
+ /**
684
+ * THE REST NEVER HOLDS A FLOOR. The rest is set aside in one row, at a thousand records or more on its first
685
+ * page and its count, so a floor in it could be left unread. The group rule puts every floor in (a) or (b); a
686
+ * rest holding one is a defect here, and the shape is refused rather than served with a floor no one will
687
+ * read. PURE (throws).
688
+ */
689
+ export function assertRestHoldsNoFloor(restLines) {
690
+ const stray = (restLines ?? []).find((l) => l?.floors > 0);
691
+ if (stray) throw new Error(`band shape: the rest holds a floor (${stray.record_id ?? stray.key}); a floor is listed in (a) or (b), never counted in the rest`);
692
+ }
693
+
694
+ /**
695
+ * Every register that binds any of these territories: each territory's own, the EU's for a member, every
696
+ * member's for the EU, Benelux's for its three, Madrid's for all. The Benelux register is written BX or as any
697
+ * of its three members' codes, binds all three, and sits inside the EU. PURE.
698
+ */
699
+ const BENELUX = ["BX", "NL", "BE", "LU"];
700
+ function bindingOffices(territories, code) {
701
+ const out = new Set();
702
+ for (const t of territories) {
703
+ out.add(t);
704
+ try { for (const l of bindingLayersFor(t)) out.add(code(l.office)); } catch { /* an unknown code binds itself */ }
705
+ }
706
+ if (out.has("WO")) out.add("IB"); // the International Bureau is the Madrid register by another name
707
+ if (BENELUX.some((c) => out.has(c))) { for (const c of BENELUX) out.add(c); out.add("EU"); out.add("WO"); out.add("IB"); }
708
+ return out;
709
+ }
710
+
711
+ export function bandGroups(records, { classify, inScope, goods = null, dominantTerm = "", keysOf = null, markets = [], territories = [] } = {}) {
712
+ const fold = (x) => foldDiacritics(String(x ?? "")).toLowerCase();
713
+ const dom = fold(dominantTerm).trim();
714
+ const domRe = dom ? new RegExp(`(^|[^\\p{L}\\p{N}])${dom.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "u") : null;
715
+ const domPrep = prepareDominantElement(dominantTerm); // the composites' own ladder, for a word fused into a longer one
716
+ // A territory or register as written, a code or a name, read as its code: "Switzerland" and "CH" are one.
717
+ const code = (t) => canonicalJurisdictionCode(territoryKey(String(t ?? "").trim()) || String(t ?? "").trim().toUpperCase());
718
+ const ordered = new Set((territories ?? []).map(code).filter(Boolean));
719
+ // An ordered territory the engine cannot read binds no register it knows; cutting on the others would drop
720
+ // records inside it unseen. So it is reported, and the list holds every register.
721
+ const unreadTerritories = [...ordered].filter((t) => !KNOWN_JURISDICTION_CODES.has(t));
722
+ const offices = unreadTerritories.length ? new Set() : bindingOffices(ordered, code);
723
+ // The client's markets, read the same way; a market of the EU holds a member's register too.
724
+ const marketsGiven = [...new Set((markets ?? []).map(code).filter(Boolean))];
725
+ const market = bindingOffices(new Set(marketsGiven), code);
726
+ // A register as a provider writes it: a code, a list of codes, or a list of {jurisdiction, …}, read by its
727
+ // code field alone. What cannot be read gives no code, and a record with none stays in.
728
+ const raw = (j) => (j == null ? [] : Array.isArray(j) ? j.flatMap(raw) : typeof j === "object"
729
+ ? ((j.jurisdiction ?? j.code ?? j.office ?? j.country) != null ? raw(j.jurisdiction ?? j.code ?? j.office ?? j.country) : [])
730
+ : [j]);
731
+ const territoriesOf = (r) => raw(r?.jurisdictions).map(code).filter((t) => /^[A-Z]{2,5}$/.test(t));
732
+ const inOrder = (ts) => !offices.size || !ts.length || ts.some((t) => offices.has(t));
733
+ const lines = new Map();
734
+ for (const r of Array.isArray(records) ? records : []) {
735
+ const { tier } = classify(r);
736
+ const g = goods ? goods.of(r) : null;
737
+ const hits = goods && g ? goods.hits(g) : null;
738
+ const words = goods && g && goods.matched ? goods.matched(g) : null;
739
+ const live = isLiveRecord(r), s = inScope(r);
740
+ const ts = territoriesOf(r);
741
+ const outside = !inOrder(ts);
742
+ const dominant = Boolean(domRe && domRe.test(fold(r?.mark_text))) || dominantElementBasis(r?.mark_text, domPrep) === "concatenation";
743
+ const floor = (tier === "identical" || tier === "near-identical") && live && s; // the floors' own test
744
+ // A floor is never counted in the rest (ruled 2026-10-12): held only outside the order's territories, it
745
+ // is still listed, in (a) or (b), where the step reads it and may set it aside by name on its territory.
746
+ const group = outside && !floor ? "rest" : recordGroup({ tier, live, inScope: s, hits, dominant });
747
+ const rec = { r, tier, hits, words, live, inScope: s, group, outside, floor, onFile: Boolean(g), territories: ts, inMarket: ts.some((t) => market.has(t)) };
748
+ const keys = typeof keysOf === "function" ? keysOf(r) : null;
749
+ const ownerKey = keys?.owner ?? (normKey(r?.owner_name) ? `owner:${normKey(r?.owner_name)}` : `record:${String(r?.record_id ?? "").trim().toLowerCase()}`);
750
+ const key = keys?.line ?? `line:${ownerKey}|${normMark(r?.mark_text) || String(r?.record_id ?? "").trim().toLowerCase()}`;
751
+ const line = lines.get(key) ?? { key, owner_key: ownerKey, owner_name: String(r?.owner_name ?? "").trim() || null, recs: [] };
752
+ line.recs.push(rec);
753
+ lines.set(key, line);
754
+ }
755
+ const rank = (g) => BAND_GROUPS.indexOf(g);
756
+ const better = (x, y) => TIER_RANK[x.tier] - TIER_RANK[y.tier] || (y.hits ?? -1) - (x.hits ?? -1)
757
+ || Number(y.live) - Number(x.live) || String(x.r?.record_id ?? "").localeCompare(String(y.r?.record_id ?? ""));
758
+ const byGroup = Object.fromEntries(BAND_GROUPS.map((g) => [g, { records: 0, set_records: 0, lines: [] }]));
759
+ const noGoods = [];
760
+ let outsideLines = 0, outsideRecords = 0;
761
+ for (const l of lines.values()) {
762
+ const group = BAND_GROUPS[Math.min(...l.recs.map((x) => rank(x.group)))];
763
+ const inGroup = l.recs.filter((x) => x.group === group).sort(better);
764
+ const best = inGroup[0];
765
+ // Unknown is decided on the records that put the line in its group: a line placed by a record whose goods
766
+ // could not be read is listed apart, whatever the line's other records carry.
767
+ const known = inGroup.some((x) => x.words != null);
768
+ const words = [...new Set(l.recs.flatMap((x) => x.words ?? []))];
769
+ const liveInScope = l.recs.filter((x) => x.live && x.inScope && !x.outside);
770
+ const row = {
771
+ key: l.key, owner_key: l.owner_key, owner_name: l.owner_name, mark: String(best.r?.mark_text ?? "").trim() || null, group,
772
+ records: l.recs.length, group_records: inGroup.length, live: l.recs.filter((x) => x.live).length,
773
+ live_in_scope: liveInScope.length, goods_live_in_scope: liveInScope.some((x) => (x.hits ?? 0) > 0),
774
+ best_tier: best.tier, goods_hits: known ? words.length : null, goods_words: known ? words : null,
775
+ in_market: l.recs.some((x) => x.inMarket), in_scope: l.recs.some((x) => x.inScope),
776
+ territories: [...new Set(l.recs.flatMap((x) => x.territories))].sort(),
777
+ classes: [...new Set(l.recs.flatMap((x) => classesOf(x.r)))].sort((p, q) => Number(p) - Number(q) || p.localeCompare(q)),
778
+ // A line the goods test could not place shows a record whose goods are on file where it has one, so
779
+ // `band_record` opens the record the line names; `goods_on_file` says so of that record.
780
+ record_id: (known ? best : (inGroup.find((x) => x.onFile) ?? best)).r?.record_id ?? null,
781
+ floors: l.recs.filter((x) => x.floor).length,
782
+ ...(known ? {} : { goods_on_file: inGroup.some((x) => x.onFile) }),
783
+ ...(offices.size && l.recs.every((x) => x.outside) ? { outside: true } : {}),
784
+ };
785
+ // A line placed in (a), (c) or (d) by the goods test, whose records carry no goods the engine can read,
786
+ // cannot be judged from the line: it is listed apart, opened before a fate, and no group row covers it.
787
+ if (!known && (group === "a" || group === "c" || group === "d")) { noGoods.push(row); continue; }
788
+ if (group === "rest" && l.recs.every((x) => x.outside)) { row.outside = true; outsideLines++; outsideRecords += l.recs.length; }
789
+ byGroup[group].records += inGroup.length;
790
+ byGroup[group].set_records += l.recs.length;
791
+ byGroup[group].lines.push(row);
792
+ }
793
+ assertRestHoldsNoFloor(byGroup.rest.lines);
794
+ const byName = (x, y) => String(x.owner_name ?? x.owner_key).localeCompare(String(y.owner_name ?? y.owner_key))
795
+ || String(x.mark ?? "").localeCompare(String(y.mark ?? "")) || String(x.key).localeCompare(String(y.key));
796
+ const herOrder = (x, y) => Number(y.goods_live_in_scope) - Number(x.goods_live_in_scope) || y.live_in_scope - x.live_in_scope
797
+ || (y.goods_hits ?? -1) - (x.goods_hits ?? -1) || Number(y.in_market) - Number(x.in_market) || y.records - x.records || byName(x, y);
798
+ for (const g of ["a", "b", "c", "d"]) byGroup[g].lines.sort(herOrder);
799
+ // The rest runs by the client's goods words, then by live records in the ordered classes (ruled 2026-10-12):
800
+ // listed whole under a thousand records, by its first page at a thousand or more, like any group.
801
+ byGroup.rest.lines.sort((x, y) => (y.goods_hits ?? -1) - (x.goods_hits ?? -1) || y.live_in_scope - x.live_in_scope || byName(x, y));
802
+ // The lines the goods test could not place run floors first, then by live records in the ordered classes,
803
+ // then records: a floor here takes its own fate, so the floors lead the first page.
804
+ noGoods.sort((x, y) => Number(y.floors > 0) - Number(x.floors > 0) || y.live_in_scope - x.live_in_scope || y.records - x.records || byName(x, y));
805
+ return { dominant_term: dominantTerm || null, goods_on_file: Boolean(goods), markets: marketsGiven, territories: [...ordered], territories_unread: unreadTerritories,
806
+ outside: { lines: outsideLines, records: outsideRecords }, by_group: byGroup, no_goods: noGoods };
807
+ }
808
+
575
809
  /**
576
810
  * Build the deterministic band shape + its readable markdown mirror.
577
811
  *
@@ -587,11 +821,11 @@ const floorRow = (r, cls) => ({
587
821
  * (charter P2d); the band itself stays per-registration
588
822
  * @returns { shape, md } deterministic — same band + inputs ⇒ same bytes (no timestamps)
589
823
  */
590
- export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null } = {}) {
824
+ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null, goods = null, goodsTest = null, goodsWordCount = 0, dominantTerm = "", manifestDominant = "", keysOf = null, markets = [], territories = [] } = {}) {
591
825
  const prepared = prepareTargets(Array.isArray(targets) && targets.every(isPrepared) ? targets.map((t) => t.text) : targets);
592
826
  const records = Array.isArray(band?.enumerated) ? band.enumerated : [];
593
827
  const crowds = Array.isArray(band?.crowds) ? band.crowds : [];
594
- const scope = new Set((inScopeClasses ?? []).map((c) => String(c).trim()).filter(Boolean));
828
+ const scope = new Set((inScopeClasses ?? []).map(niceClassKey).filter(Boolean));
595
829
  const inScope = (r) => {
596
830
  const cls = classesOf(r);
597
831
  if (!scope.size) return true; // no instructed classes ⇒ everything is in scope
@@ -606,8 +840,10 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
606
840
  const unclassifiable = [];
607
841
  const scriptGaps = new Map(); // script(s) key → { count, sample[] }
608
842
 
843
+ const classified = new Map();
609
844
  for (const r of records) {
610
845
  const cls = classifyRecord(r?.mark_text, prepared);
846
+ classified.set(r, cls);
611
847
  byTier[cls.tier]++;
612
848
  for (const c of classesOf(r)) inc(byClass, c);
613
849
  inc(byStatus, String(r?.status ?? r?.screen?.status ?? "unknown").trim().toLowerCase() || "unknown");
@@ -643,6 +879,23 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
643
879
  const floorsOrdered = probeOrder(floors, "floors");
644
880
 
645
881
  const ownerCap = Number.isFinite(caps.owners) ? caps.owners : 40;
882
+ const grouped = bandGroups(records, { classify: (r) => classified.get(r), inScope, goods, dominantTerm, keysOf, markets, territories });
883
+ const ownersIn = (ls) => new Set(ls.map((l) => l.owner_key)).size;
884
+ const groupOf = (g) => {
885
+ const ls = grouped.by_group[g].lines;
886
+ return { records: grouped.by_group[g].records, set_records: grouped.by_group[g].set_records, lines: ls.length, owners: ownersIn(ls), list: ls };
887
+ };
888
+ const rest = grouped.by_group.rest.lines;
889
+ const groups = {
890
+ dominant_term: grouped.dominant_term, manifest_dominant: manifestDominant || null, goods_on_file: grouped.goods_on_file,
891
+ markets: grouped.markets, territories: grouped.territories, territories_unread: grouped.territories_unread, outside: grouped.outside,
892
+ goods_words_total: goodsWordCount || 0, goods_test: goodsTest ?? { source: goods ? "goods_words" : "none", words: [] },
893
+ crowded_at: CROWDED_GROUP_RECORDS, first_page: ownerCap,
894
+ a: groupOf("a"), b: groupOf("b"), c: groupOf("c"), d: groupOf("d"),
895
+ rest: { records: grouped.by_group.rest.set_records, lines: rest.length, owners: ownersIn(rest), list: rest },
896
+ no_goods: { records: grouped.no_goods.reduce((n, l) => n + l.records, 0), lines: grouped.no_goods.length,
897
+ owners: ownersIn(grouped.no_goods), list: grouped.no_goods },
898
+ };
646
899
  const concentrations = [...owners.entries()]
647
900
  .map(([owner_name, v]) => ({ owner_name, records: v.records, live: v.live }))
648
901
  .sort((a, b) => b.records - a.records || a.owner_name.localeCompare(b.owner_name))
@@ -736,11 +989,12 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
736
989
 
737
990
  const shape = {
738
991
  schema_version: BAND_SHAPE_SCHEMA_VERSION,
739
- role: "deterministic shape of the merged register band — judgment reads THIS whole and looks up records through the band tools (every lookup is logged); machine checks keep reading the complete band; tiers are mechanical string classes, never a relevance or materiality judgment",
992
+ role: "deterministic shape of the merged register band — judgment reads each group by the size sentence at its head and looks up records through the band tools (every lookup is logged); machine checks keep reading the complete band; tiers are mechanical string classes, never a relevance or materiality judgment",
740
993
  targets: prepared.map((t) => t.text),
741
994
  in_scope_classes: [...scope].sort(),
742
995
  totals: { records: records.length, crowds: crowds.length, by_tier: byTier },
743
996
  floors: { in_class_identical_or_near: floorsOrdered },
997
+ groups,
744
998
  by_class: byClass,
745
999
  by_status: byStatus,
746
1000
  by_registry: byRegistry,
@@ -765,7 +1019,9 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
765
1019
  } } : {}),
766
1020
  };
767
1021
 
768
- return { shape, md: renderBandShapeMd(shape, { positions }) };
1022
+ const { md, lines } = bandShapeMdLines(shape, { positions });
1023
+ shape.md_lines = lines;
1024
+ return { shape, md };
769
1025
  }
770
1026
 
771
1027
  const fmtN = (n) => Number(n ?? 0).toLocaleString("en-US");
@@ -779,7 +1035,7 @@ function topTable(md, title, cols, entries, cap = 30) {
779
1035
  if (rows.length > cap) md.push(`| … ${fmtN(rows.length - cap)} more (full detail in band-shape.json) | |`);
780
1036
  }
781
1037
 
782
- /** The readable mirror — small enough to Read whole; the floors section is complete by contract. PURE. */
1038
+ /** The readable mirror, served in parts when large; each floor is marked on its line, and the data keeps them all. PURE. */
783
1039
  /**
784
1040
  * §8 — mark → the sharpest floor tier that mark holds, for the write-up-form predicate.
785
1041
  *
@@ -811,8 +1067,114 @@ export function floorTierByMark(shape) {
811
1067
  /** The same normalisation the floors were built with, so a caller cannot key the map differently. */
812
1068
  export const floorMarkKey = (s) => normMark(s);
813
1069
 
1070
+ // One line per mark of an owner, about 150 characters: the goods words it matched, never the goods text, and
1071
+ // whether it holds a floor. Each list carries the method's size sentence for its own length: read them all;
1072
+ // read the near ones first and list the rest; or, at a thousand records or more, the count and the first page.
1073
+ const GROUP_HEADINGS = {
1074
+ a: "(a) Identical or near-identical to the mark or one of its forms, live in the ordered classes, for the client's goods",
1075
+ b: "(b) Identical or near-identical to the mark or one of its forms, in the ordered classes",
1076
+ c: "(c) The same family as the mark, or carrying the searched word, for the client's goods",
1077
+ d: "(d) Every other live mark in the ordered classes, for the client's goods",
1078
+ };
1079
+ export const READ_ALL_LINES = 50;
1080
+ /** How many lines of the band hold a floor, wherever the shape put them. PURE. */
1081
+ const floorLineCount = (groups) => ["a", "b", "c", "d", "no_goods"].reduce((n, g) => n + (groups[g]?.list ?? []).filter((l) => l.floors > 0).length, 0);
1082
+ /** The lookup that pages a group, as the shape and the form's refusal both name it, so the two cannot drift. */
1083
+ export const groupPageCall = (g) => `band_lookup {"group":"${g}"}`;
1084
+
1085
+ function renderGroupsMd(md, groups, rows = []) {
1086
+ md.push("", "## The band in the lawyer's order — for the client's goods first", "");
1087
+ if (groups.dominant_term && groups.manifest_dominant && normKey(groups.dominant_term) !== normKey(groups.manifest_dominant))
1088
+ md.push(`Sorted on «${cell(groups.dominant_term)}», the word the register's goods-narrowed search asked for (the variants stage named «${cell(groups.manifest_dominant)}»).`, "");
1089
+ // The words the goods test read, and where they came from: the client's product words, or, where the
1090
+ // variants stage named none, the words the register search was narrowed by. A fallback is said, never silent.
1091
+ const tested = groups.goods_test ?? { source: "none", words: [] };
1092
+ const named = tested.words?.length ? `: ${tested.words.map((w) => `«${cell(w)}»`).join(", ")}` : "";
1093
+ md.push(tested.source === "product_words"
1094
+ ? `The goods test reads each record's goods for the client's product words${named}.`
1095
+ : tested.source === "goods_words"
1096
+ ? `No product words were given, so the goods test reads each record's goods for the words the register search was narrowed by${named}. They include words other filings use for the same goods, so "for the client's goods" below is wider than the client's own product.`
1097
+ : "No goods words were given, so no record was tested on its goods.", "");
1098
+ if (groups.territories_unread?.length)
1099
+ md.push(`The order names ${groups.territories_unread.map((t) => `«${cell(t)}»`).join(", ")}, which the engine cannot read as a territory, so the list holds every register.`, "");
1100
+ else if (groups.territories?.length) md.push(`The list holds the order's territories: ${groups.territories.join(", ")}.`, "");
1101
+ if (groups.markets?.length) md.push(`The client's markets: ${groups.markets.join(", ")}.`, "");
1102
+ const total = groups.goods_words_total || 0;
1103
+ const word = groups.dominant_term || groups.manifest_dominant;
1104
+ const carrying = word ? `carrying «${cell(word)}»` : "carrying the searched word";
1105
+ const heading = (g) => (g === "c" ? `(c) The same family as the mark, or ${carrying}, for the client's goods` : GROUP_HEADINGS[g]);
1106
+ const unread = (l) => (tested.source === "none" ? "—" : l.goods_on_file ? "on file, not readable" : "no goods on file");
1107
+ const words = (l) => (l.goods_words == null ? unread(l) : `${fmtN(l.goods_words.length)} of ${fmtN(total)}${l.goods_words.length ? `: ${l.goods_words.join(", ")}` : ""}`);
1108
+ const count = (grp) => `${fmtN(grp.records)} records in ${fmtN(grp.lines)} lines from ${fmtN(grp.owners)} owners`;
1109
+ const where = (l) => `${l.territories.join(",")}${l.outside ? " (outside the order)" : ""}`;
1110
+ const row = (l) => `| ${cell(l.mark)} | ${cell(l.owner_name)} | ${cell(l.classes.join(","))} | ${fmtN(l.live)} of ${fmtN(l.records)} | ${cell(where(l))} | ${cell(words(l))} | ${l.floors > 0 ? "yes" : "no"} | ${cell(l.record_id)} |`;
1111
+ const head = ["| mark | owner | classes | live | territories | goods words | floor | record |", "|---|---|---|---|---|---|---|---|"];
1112
+ // A crowded group's own words for what it holds, in the crowded-field method's sentence (ruled 2026-10-12).
1113
+ const crowd = { b: "identical marks in the ordered classes for other goods than the client's",
1114
+ c: "marks of the same family as the mark, or carrying the searched word, for the client's goods", d: "other marks in the ordered classes for the client's goods" };
1115
+ const firstPage = `The first ${fmtN(groups.first_page)} lines are below`;
1116
+ for (const g of ["a", "b", "c", "d"]) {
1117
+ const grp = groups[g];
1118
+ md.push(`### ${heading(g)}`, "");
1119
+ if (!grp.lines) { md.push("(none)", ""); continue; }
1120
+ const crowded = grp.records >= groups.crowded_at;
1121
+ const readA = "These are the identical and near-identical marks live in the ordered classes for the client's goods: read every line.";
1122
+ md.push(g === "a"
1123
+ ? (crowded
1124
+ ? `${count(grp)}. ${readA} ${firstPage}; page every other line with ${groupPageCall(g)}. Every floor here is placed or set aside by name; this group's row covers only its other lines.`
1125
+ : grp.lines <= READ_ALL_LINES ? `${count(grp)}. Fifty? Read them all.` : `${count(grp)}. ${readA}`)
1126
+ : crowded
1127
+ ? `${count(grp)}. A thousand or more? The field is crowded. These are ${crowd[g]}. Read the first page, carry forward any line a lawyer would raise, and set the rest aside in this group's one row with the count: the client is told the field is crowded. ${firstPage}; page with ${groupPageCall(g)} only where a line needs reading.`
1128
+ : grp.lines <= READ_ALL_LINES
1129
+ ? `${count(grp)}. Fifty? Read them all.`
1130
+ : `${count(grp)}. A few hundred? Sort by the client's goods and markets, read the near ones first, list the rest.`, "");
1131
+ md.push(...head);
1132
+ for (const l of crowded ? grp.list.slice(0, groups.first_page) : grp.list) { rows.push([md.length, l.key]); md.push(row(l)); }
1133
+ md.push("");
1134
+ }
1135
+ const outside = groups.outside?.lines ? `, ${fmtN(groups.outside.lines)} lines of them held only outside the ordered territories` : "";
1136
+ // The rest is read by the size rule too: listed under a thousand records, by its first page at a thousand or
1137
+ // more, where its one row stands on that page read and the count.
1138
+ const rest = groups.rest;
1139
+ const restCrowded = rest.records >= groups.crowded_at;
1140
+ md.push(`**The rest: ${count(rest)}${outside}** — every record in none of (a) to (d) above. ${!rest.lines ? "Write down what you set aside and why: one row, with this count."
1141
+ : restCrowded
1142
+ ? `A thousand or more? The field is crowded. Read the first page, carry forward any line a lawyer would raise, and set the rest aside in this group's one row with the count: the client is told the field is crowded. ${firstPage}; page with ${groupPageCall("rest")} only where a line needs reading.`
1143
+ : `${rest.lines <= READ_ALL_LINES ? "Fifty? Read them all." : "A few hundred? Sort by the client's goods and markets, read the near ones first, list the rest."} Write down what you set aside and why: one row, with this count.`}`);
1144
+ if (rest.lines) {
1145
+ md.push("", ...head);
1146
+ for (const l of restCrowded ? rest.list.slice(0, groups.first_page) : rest.list) { rows.push([md.length, l.key]); md.push(row(l)); }
1147
+ }
1148
+ if (groups.no_goods.lines) {
1149
+ // The lines the goods test could not place, read by the same size rule (ruled 2026-10-12): under a thousand
1150
+ // records, a fate per line by name, opened where goods are on file; at a thousand or more, the first page,
1151
+ // and one row on the rest whose goods are unreadable in a crowded field. A floor is never set aside on
1152
+ // its goods, so a floor here is placed or set aside on its status or territory.
1153
+ const ng = groups.no_goods;
1154
+ const crowded = ng.records >= groups.crowded_at;
1155
+ const lead = tested.source === "none"
1156
+ ? `**Not tested on goods: ${count(ng)}.** No goods words were given, so the goods test could not place them.`
1157
+ : `**Goods the test could not read: ${count(ng)}.**`;
1158
+ md.push("", crowded
1159
+ ? `${lead} A thousand or more? ${firstPage}, the floors first, then the live ones in the ordered classes. Carry forward any line a lawyer would raise, and set the rest aside in this list's one row with the count, on the ground that their goods are unreadable and the field is crowded. A floor here is placed or set aside by name on its status or its territory; this row never covers it. Where more floors are counted than this page shows, page the rest with ${groupPageCall("no_goods")}; they come first.`
1160
+ : `${lead} ${ng.lines <= READ_ALL_LINES ? "Fifty? Read them all." : "A few hundred? Read the live ones in the ordered classes first."} Each line takes its own fate by name; no group row covers these lines.${tested.source === "none" ? "" : ' A line marked "on file, not readable" has goods on file: open its record with band_record before its fate. A line marked "no goods on file" has nothing to open.'} A floor here is placed, or set aside by name on its status or its territory; any other line may be set aside on the ground that its goods are unknown.`, "");
1161
+ md.push(...head);
1162
+ for (const l of crowded ? ng.list.slice(0, groups.first_page) : ng.list) { rows.push([md.length, l.key]); md.push(row(l)); }
1163
+ }
1164
+ }
1165
+
814
1166
  export function renderBandShapeMd(shape, { positions = null } = {}) {
815
- const md = [];
1167
+ return bandShapeMdLines(shape, { positions }).md;
1168
+ }
1169
+
1170
+ /**
1171
+ * The readable shape, and the md line each listed line is printed on: `{ md, lines: {lineKey: lineNumber} }`,
1172
+ * 0-based over `md.split("\n")`. The band server reads `lines` from the shape's data to log which lines a
1173
+ * part it serves holds, so a group's row is checked against what the step read, not against what was
1174
+ * printed. PURE.
1175
+ */
1176
+ export function bandShapeMdLines(shape, { positions = null } = {}) {
1177
+ const md = [], rows = [];
816
1178
  md.push("# Band shape — the register band, mechanically mapped");
817
1179
  md.push("");
818
1180
  md.push("Deterministic projection of the complete merged register band (code-derived after every band");
@@ -829,17 +1191,17 @@ export function renderBandShapeMd(shape, { positions = null } = {}) {
829
1191
  md.push("|---|---|");
830
1192
  for (const t of SHAPE_TIERS) md.push(`| ${t} | ${fmtN(shape.totals.by_tier[t])} |`);
831
1193
 
1194
+ // THE FLOORS ARE MARKED ON THEIR LINES, NOT LISTED A SECOND TIME (ruled 2026-10-12). The band in the ruled order
1195
+ // carries every floor as a line, and a per-record list of the same records was most of a crowded shape the
1196
+ // step had to read whole before it could act. The shape's data keeps every floor for the floor duty, and
1197
+ // `band_lookup` returns any floor record on request.
832
1198
  const floors = shape.floors.in_class_identical_or_near;
833
- md.push("", "## Floors — every LIVE in-class identical / near-identical record (complete list, never sampled)", "");
834
- if (!floors.length) {
835
- md.push("(none — no live in-class record classified identical or near-identical to any target)");
836
- } else {
837
- md.push("| mark | tier | target | classes | status | owner | registry | record |");
838
- md.push("|---|---|---|---|---|---|---|---|");
839
- for (const f of floors) {
840
- md.push(`| ${cell(f.mark_text)} | ${f.tier} | ${cell(f.matched_target)} | ${cell(f.classes.join(","))} | ${cell(f.status)} | ${cell(f.owner_name)} | ${cell(f.registry)} | ${cell(f.record_id)} |`);
841
- }
842
- }
1199
+ const floorLines = shape.groups ? floorLineCount(shape.groups) : null;
1200
+ md.push("", floors.length
1201
+ ? `**Floors: ${fmtN(floors.length)} records${floorLines != null ? ` in ${fmtN(floorLines)} lines` : ""}** — every live in-class identical or near-identical record, each marked on its line in the band below.`
1202
+ : "**Floors:** none — no live in-class record classified identical or near-identical to any target.");
1203
+
1204
+ if (shape.groups) renderGroupsMd(md, shape.groups, rows);
843
1205
 
844
1206
  topTable(md, "By Nice class", ["class", "records"], shape.by_class);
845
1207
  topTable(md, "By status", ["status", "records"], shape.by_status);
@@ -929,5 +1291,12 @@ export function renderBandShapeMd(shape, { positions = null } = {}) {
929
1291
  if ((b.records ?? b.sample ?? b.zones ?? []).length > 20) md.push(` - … and ${fmtN((b.records ?? b.sample ?? b.zones).length - 20)} more (band-shape.json carries all)`);
930
1292
  }
931
1293
  md.push("");
932
- return md.join("\n");
1294
+ // A pushed element may hold line breaks of its own, so each row's line number counts the breaks before it.
1295
+ const lines = {};
1296
+ let at = 0, line = 0;
1297
+ for (const [index, key] of rows) {
1298
+ for (; at < index; at++) line += 1 + (md[at].match(/\n/g)?.length ?? 0);
1299
+ if (key != null) lines[key] = line;
1300
+ }
1301
+ return { md: md.join("\n"), lines };
933
1302
  }
@@ -151,6 +151,7 @@ export function renderClearanceVariants(model, scopeRows) {
151
151
  // The prose copy restates exactly what the structured sibling holds — a reader of the manifest must
152
152
  // be able to see which words the register search was narrowed to without opening the JSON.
153
153
  if (model.goods_words?.length) out.push("### Goods words the register search is narrowed to", "", ...model.goods_words.map((w) => `- ${w}`), "");
154
+ if (model.product_words?.length) out.push("### The client's product words", "", ...model.product_words.map((w) => `- ${w}`), "");
154
155
  // — the search floor, on the human surface because a reader auditing the run has to see what was
155
156
  // obliged as well as what was done. Rendered ONLY when designated: an empty section would read as a
156
157
  // floor of nothing rather than as no floor, and those are the two states this mechanism must keep apart.
@@ -178,7 +179,7 @@ export function renderClearanceVariants(model, scopeRows) {
178
179
  */
179
180
  /** The shape this tool declares, at every depth — what the ACCEPTOR enforces. */
180
181
  const DECLARED = Object.freeze({
181
- "": ["mark", "dominant_element", "elements", "variants", "incumbent_classes", "search_floor", "watchlist_owners", "goods_words", "scope_ledger"],
182
+ "": ["mark", "dominant_element", "elements", "variants", "incumbent_classes", "search_floor", "watchlist_owners", "goods_words", "product_words", "scope_ledger"],
182
183
  elements: ["value", "kind", "famous_mark_flag"],
183
184
  variants: ["value", "category", "rationale", "romanization"],
184
185
  scope_ledger: ["layer", "item", "status", "reason", "reopen_trigger"],
@@ -218,6 +219,7 @@ export function mergeClearanceVariantsCall(stored, received) {
218
219
  // correct one part, and a seat that sends only that part, would otherwise delete the goods words
219
220
  // — and the loss reads as "this matter has none" rather than as a partial call.
220
221
  goods_words: keepIfAbsent(received?.goods_words, base.goods_words),
222
+ product_words: keepIfAbsent(received?.product_words, base.product_words),
221
223
  };
222
224
  }
223
225
 
@@ -234,6 +236,8 @@ export function acceptClearanceVariants(params) {
234
236
  // back a key nothing in this chain declares: the tool's schema offers the slot, and this is what
235
237
  // moves the answer from the call into the manifest the compiler reads.
236
238
  goods_words: params?.goods_words,
239
+ // — the client's product words, the particular goods the band shape groups on (ruled 2026-10-12).
240
+ product_words: params?.product_words,
237
241
  // — the search-floor axes. Validated by the model parser (closed against REGISTER_AXES), not
238
242
  // here, so there is ONE definition of what a floor may name.
239
243
  search_floor: params?.search_floor,