clearotron 0.4.0-beta.2 → 0.4.0-beta.3

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 (49) hide show
  1. package/THIRD-PARTY-NOTICES.md +5 -5
  2. package/build-info.json +2 -2
  3. package/driver/CHANGELOG.md +9 -0
  4. package/driver/coverage-form.mjs +6 -0
  5. package/driver/coverage-ledger.mjs +14 -2
  6. package/driver/driver.config.mjs +24 -7
  7. package/driver/engine/mcp/stdio-server.mjs +12 -1
  8. package/driver/package.json +1 -1
  9. package/driver/pipeline-knockout.mjs +29 -8
  10. package/driver/pipeline.mjs +58 -8
  11. package/driver/progress.mjs +6 -1
  12. package/driver/publish/knockout.mjs +16 -3
  13. package/driver/publish/render-knockout.mjs +38 -11
  14. package/driver/record-carry.mjs +16 -0
  15. package/driver/reference-strip-signatures.mjs +11 -1
  16. package/driver/register-plan.mjs +7 -3
  17. package/driver/register-served.mjs +91 -0
  18. package/driver/remedy-accounting.mjs +38 -9
  19. package/driver/score-redaction.mjs +389 -20
  20. package/driver/skills/clearance-register/SKILL.md +0 -8
  21. package/driver/skills/clearance-register/digest.md +1 -1
  22. package/driver/skills/clearance-register/providers/corsearch.md +1 -1
  23. package/driver/skills/clearance-register/register-recipes.md +3 -9
  24. package/driver/skills/clearance-search/SKILL.md +2 -2
  25. package/driver/skills/clearance-search/phase2-execution.md +1 -1
  26. package/driver/skills/knockout-assess/SKILL.md +2 -0
  27. package/driver/stages-knockout.mjs +22 -0
  28. package/driver/suite-census.json +120 -18
  29. package/driver/unit-inventory.mjs +38 -43
  30. package/mcp-server/CHANGELOG.md +4 -0
  31. package/mcp-server/package.json +1 -1
  32. package/node_modules/brace-expansion/index.js +78 -22
  33. package/node_modules/brace-expansion/package.json +1 -1
  34. package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
  35. package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
  36. package/package.json +1 -1
  37. package/portal-ui/package.json +2 -2
  38. package/providers/jx/src/core.js +0 -1
  39. package/providers/jx/src/judge.js +0 -1
  40. package/providers/jx/src/nativeread.js +0 -1
  41. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  42. package/providers/oauth-mcp-bridge/package.json +1 -1
  43. package/providers/signa/src/core.js +37 -14
  44. package/scripts/e2e-first-time.mjs +12 -4
  45. package/scripts/e2e-scenario-ops.mjs +25 -2
  46. package/scripts/e2e.mjs +113 -15
  47. package/scripts/mint-reference-strip-backlog.mjs +36 -2
  48. package/scripts/score.mjs +102 -29
  49. package/shared/identifier-scan.mjs +31 -1
@@ -23,6 +23,26 @@ var EXPANSION_MAX = 100000
23
23
  // characters) so legitimate input is unaffected.
24
24
  var EXPANSION_MAX_LENGTH = 4000000
25
25
 
26
+ // `expand` recurses once per level of brace *nesting* - both when expanding a
27
+ // set's comma members and when re-wrapping a set whose body is a single part.
28
+ // The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one
29
+ // level per chained group), which left nesting depth unbounded: about 3,100
30
+ // levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack
31
+ // and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser
32
+ // will follow nesting. It sits far above any realistic pattern and well below
33
+ // the depth at which the stack runs out.
34
+ var EXPANSION_MAX_DEPTH = 1000
35
+
36
+ // Bash keeps a quirk where a brace group followed by a comma set still expands
37
+ // (`{a},b}`). The parser implements it by rewriting the string and restarting
38
+ // the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`
39
+ // full passes over a string that itself grows by one `escClose` sentinel each
40
+ // time - quadratic in `n`, with a ~26x constant from the sentinel's length.
41
+ // 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27
42
+ // seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many
43
+ // times the scan may restart. Real `{a},b}` input needs a handful.
44
+ var EXPANSION_MAX_REWRITES = 1000
45
+
26
46
  function numeric(str) {
27
47
  return parseInt(str, 10) == str
28
48
  ? parseInt(str, 10)
@@ -46,34 +66,54 @@ function unescapeBraces(str) {
46
66
  }
47
67
 
48
68
 
69
+ // Like `target.push(...items)` but doesn't overflow the stack
70
+ function pushAll(target, items) {
71
+ for (var i = 0; i < items.length; i++) {
72
+ target.push(items[i]);
73
+ }
74
+ }
75
+
49
76
  // Basically just str.split(","), but handling cases
50
77
  // where we have nested braced sections, which should be
51
78
  // treated as individual members, like {a,{b,c},d}
52
79
  function parseCommaParts(str) {
53
- if (!str)
54
- return [''];
55
-
56
80
  var parts = [];
57
- var m = balanced('{', '}', str);
58
81
 
59
- if (!m)
60
- return str.split(',');
82
+ // Walk the brace groups iteratively. Recursing on `post` once per group let a
83
+ // chain of them exhaust the stack - the parsing-side counterpart to
84
+ // the `expand` overflow fixed for CVE-2026-14257, and not something `max` or
85
+ // `maxLength` can bound, since it happens before expansion.
86
+ //
87
+ // The part the next chunk continues
88
+ var carry = '';
61
89
 
62
- var pre = m.pre;
63
- var body = m.body;
64
- var post = m.post;
65
- var p = pre.split(',');
90
+ for (;;) {
91
+ var m = balanced('{', '}', str);
66
92
 
67
- p[p.length-1] += '{' + body + '}';
68
- var postParts = parseCommaParts(post);
69
- if (post.length) {
70
- p[p.length-1] += postParts.shift();
71
- p.push.apply(p, postParts);
72
- }
93
+ if (!m) {
94
+ var tail = str.split(',');
95
+ tail[0] = carry + tail[0];
96
+ pushAll(parts, tail);
97
+ return parts;
98
+ }
73
99
 
74
- parts.push.apply(parts, p);
100
+ var pre = m.pre;
101
+ var body = m.body;
102
+ var post = m.post;
103
+ var p = pre.split(',');
75
104
 
76
- return parts;
105
+ p[0] = carry + p[0];
106
+ p[p.length-1] += '{' + body + '}';
107
+
108
+ if (!post.length) {
109
+ pushAll(parts, p);
110
+ return parts;
111
+ }
112
+
113
+ carry = p.pop();
114
+ pushAll(parts, p);
115
+ str = post;
116
+ }
77
117
  }
78
118
 
79
119
  function expandTop(str, options) {
@@ -83,6 +123,8 @@ function expandTop(str, options) {
83
123
  options = options || {};
84
124
  var max = options.max == null ? EXPANSION_MAX : options.max;
85
125
  var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength;
126
+ var maxDepth = options.maxDepth == null ? EXPANSION_MAX_DEPTH : options.maxDepth;
127
+ var maxRewrites = options.maxRewrites == null ? EXPANSION_MAX_REWRITES : options.maxRewrites;
86
128
 
87
129
  // I don't know why Bash 4.3 does this, but it does.
88
130
  // Anything starting with {} will have the first two bytes preserved
@@ -94,7 +136,7 @@ function expandTop(str, options) {
94
136
  str = '\\{\\}' + str.substr(2);
95
137
  }
96
138
 
97
- return expand(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
139
+ return expand(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces);
98
140
  }
99
141
 
100
142
  function identity(e) {
@@ -218,8 +260,18 @@ function expand(
218
260
  str,
219
261
  max,
220
262
  maxLength,
263
+ maxDepth,
264
+ depth,
265
+ maxRewrites,
221
266
  isTop
222
267
  ) {
268
+ // Too deeply nested to keep following: treat the rest as literal, the same
269
+ // way a group that cannot expand is already handled. Truncating rather than
270
+ // throwing keeps expansion total, matching `max` and `maxLength`.
271
+ if (depth > maxDepth) {
272
+ return [str];
273
+ }
274
+
223
275
  // Consume the string's top-level brace groups left to right, threading a
224
276
  // running set of combined prefixes (`acc`). Expanding the tail iteratively -
225
277
  // rather than recursing on `m.post` once per group - keeps the native stack
@@ -240,6 +292,9 @@ function expand(
240
292
  // `accBase[a]` records how much of `acc[a]` predates the current run;
241
293
  // `combine` treats an expansion as empty when it adds nothing past that.
242
294
  var accBase = [0]
295
+ // How many times the `{a},b}` rewrite below has restarted the scan. Each pass
296
+ // re-reads the whole string, so leaving this unbounded is quadratic.
297
+ var rewrites = 0
243
298
  var dropEmpties = false
244
299
  var firstGroup = true
245
300
  var nextBase
@@ -271,7 +326,8 @@ function expand(
271
326
  var isOptions = m.body.indexOf(',') >= 0;
272
327
  if (!isSequence && !isOptions) {
273
328
  // {a},b}
274
- if (m.post.match(/,(?!,).*\}/)) {
329
+ if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) {
330
+ rewrites++;
275
331
  str = m.pre + '{' + m.body + escClose + m.post;
276
332
  // The rewritten string is expanded as if it were a fresh top-level one,
277
333
  // so start a new empty-drop run: anchor the baseline at what `acc`
@@ -310,7 +366,7 @@ function expand(
310
366
  var n = parseCommaParts(m.body);
311
367
  if (n.length === 1 && n[0] !== undefined) {
312
368
  // x{{a,b}}y ==> x{a}y x{b}y
313
- n = expand(n[0], max, maxLength, false).map(embrace);
369
+ n = expand(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace);
314
370
  //XXX is this necessary? Can't seem to hit it in tests.
315
371
  /* c8 ignore start */
316
372
  if (n.length === 1) {
@@ -349,7 +405,7 @@ function expand(
349
405
  values = []
350
406
  var valuesLength = 0
351
407
  outer: for (var j = 0; j < n.length; j++) {
352
- var expanded = expand(n[j], max, maxLength, false)
408
+ var expanded = expand(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false)
353
409
  for (var k = 0; k < expanded.length; k++) {
354
410
  var v = expanded[k]
355
411
  if (dropsEmpties && !v) continue
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "brace-expansion",
3
3
  "description": "Brace expansion as known from sh/bash",
4
- "version": "1.1.18",
4
+ "version": "1.1.21",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git://github.com/juliangruber/brace-expansion.git"
@@ -22,6 +22,26 @@ var EXPANSION_MAX = 100000
22
22
  // characters) so legitimate input is unaffected.
23
23
  var EXPANSION_MAX_LENGTH = 4000000
24
24
 
25
+ // `expand` recurses once per level of brace *nesting* - both when expanding a
26
+ // set's comma members and when re-wrapping a set whose body is a single part.
27
+ // The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one
28
+ // level per chained group), which left nesting depth unbounded: about 3,100
29
+ // levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack
30
+ // and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser
31
+ // will follow nesting. It sits far above any realistic pattern and well below
32
+ // the depth at which the stack runs out.
33
+ var EXPANSION_MAX_DEPTH = 1000
34
+
35
+ // Bash keeps a quirk where a brace group followed by a comma set still expands
36
+ // (`{a},b}`). The parser implements it by rewriting the string and restarting
37
+ // the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`
38
+ // full passes over a string that itself grows by one `escClose` sentinel each
39
+ // time - quadratic in `n`, with a ~26x constant from the sentinel's length.
40
+ // 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27
41
+ // seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many
42
+ // times the scan may restart. Real `{a},b}` input needs a handful.
43
+ var EXPANSION_MAX_REWRITES = 1000
44
+
25
45
  function numeric(str) {
26
46
  return parseInt(str, 10) == str
27
47
  ? parseInt(str, 10)
@@ -45,34 +65,54 @@ function unescapeBraces(str) {
45
65
  }
46
66
 
47
67
 
68
+ // Like `target.push(...items)` but doesn't overflow the stack
69
+ function pushAll(target, items) {
70
+ for (var i = 0; i < items.length; i++) {
71
+ target.push(items[i]);
72
+ }
73
+ }
74
+
48
75
  // Basically just str.split(","), but handling cases
49
76
  // where we have nested braced sections, which should be
50
77
  // treated as individual members, like {a,{b,c},d}
51
78
  function parseCommaParts(str) {
52
- if (!str)
53
- return [''];
54
-
55
79
  var parts = [];
56
- var m = balanced('{', '}', str);
57
80
 
58
- if (!m)
59
- return str.split(',');
81
+ // Walk the brace groups iteratively. Recursing on `post` once per group let a
82
+ // chain of them exhaust the stack - the parsing-side counterpart to
83
+ // the `expand` overflow fixed for CVE-2026-14257, and not something `max` or
84
+ // `maxLength` can bound, since it happens before expansion.
85
+ //
86
+ // The part the next chunk continues
87
+ var carry = '';
60
88
 
61
- var pre = m.pre;
62
- var body = m.body;
63
- var post = m.post;
64
- var p = pre.split(',');
89
+ for (;;) {
90
+ var m = balanced('{', '}', str);
65
91
 
66
- p[p.length-1] += '{' + body + '}';
67
- var postParts = parseCommaParts(post);
68
- if (post.length) {
69
- p[p.length-1] += postParts.shift();
70
- p.push.apply(p, postParts);
71
- }
92
+ if (!m) {
93
+ var tail = str.split(',');
94
+ tail[0] = carry + tail[0];
95
+ pushAll(parts, tail);
96
+ return parts;
97
+ }
98
+
99
+ var pre = m.pre;
100
+ var body = m.body;
101
+ var post = m.post;
102
+ var p = pre.split(',');
103
+
104
+ p[0] = carry + p[0];
105
+ p[p.length-1] += '{' + body + '}';
72
106
 
73
- parts.push.apply(parts, p);
107
+ if (!post.length) {
108
+ pushAll(parts, p);
109
+ return parts;
110
+ }
74
111
 
75
- return parts;
112
+ carry = p.pop();
113
+ pushAll(parts, p);
114
+ str = post;
115
+ }
76
116
  }
77
117
 
78
118
  function expandTop(str, options) {
@@ -82,6 +122,8 @@ function expandTop(str, options) {
82
122
  options = options || {};
83
123
  var max = options.max == null ? EXPANSION_MAX : options.max;
84
124
  var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength;
125
+ var maxDepth = options.maxDepth == null ? EXPANSION_MAX_DEPTH : options.maxDepth;
126
+ var maxRewrites = options.maxRewrites == null ? EXPANSION_MAX_REWRITES : options.maxRewrites;
85
127
 
86
128
  // I don't know why Bash 4.3 does this, but it does.
87
129
  // Anything starting with {} will have the first two bytes preserved
@@ -93,7 +135,7 @@ function expandTop(str, options) {
93
135
  str = '\\{\\}' + str.substr(2);
94
136
  }
95
137
 
96
- return expand(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
138
+ return expand(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces);
97
139
  }
98
140
 
99
141
  function embrace(str) {
@@ -205,8 +247,18 @@ function expand(
205
247
  str,
206
248
  max,
207
249
  maxLength,
250
+ maxDepth,
251
+ depth,
252
+ maxRewrites,
208
253
  isTop
209
254
  ) {
255
+ // Too deeply nested to keep following: treat the rest as literal, the same
256
+ // way a group that cannot expand is already handled. Truncating rather than
257
+ // throwing keeps expansion total, matching `max` and `maxLength`.
258
+ if (depth > maxDepth) {
259
+ return [str];
260
+ }
261
+
210
262
  // Consume the string's top-level brace groups left to right, threading a
211
263
  // running set of combined prefixes (`acc`). Expanding the tail iteratively -
212
264
  // rather than recursing on `m.post` once per group - keeps the native stack
@@ -219,6 +271,9 @@ function expand(
219
271
  // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop
220
272
  // is on the final strings, so it is applied to whichever `combine` produces
221
273
  // them (the one with no brace set left in the tail).
274
+ // How many times the `{a},b}` rewrite below has restarted the scan. Each pass
275
+ // re-reads the whole string, so leaving this unbounded is quadratic.
276
+ var rewrites = 0
222
277
  var dropEmpties = false
223
278
  var firstGroup = true
224
279
 
@@ -254,7 +309,8 @@ function expand(
254
309
  var isOptions = m.body.indexOf(',') >= 0;
255
310
  if (!isSequence && !isOptions) {
256
311
  // {a},b}
257
- if (m.post.match(/,(?!,).*\}/)) {
312
+ if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) {
313
+ rewrites++;
258
314
  str = m.pre + '{' + m.body + escClose + m.post;
259
315
  isTop = true;
260
316
  continue;
@@ -282,7 +338,7 @@ function expand(
282
338
  var n = parseCommaParts(m.body);
283
339
  if (n.length === 1 && n[0] !== undefined) {
284
340
  // x{{a,b}}y ==> x{a}y x{b}y
285
- n = expand(n[0], max, maxLength, false).map(embrace);
341
+ n = expand(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace);
286
342
  //XXX is this necessary? Can't seem to hit it in tests.
287
343
  /* c8 ignore start */
288
344
  if (n.length === 1) {
@@ -316,7 +372,7 @@ function expand(
316
372
  values = []
317
373
  var valuesLength = 0
318
374
  outer: for (var j = 0; j < n.length; j++) {
319
- var expanded = expand(n[j], max, maxLength, false)
375
+ var expanded = expand(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false)
320
376
  for (var k = 0; k < expanded.length; k++) {
321
377
  var v = expanded[k]
322
378
  if (dropsEmpties && !v) continue
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "brace-expansion",
3
3
  "description": "Brace expansion as known from sh/bash",
4
- "version": "2.1.4",
4
+ "version": "2.1.7",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git://github.com/juliangruber/brace-expansion.git"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.4.0-beta.2",
4
+ "version": "0.4.0-beta.3",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.0-beta.2",
5
+ "version": "0.4.0-beta.3",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -24,6 +24,6 @@
24
24
  "@types/react-dom": "^19.3.0",
25
25
  "@vitejs/plugin-react": "^6.1.1",
26
26
  "typescript": "^7.0.2",
27
- "vite": "^8.3.0"
27
+ "vite": "^8.3.1"
28
28
  }
29
29
  }
@@ -120,7 +120,6 @@ export function buildCandidateRequest({ mark, productContext = "", lane = "zh",
120
120
  model,
121
121
  max_tokens: 2048, // 8 Han candidates + rationales need headroom; truncation is checked either way
122
122
  tools: [CANDIDATE_TOOL],
123
- tool_choice: { type: "tool", name: CANDIDATE_TOOL.name }, // FORCED tool answer; shape validated at parse
124
123
  messages: [{ role: "user", content: promptFor({ mark: String(mark).trim(), productContext: String(productContext ?? "").trim() }) }],
125
124
  };
126
125
  }
@@ -79,7 +79,6 @@ export function buildJudgeRequest({ mark, hits, model = DEFAULT_MODEL }) {
79
79
  model,
80
80
  max_tokens: 4096, // 40 one-clause judgments fit well inside; truncation checked either way
81
81
  tools: [JUDGE_TOOL],
82
- tool_choice: { type: "tool", name: JUDGE_TOOL.name }, // FORCED tool answer; shape validated at parse
83
82
  messages: [{ role: "user", content: prompt }],
84
83
  };
85
84
  }
@@ -81,7 +81,6 @@ export function buildNativereadRequest({ mark, lane = "zh", payload, model = DEF
81
81
  model,
82
82
  max_tokens: 4096, // 12 grounded items with grounds quotes need headroom; truncation checked either way
83
83
  tools: [NATIVEREAD_TOOL],
84
- tool_choice: { type: "tool", name: NATIVEREAD_TOOL.name }, // FORCED tool answer; shape validated at parse
85
84
  messages: [{ role: "user", content: prompt }],
86
85
  };
87
86
  }
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.0-beta.3
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.0-beta.2
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0-beta.3",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -319,7 +319,14 @@ export function normalizeRecord(rec, officeHint = null) {
319
319
  registrationNumber: rec.registration_number ?? rec.ir_number ?? null,
320
320
  applicationDate: toIso(rec.filing_date),
321
321
  registrationDate: toIso(rec.registration_date),
322
- expiryDate: toIso(rec.expiry_date),
322
+ // — THE OFFICE'S DATE FIRST, THEN SIGNA'S OWN (their 27 September release). That release split what
323
+ // an office published from what Signa computes: `expiry_date` now carries only the published date and
324
+ // is EMPTY on USPTO records, where the computed one moved to `derived.expiry_date`. Read one and the
325
+ // field goes quietly null on every US record — measured on the record bodies of the run that
326
+ // straddled the release: 4 of 9 US records carried it only under `derived`, and all 4 normalised to
327
+ // null here. Where both are present they are identical (12 of 12 on that run), so the office's value
328
+ // first costs nothing and keeps the published date authoritative when there is one.
329
+ expiryDate: toIso(rec.expiry_date ?? rec.derived?.expiry_date),
323
330
  statusClass: statusClassOf(rec), // live | dead | unknown — authoritative for the gates
324
331
  statusText: pickStatusText(rec),
325
332
  markText: rec.mark_text ?? null,
@@ -353,16 +360,31 @@ export function normalizeRecord(rec, officeHint = null) {
353
360
  // "absent from the register", and `nativeScriptIndex` is settled from the record, not from this.
354
361
  markTextScript: rec.mark_text_script ?? null,
355
362
  markTextLanguage: rec.mark_text_language ?? null,
356
- renewalDueDate: toIso(rec.renewal_due_date),
363
+ // The same split as `expiryDate` above, and the vendor names both fields in it. UNEXERCISED ON THE
364
+ // DATA WE HOLD: of the 22 record bodies from the run that straddled the release, none carried a
365
+ // renewal date in either place, so the fallback recovers nothing we can point at. It is here because
366
+ // the register says this field moved with expiry, and reading one place is how the expiry loss
367
+ // happened; the arm for it is therefore synthetic and says so.
368
+ renewalDueDate: toIso(rec.renewal_due_date ?? rec.derived?.renewal_due_date),
357
369
  publicationDate: toIso(rec.publication_date),
358
370
  priorityDate: toIso(rec.priority_date),
359
371
  terminationDate: toIso(rec.termination_date),
360
- // Opposition data IS on the record now: `opposition_window` rides every search row and
361
- // `proceedings_count` the full record. The per-proceeding detail behind
362
- // GET /v1/trademarks/{id}/proceedings is still an unwired tool — so the window and the count are
363
- // reported, and the absence of detail is stated rather than left to look like an absence of
364
- // proceedings.
365
- oppositionWindow: rec.opposition_window ?? null,
372
+ // Opposition data IS on the record, and NOT where this used to look. The register reports the window
373
+ // under its computed object, and as an OBJECT rather than a scalar:
374
+ // `{supported, window_opens, window_closes, …}`, or `{supported: false, reason, rule_id}` where the
375
+ // stage cannot be opposed. `proceedings_count` is still the office's own, top-level.
376
+ //
377
+ // THE OLD READ RETURNED NULL ON EVERY RECORD WE HOLD, and the comment here asserted otherwise — that
378
+ // the window "rides every search row". Measured over all four saved runs on this register: 0 of 6,
379
+ // 0 of 5, 0 of 7 and 0 of 22 bodies carried it top-level, and 6, 3, 7 and 22 carried it under the
380
+ // computed object. So the earliest evidence we have already reads the new place, eleven days before
381
+ // the release the register attributes the move to. Which of the two dates is right does not change
382
+ // what to read; it does mean this was not reported missing for as long as it was missing.
383
+ //
384
+ // The per-proceeding detail behind GET /v1/trademarks/{id}/proceedings is still an unwired tool, so
385
+ // the window and the count are reported and the absence of detail is stated rather than left to look
386
+ // like an absence of proceedings.
387
+ oppositionWindow: rec.opposition_window ?? rec.derived?.opposition_window ?? null,
366
388
  proceedingsCount: rec.proceedings_count ?? null,
367
389
  oppositions: null,
368
390
  _provenance: { opposition: rec.proceedings_count != null ? `count=${rec.proceedings_count} from the record; per-proceeding detail via /proceedings, tool not wired` : "per-proceeding detail via /proceedings, tool not wired" },
@@ -485,8 +507,8 @@ export function normalizeSearchResponse(body, echoQuery) {
485
507
  //
486
508
  // THIS HEADER USED TO ADVERTISE AN ENVIRONMENT VARIABLE, `CLAWDI_SIGNA_MOCK`. There was never such a
487
509
  // variable: two occurrences in the whole tree, both comments, zero reads. Following the line produced a
488
- // run that died at preflight in seconds, and it cost a real investigation (, found by e2e looking
489
- // for a credential-free register lane for fault injection).
510
+ // run that died at preflight in seconds, and it cost a real investigation — found while looking for a
511
+ // register path that needs no credential, to inject faults into.
490
512
  //
491
513
  // The lane below is real and works. What does not exist is a way to REACH it from a run: `doSearch`,
492
514
  // `doRecordFetch` and `doEnumerate` each take `{ mock = false }`, the capability wrappers pass
@@ -494,10 +516,11 @@ export function normalizeSearchResponse(body, echoQuery) {
494
516
  // (driver.config.mjs) call these functions with no options object at all. So the only callers that
495
517
  // enable it are this provider's own tests, and that is the whole of it.
496
518
  //
497
- // AND IT STAYS THAT WAY DELIBERATELY, rather than for want of wiring. offered to wire it; the
498
- // acceptance that comes with wiring is "preflightCredentials must not demand SIGNA_API_KEY when the
499
- // mock lane is on", and that produces a clearance run with no register credential, reaching the
500
- // register stage, answering from ten in-repo fixtures. ADR-0003's first table row rules exactly that
519
+ // AND IT STAYS THAT WAY DELIBERATELY, rather than for want of wiring. Wiring it was offered and
520
+ // declined; the acceptance that comes with wiring is "preflightCredentials must not demand
521
+ // SIGNA_API_KEY when the mock path is on", and that produces a clearance run with no register
522
+ // credential, reaching the register stage, answering from ten in-repo fixtures. ADR-0003's first
523
+ // table row rules exactly that
501
524
  // out: the register REFUSES at preflight, by name, because "an unconfigured register that answered
502
525
  // 'no conflicts found' is the most dangerous output this system can produce".
503
526
  //
@@ -163,10 +163,18 @@ export const NOT_COUNTED_EVENTS = {
163
163
  "jx-aim-consumed", "jx-candidate-fold", "jx-nativeread",
164
164
  "jx-serp-grid", "jx-serp-grid-overflow", "jx-serp-grid-spec", "jx-slices-stated",
165
165
  "knockout-published", "knockout-receipts",
166
- "knockout-register-records", "knockout-sweep-skipped", "knockout-sweep-start",
167
- // The sweep's cost line: marks, calls, cells, the largest place list any one name carried, the minutes
168
- // and the target they are read against. A RECORD, never a failure — nothing about it is a step that
169
- // went wrong, a question re-asked or an attempt repeated, so it does not bear on "first time" (515).
166
+ "knockout-register-records",
167
+ // The run's own span against the ten-minute target, written at delivery because that is the first
168
+ // point at which the run's span is known. Same class as the sweep's line below: a RECORD, never a
169
+ // failure. `overBar: null` on it means the span could not be measured, which is a gap in the record
170
+ // and still not a retry (515).
171
+ "knockout-run-total",
172
+ "knockout-sweep-skipped", "knockout-sweep-start",
173
+ // The sweep's cost line: marks, calls, cells, the largest place list any one name carried, and the
174
+ // sweep's own elapsed minutes. A RECORD, never a failure — nothing about it is a step that went
175
+ // wrong, a question re-asked or an attempt repeated, so it does not bear on "first time" (515).
176
+ // The target moved off this line to the run's own, above: the bar is about a run and this span is
177
+ // one stage of it.
170
178
  "knockout-sweep-total", "level-scope-note",
171
179
  "named-band-merged", "one-shot-stamp-settled", "order-probe", "output-snapshot", "owner-screen-derived",
172
180
  "placement-borderline", "placement-form-written", "plan-execution", "plan-execution-census",
@@ -282,17 +282,40 @@ export function narrowingNamesItsCrowd(a, runDir) {
282
282
  const exec = readJson(driverDir(runDir, "plan-execution.json"));
283
283
  if (!exec) return { ok: false, saw: "_driver/plan-execution.json absent, so no question's count can be read" };
284
284
  const counts = executedCounts(exec);
285
- const crowdMissing = [], crowdUncounted = [], narrowingUncounted = [];
285
+ // A QUESTION ASKED OFF THE PLAN IS DISPOSITIONED, NOT OMITTED, and this op had no branch for it.
286
+ //
287
+ // The plan record carries an `unplanned` bucket: the engine asked something the plan did not contain
288
+ // and said so. Such an entry is on the record with a disposition and carries no executed count, and
289
+ // this check used to render that as "carries no count of its own" — which describes a silent omission.
290
+ // On the run that surfaced it, that ONE narrowing was the whole failure: 35 of 36 carried their own
291
+ // count, all 14 crowds were on the record, and neither crowd branch fired.
292
+ //
293
+ // IT IS NAMED, NOT COUNTED AGAINST THE ENGINE. The rule this op states is that the crowd a narrowing
294
+ // replaced is on the record with a count. An off-plan question satisfies the first half and cannot
295
+ // satisfy the second, because no count exists to read — and whether the engine ought to record one for
296
+ // a question it asked off-plan is a question about the RECORD, not about this narrowing. That belongs
297
+ // to the lane that writes the record; this op says what it found and declines to invent a verdict.
298
+ const offPlanQids = new Set(list(exec.unplanned).map((e) => text(e?.qid)).filter(Boolean));
299
+ const crowdMissing = [], crowdUncounted = [], narrowingUncounted = [], narrowingOffPlan = [];
286
300
  for (const n of narrowings) {
287
301
  const crowd = text(n.narrows);
288
302
  if (!byQid.has(crowd)) crowdMissing.push(`${n.qid} → ${crowd}`);
289
303
  else if (!counts.has(crowd)) crowdUncounted.push(`${n.qid} → ${crowd}`);
290
- if (!counts.has(text(n.qid))) narrowingUncounted.push(text(n.qid));
304
+ if (!counts.has(text(n.qid))) {
305
+ if (offPlanQids.has(text(n.qid))) narrowingOffPlan.push(text(n.qid));
306
+ else narrowingUncounted.push(text(n.qid));
307
+ }
291
308
  }
292
309
  const parts = [`${plural(narrowings.length, "narrowing")} naming ${new Set(narrowings.map((n) => text(n.narrows))).size} crowd(s)`];
293
310
  if (crowdMissing.length) parts.push(`${crowdMissing.length} name a crowd that is not on the record${sample(crowdMissing)}`);
294
311
  if (crowdUncounted.length) parts.push(`${crowdUncounted.length} name a crowd with no count${sample(crowdUncounted)}`);
295
312
  if (narrowingUncounted.length) parts.push(`${narrowingUncounted.length} carry no count of their own${sample(narrowingUncounted)}`);
313
+ // Said whether or not anything failed, because it is the difference between a question the engine
314
+ // never answered and one it asked outside the plan — and a reader cannot tell those apart from a
315
+ // count's absence alone.
316
+ if (narrowingOffPlan.length) parts.push(`${narrowingOffPlan.length} asked OFF THE PLAN: on the record`
317
+ + ` in plan-execution's \`unplanned\` bucket with a disposition and no executed count, so this check`
318
+ + ` has no count to read rather than a count that is missing${sample(narrowingOffPlan)}`);
296
319
  if (parts.length === 1) parts.push("each crowd and each narrowing on the record with its count");
297
320
  return { ok: !crowdMissing.length && !crowdUncounted.length && !narrowingUncounted.length, saw: parts.join("; ") };
298
321
  }