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
@@ -115,8 +115,11 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
115
115
  // it must be read-only. Nothing here needs to claim a write is a read to get called, and a
116
116
  // `readOnlyHint: true` on a tool that writes a ledger would be a lie told to a sandbox for
117
117
  // convenience — the exact thing annotations exist to prevent.
118
- const list = tools.map((t) => ({
119
- name: t.name, description: t.description, inputSchema: t.inputSchema,
118
+ // Listed when asked, not at start: a tool may give the schema for the run it serves (`schemaNow`), read
119
+ // at call time like every per-run read here. A tool without one lists its declared schema, as before.
120
+ const list = () => tools.map((t) => ({
121
+ name: t.name, description: t.description,
122
+ inputSchema: typeof t.schemaNow === "function" ? t.schemaNow(t.inputSchema) : t.inputSchema,
120
123
  ...(t.annotations ? { annotations: t.annotations } : {}),
121
124
  }));
122
125
  const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
@@ -137,7 +140,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
137
140
  } else if (method === "ping") {
138
141
  ok(id, {});
139
142
  } else if (method === "tools/list") {
140
- ok(id, { tools: list });
143
+ ok(id, { tools: list() });
141
144
  } else if (method === "tools/call") {
142
145
  const tool = byName.get(params?.name);
143
146
  if (!tool) return ok(id, { isError: true, content: [{ type: "text", text: `unknown tool: ${params?.name}` }] });
@@ -167,7 +170,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
167
170
  // interpolate a mark, and this row's own contract is the call and never its content. A row that
168
171
  // copied the message would leak the day one of them does; a row that copies `unmet` cannot.
169
172
  logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false,
170
- reason: `missing_required:${unmet.join(",")}` });
173
+ failure: "schema", reason: `missing_required:${unmet.join(",")}` });
171
174
  return ok(id, { isError: true, content: [{ type: "text", text:
172
175
  `${tool.name}_missing_required:${unmet.join(",")} — the schema you were handed declares `
173
176
  + `${unmet.length === 1 ? "this field" : "these fields"} REQUIRED and the call did not carry `
@@ -177,13 +180,19 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
177
180
  try { out = await tool.handler(params?.arguments || {}); }
178
181
  catch (e) {
179
182
  // A THROWN tool error SETTLES. It returned an answer — a wrong one, but the model saw it and
180
- // could act on it. Recording it as unsettled would attribute a live failure to a timeout.
181
- logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false });
183
+ // could act on it. Recording it as unsettled would attribute a live failure to a timeout. The
184
+ // row names the error's class and never its message, which can carry whatever the tool was given.
185
+ logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false,
186
+ failure: "threw", reason: `threw:${errorClass(e)}` });
182
187
  return ok(id, { isError: true, content: [{ type: "text", text: `${tool.name} error: ${e?.message ?? e}` }] });
183
188
  }
184
189
  const isError = typeof out === "object" && out?.isError === true;
185
190
  const text = typeof out === "string" ? out : (out?.text ?? JSON.stringify(out));
186
- logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError });
191
+ // A tool that answered with an error rejected the call itself, after the schema let it past. The row
192
+ // says so, and carries the complaint's own code when the text opens with one, never the text.
193
+ const code = isError ? complaintCode(text) : null;
194
+ logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError,
195
+ ...(isError ? { failure: "server", ...(code ? { reason: code } : {}) } : {}) });
187
196
  ok(id, { isError: isError || undefined, content: [{ type: "text", text }] });
188
197
  } else if (id != null) {
189
198
  err(id, -32601, `method not found: ${method}`);
@@ -205,3 +214,32 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
205
214
  // so `claude --mcp-config` reports a connected server whose tool calls return a clear isError.
206
215
  process.on("uncaughtException", (e) => { process.stderr.write(`[mcp:${name}] ${e?.stack ?? e}\n`); });
207
216
  }
217
+
218
+ // ── WHAT REFUSED A CALL, as the settled row names it ───────────────────────────────────────────────────
219
+ //
220
+ // (At the end of this file so that adding it moved no line another file cites.)
221
+ //
222
+ // A settled row with `ok: false` carries `failure`, one of three words for where the call stopped:
223
+ // schema the call did not carry a field the tool's schema requires; refused before the tool ran
224
+ // threw the tool ran and threw
225
+ // server the tool ran and answered with an error of its own
226
+ // So a call refused before its tool ever ran is told apart from one the tool rejected without knowing
227
+ // that the first leaves no call file behind. `reason` is never the message: a message can quote what the
228
+ // tool was given. It is the missing fields, the error's class, or the complaint's own code.
229
+
230
+ /** An error's class name, never its message. PURE. */
231
+ export function errorClass(e) {
232
+ const n = typeof e?.name === "string" && /^[A-Za-z][A-Za-z0-9]*$/.test(e.name) ? e.name : "";
233
+ return n || "Error";
234
+ }
235
+
236
+ /**
237
+ * The code a tool's complaint opens with, as the acceptors write them (`knockoutframe_classes_plain: …`,
238
+ * `judgment_record_not_held:1:…`): a lower-case word with at least one underscore, read up to the first
239
+ * character that is not part of it. `null` when the text opens any other way, so prose never reaches the
240
+ * row. PURE.
241
+ */
242
+ export function complaintCode(text) {
243
+ const m = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)+)(?=$|[^a-z0-9_])/.exec(String(text ?? ""));
244
+ return m ? m[1] : null;
245
+ }
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
26
26
  import { everyToolCallRefused } from "./tool-refusal.mjs";
27
+ import { newSessionRecord, noteCodexEvent, sessionSummary, countTool, streamSink, codexEnding } from "./session-record.mjs"; // what the session went through, beside how it ended
27
28
  import { writeSecretFile } from "../../shared/secret-file.mjs"; // the rotated login goes back the way every credential is written
28
29
  import { tmpdir, homedir } from "node:os";
29
30
  import { join, dirname, sep } from "node:path";
@@ -38,11 +39,28 @@ import { resolveEngineProgram } from "../driver.config.mjs"; // — the one pl
38
39
  const codexBin = () => { const r = resolveEngineProgram("openai-agent"); return r.resolved ?? r.bin; };
39
40
 
40
41
  // tier/alias → codex `-m` model id. opus/sonnet/haiku are the driver's abstract tiers (CONTRACT §3). The
41
- // GPT ids are ENV-OVERRIDABLE and default to three DISTINCT rungs of the codex ladder — `gpt-5.6-sol`,
42
- // `gpt-5.6-terra`, `gpt-5.6-luna`, all present in the codex CLI as installed — so an out-of-the-box run
42
+ // GPT ids are ENV-OVERRIDABLE and default to the vendor's current models — `gpt-6.1-sol` for the judgment
43
+ // and sweep tiers, `gpt-6-luna` for the cheap tier (the GPT-6 ladder, below) — so an out-of-the-box run
43
44
  // uses REAL, current ids rather than invented guesses, and a stage's declared tier means the same thing
44
- // on both engines. `sol` remains what codex itself reports as its own default (live-probed 2026-07-27:
45
- // `model: gpt-5.6-sol, provider: openai`), which is why the judgment tier keeps it.
45
+ // on both engines. Until 2026-10-09 they were `gpt-5.6-sol`, `gpt-5.6-terra` and `gpt-5.6-luna`; `sol`
46
+ // was what codex itself reported as its own default (live-probed 2026-07-27: `model: gpt-5.6-sol,
47
+ // provider: openai`), which is why the judgment tier kept it.
48
+ //
49
+ // ── THE GPT-6 LADDER (ruling 2026-10-09) ────────────────────────────────────────────────────────────────
50
+ //
51
+ // The owner asked for the latest models on both engines, cheaper and faster. The vendor's price list,
52
+ // read 2026-10-09 (standard tier, per million tokens in / out): `gpt-6.1-sol` $2 / $10, `gpt-6-luna`
53
+ // $0.10 / $0.50, `gpt-6-astra` $10 / $50. The 5.6 ladder it replaces was sol $4 / $20, terra $2 / $12,
54
+ // luna $0.20 / $1.20. There is no GPT-6 middle model and Sol 6.1 costs less than Terra 5.6, so the sweep
55
+ // tier takes Sol as well; Astra is the premium model, neither cheaper nor faster, and is not a rung.
56
+ //
57
+ // THE CODEX FLOOR MOVED WITH IT. On ChatGPT-account sign-in (subscription billing) codex 0.156.1, 0.157.0
58
+ // and 0.158.0 refuse `gpt-6.1-sol` with a 400 — "not supported when using Codex with a ChatGPT account" —
59
+ // and 0.159.0 to 0.162.0 accept it; `gpt-6-luna` is accepted from 0.156.1. Measured 2026-10-09 with
60
+ // one-word turns on the test box. Below 0.159.0 the judgment tier stops at its first stage, so the floor
61
+ // in ENGINE_BINARIES (driver.config.mjs) is 0.159.0.
62
+ //
63
+ // The August measurement below still names the symptom to watch for when a tier moves to a cheaper model.
46
64
  //
47
65
  // ── THE THREE TIERS MAP ONTO THE CODEX LADDER (ruling 2026-09-02,) ──────────────────────────
48
66
  //
@@ -89,9 +107,9 @@ const codexBin = () => { const r = resolveEngineProgram("openai-agent"); return
89
107
  // runs SOMETHING is worse than one that refuses: the arm still produces a number and the number is a
90
108
  // story. They fall through to the throw below, which is where they always should have been. Concrete
91
109
  // gpt-*/o* ids still pass through.
92
- const JUDGMENT = process.env.CLEAROTRON_OPENAI_MODEL_JUDGMENT || "gpt-5.6-sol"; // opus — unchanged
93
- const SWEEP = process.env.CLEAROTRON_OPENAI_MODEL_SWEEP || "gpt-5.6-terra"; // sonnet — was sol
94
- const CHEAP = process.env.CLEAROTRON_OPENAI_MODEL_CHEAP || "gpt-5.6-luna"; // haiku — was sol
110
+ const JUDGMENT = process.env.CLEAROTRON_OPENAI_MODEL_JUDGMENT || "gpt-6.1-sol"; // opus — was gpt-5.6-sol
111
+ const SWEEP = process.env.CLEAROTRON_OPENAI_MODEL_SWEEP || "gpt-6.1-sol"; // sonnet — was gpt-5.6-terra
112
+ const CHEAP = process.env.CLEAROTRON_OPENAI_MODEL_CHEAP || "gpt-6-luna"; // haiku — was gpt-5.6-luna
95
113
  const OPENAI_MODEL = { opus: JUDGMENT, sonnet: SWEEP, haiku: CHEAP };
96
114
  export function openaiModel(model) {
97
115
  if (!model) return undefined;
@@ -311,8 +329,10 @@ export function parseCodexEvent(line, ev) {
311
329
  switch (e?.type) {
312
330
  case "thread.started": if (e.thread_id) ev.threadId = e.thread_id; break;
313
331
  case "turn.completed": ev.turnCompleted = true; if (e.usage) ev.usage = e.usage; break;
314
- case "turn.failed": ev.turnFailed = e.error?.message || "turn.failed"; break;
315
- case "error": ev.streamError = e.message || "stream error"; break;
332
+ // The program's own words are kept apart from the placeholders, so the record never shows a word of
333
+ // ours where it said nothing (session-record.mjs, codexEnding).
334
+ case "turn.failed": ev.turnFailed = e.error?.message || "turn.failed"; ev.turnFailedText = typeof e.error?.message === "string" ? e.error.message : null; break;
335
+ case "error": ev.streamError = e.message || "stream error"; ev.streamErrorText = typeof e.message === "string" ? e.message : null; break;
316
336
  case "item.completed":
317
337
  if (e.item?.type === "agent_message" && typeof e.item.text === "string") ev.agentText = e.item.text;
318
338
  // A FILE CHANGE CODEX ITSELF REPORTS AS FAILED. Measured on codex-cli 0.156.1 with its sandbox unable
@@ -330,6 +350,7 @@ export function parseCodexEvent(line, ev) {
330
350
  ev.commandFailures.push({ command: String(e.item.command ?? ""), output: String(e.item.aggregated_output ?? "") });
331
351
  }
332
352
  noteMcpToolCall(e.item, ev);
353
+ noteToolItem(e.item, ev);
333
354
  break;
334
355
  // ── — AN MCP CALL THAT WAS REFUSED IS NOT A CALL NOBODY MADE ──────────
335
356
  //
@@ -352,6 +373,7 @@ export function parseCodexEvent(line, ev) {
352
373
  // would double every call.
353
374
  case "item.started":
354
375
  noteMcpToolCall(e.item, ev);
376
+ noteToolItem(e.item, ev);
355
377
  break;
356
378
  default: break;
357
379
  }
@@ -377,6 +399,47 @@ export function noteMcpToolCall(item, ev) {
377
399
  });
378
400
  }
379
401
 
402
+ // The item kinds codex reports a tool call as, besides its MCP calls. Its own words, measured on
403
+ // codex-cli 0.150–0.158: a shell command, a file edit, a web search.
404
+ const TOOL_ITEM_KINDS = new Set(["command_execution", "file_change", "web_search"]);
405
+
406
+ /**
407
+ * Remember one tool-shaped item by id, for the per-name count. Codex writes each item twice (started,
408
+ * then its terminal state) under one id, so the id is the unit, exactly as for the MCP gauge.
409
+ */
410
+ export function noteToolItem(item, ev) {
411
+ if (!item?.id) return;
412
+ const name = item.type === "mcp_tool_call" ? `mcp__${item.server ?? "?"}__${item.tool ?? "?"}`
413
+ : TOOL_ITEM_KINDS.has(item.type) ? item.type : null;
414
+ if (!name) return;
415
+ (ev.toolItems ??= new Map()).set(item.id, name);
416
+ }
417
+
418
+ /**
419
+ * The turn's tool calls by name, in the shape anthropic-agent records: an MCP call under
420
+ * `mcp__<server>__<tool>`, and codex's own tool items under its own word for them. Counted once per item
421
+ * id. An OBJECT is a measurement (`{}` = none observed).
422
+ */
423
+ export function codexToolCallsByName(ev) {
424
+ const byName = Object.create(null);
425
+ for (const name of ev?.toolItems?.values() ?? []) countTool(byName, name);
426
+ return { ...byName };
427
+ }
428
+
429
+ /**
430
+ * The tool-server calls of this turn that came back as an error, by tool name, in the shape
431
+ * anthropic-agent records: a call that reached its server and `failed` there, which codex writes with no
432
+ * `error.message` (a call codex refused itself carries one, and `toolCallsRefused` counts those). Counted
433
+ * once per item id. An OBJECT is a measurement (`{}`: none). PURE.
434
+ */
435
+ export function codexToolServerErrors(ev) {
436
+ const byName = Object.create(null);
437
+ for (const c of ev?.mcpCalls?.values() ?? []) {
438
+ if (c.status === "failed" && !c.message) countTool(byName, `mcp__${c.server ?? "?"}__${c.tool ?? "?"}`);
439
+ }
440
+ return { ...byName };
441
+ }
442
+
380
443
  /**
381
444
  * The MCP tool-call gauge for this turn: what completed, what was REFUSED, and why.
382
445
  *
@@ -540,11 +603,16 @@ function settleTuple({ r, ev, resumeRef }) {
540
603
  const failed = overflow || killed || !ev.turnCompleted || !!ev.turnFailed;
541
604
  const usage = mapUsage(ev.usage);
542
605
  const text = ev.agentText || "";
543
- const json = buildEnvelope({
544
- text, ok: !failed, killed, usage,
545
- summary: ev.turnFailed ? "failed" : (ev.turnCompleted ? "success" : undefined),
546
- runId: ev.threadId,
547
- });
606
+ const json = {
607
+ ...buildEnvelope({
608
+ text, ok: !failed, killed, usage,
609
+ summary: ev.turnFailed ? "failed" : (ev.turnCompleted ? "success" : undefined),
610
+ runId: ev.threadId,
611
+ }),
612
+ // What ended the turn, in the program's own words when it failed (session-record.mjs, codexEnding):
613
+ // the same three fields the Claude adapter writes. Recorded only; `ok` above is unchanged.
614
+ ...codexEnding(ev),
615
+ };
548
616
  const stderrOut = overflow
549
617
  ? (r.stderr + `\nopenai-agent output overflow: stdout/stderr exceeded ${r.maxBuffer} chars — killed the tree and failed the turn (truncated tail never parsed)`)
550
618
  : (stallKill ? (r.stderr + "\nrequest timed out (openai-agent stall-watchdog: 0 streamed tokens)") : r.stderr);
@@ -589,6 +657,10 @@ function settleTuple({ r, ev, resumeRef }) {
589
657
  // adapter reads its commands only for failures (below), so it reports no command-tool count: null, never zero.
590
658
  toolCallsRefused: mcpToolGauge(ev).mcpToolCallsRefused,
591
659
  commandToolCalls: null,
660
+ // The per-name split of the calls codex reports (see codexToolCallsByName), what the session went
661
+ // through (session-record.mjs: every turn ending and stream error, in order), and the raw stream.
662
+ toolCallsByName: codexToolCallsByName(ev), toolCallsErroredByName: codexToolServerErrors(ev),
663
+ session: sessionSummary(ev.session), stream: ev.stream ?? null,
592
664
  writesFailed: ev.writesFailed ?? 0,
593
665
  commandsFailed: ev.commandsFailed ?? 0,
594
666
  commandFailures: ev.commandFailures ?? [],
@@ -624,6 +696,8 @@ function errResult(t0, e, resumeRef) {
624
696
  code: 1, killed: false, wall: (Date.now() - t0) / 1000, stdout: "",
625
697
  stderr: `openai-agent error: ${e?.message ?? e}`, laneWaitMs: 0,
626
698
  json: null, usage: null, modelWire: null, sessionRef: resumeRef ?? null,
699
+ // No session ran: nothing to have been interrupted, no stream.
700
+ toolCallsByName: null, toolCallsErroredByName: null, session: null, stream: null,
627
701
  };
628
702
  }
629
703
 
@@ -652,7 +726,7 @@ export const openaiAgentEngine = {
652
726
  // `thread/resume failed: no rollout found for thread id ... (code -32600)`, exit 1 — with the control
653
727
  // (same id, same binary, its own home) resuming cleanly. So every warm-patch retry and every
654
728
  // form-repair sub-turn on the codex arm has been failing.
655
- async runTurn({ message, model, thinking, timeoutSec, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, stallSec, codexHome: providedHome } = {}) {
729
+ async runTurn({ message, model, thinking, timeoutSec, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, stallSec, codexHome: providedHome, streamFile = null } = {}) {
656
730
  if (!message) throw new Error("openai-agent.runTurn: message is required");
657
731
  const t0 = Date.now();
658
732
  let codexHome;
@@ -703,16 +777,25 @@ export const openaiAgentEngine = {
703
777
  // Stamped HERE, not at function entry: everything above is file writes that can take a moment, and
704
778
  // a floor set too early would let a previous turn's rollout back in on a shared home.
705
779
  const spawnedAtMs = Date.now();
706
- const ev = { threadId: null, usage: null, agentText: "", turnCompleted: false, turnFailed: null, streamError: null, model: null, mcpCalls: new Map() };
780
+ const ev = { threadId: null, usage: null, agentText: "", turnCompleted: false, turnFailed: null, streamError: null, model: null, mcpCalls: new Map(),
781
+ session: newSessionRecord() };
782
+ // The raw stream, one file per session beside its dispatch; every line codex wrote on stdout.
783
+ const sink = streamSink(streamFile);
707
784
  const r = await runStreamingChild({
708
785
  bin: codexBin(), args, input, runDir,
709
786
  // — the run dir, not a shared tmpdir. codex makes cwd a workspace root, writable under the
710
787
  // stage's profile, so this tightens the writable surface onto the run rather than widening it.
711
788
  cwd: resolveSpawnCwd({ cwd, runDir }),
712
789
  env, stallSec, timeoutSec,
713
- onStdoutLine: (line) => parseCodexEvent(line, ev),
790
+ onStdoutLine: (line) => {
791
+ sink.write(`${line}\n`);
792
+ parseCodexEvent(line, ev);
793
+ let e; try { e = JSON.parse(line); } catch { return; }
794
+ noteCodexEvent(ev.session, e, Date.now() - t0);
795
+ },
714
796
  stderrIsLiveness: true, // codex streams progress on STDERR → it is liveness for the stall watchdog
715
797
  });
798
+ ev.stream = sink.close();
716
799
  if (r.spawnError) return errResult(t0, r.spawnError, resumeRef);
717
800
  // The stream said no model (it never does — see readServedModel); the rollout under THIS run's
718
801
  // CODEX_HOME did. Read here, inside the try, because the finally below deletes that dir.
@@ -0,0 +1,223 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // engine/session-record.mjs — what a model session went through, kept beside what it ended with.
4
+ //
5
+ // ── why this exists ──────────────────────────────────────────────────────────────────────────────────
6
+ //
7
+ // Both engines read the program's stream in memory and kept the LAST result. A session the vendor's
8
+ // classifier cut off mid-answer, or one the API refused and a restart recovered, ended in an ordinary
9
+ // successful result and was recorded as a clean session. A cut drops the tool calls that had not
10
+ // finished, so a stage that lost calls mid-answer looked exactly like one that finished.
11
+ //
12
+ // Measured on the bench's saved-data replays (2026-09-28, 50 Claude sessions): 23 were cut and carried
13
+ // on; in 8 the API then refused; one of the 8 still answered, because a background helper restarted the
14
+ // session and the last result was a success. The program's own words for each event, read off a saved
15
+ // stream with the content left out:
16
+ //
17
+ // cut a `user` event, `isSynthetic: true`, whose text block says the response "was stopped by
18
+ // a safety classifier"
19
+ // refusal a `system` event, subtype `model_refusal_no_fallback`, carrying `api_refusal_category`
20
+ // and `original_model`; then an `assistant` message with `stop_reason: "refusal"`
21
+ // restart a second `system` / `init` event in the same stream
22
+ // results one `result` event per answer, each with `result_index`, `is_error`, `stop_reason`,
23
+ // `terminal_reason` and, for one a helper's notice started, `origin.kind`
24
+ //
25
+ // So nothing here is inferred: every row is an event the program wrote, with when it arrived. The
26
+ // record answers "was this session interrupted, refused or restarted, and when" from the run directory,
27
+ // and it never decides anything — the retry policy reads the last result exactly as before.
28
+ //
29
+ // ── what is NOT recorded ─────────────────────────────────────────────────────────────────────────────
30
+ //
31
+ // No text in the session record. The cut message, the refusal's explanation and every result's answer
32
+ // carry the model's or the vendor's words about client matter; a row carries the event's kind, its time
33
+ // and its codes. The raw stream, which does carry the words, goes to its own file in the run directory
34
+ // (streamSink below), where the dispatch text already sits.
35
+ //
36
+ // ONE EXCEPTION, and it sits beside the record rather than in it: when a session ENDED IN AN ERROR, the
37
+ // program's own words for that error go on the attempt row (`errorText`, see "how the session ended"
38
+ // below). An expired sign-in recorded `stop_sequence` six times and nothing else, while the program had
39
+ // said in plain words that the session had expired. On a healthy ending that field is the model's answer
40
+ // and stays off the row.
41
+
42
+ import { mkdirSync, openSync, writeSync, closeSync, statfsSync } from "node:fs";
43
+ import { dirname } from "node:path";
44
+
45
+ /** The program's words for a classifier cut, as it writes them into the session. Matched, never quoted. */
46
+ export const CLASSIFIER_CUT_RE = /stopped by a safety classifier/i;
47
+
48
+ // A row list is a record, not a log: a session that is cut fifty times has said what it needs to say by
49
+ // the fiftieth, and the count beside the list keeps saying how many there were.
50
+ const ROWS_CAP = 50;
51
+
52
+ /** An empty record. `starts` counts the program's own session starts (`system`/`init`). */
53
+ export function newSessionRecord() {
54
+ return { starts: 0, results: [], classifierCuts: [], refusals: [], refusalStops: 0, dropped: 0 };
55
+ }
56
+
57
+ const push = (rec, list, row) => {
58
+ if (list.length < ROWS_CAP) list.push(row);
59
+ else rec.dropped += 1;
60
+ };
61
+
62
+ /** The text a `user` event carries outside its tool results: a string, or its `text` blocks. */
63
+ function userText(content) {
64
+ if (typeof content === "string") return content;
65
+ if (!Array.isArray(content)) return "";
66
+ return content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
67
+ }
68
+
69
+ /**
70
+ * Fold one parsed Claude stream event into the record. `atMs` is milliseconds since the spawn. PURE
71
+ * over its arguments; the caller owns the clock.
72
+ */
73
+ export function noteClaudeEvent(rec, ev, atMs) {
74
+ if (!rec || !ev || typeof ev !== "object") return;
75
+ if (ev.type === "system" && ev.subtype === "init") { rec.starts += 1; return; }
76
+ if (ev.type === "system" && ev.subtype === "model_refusal_no_fallback") {
77
+ push(rec, rec.refusals, {
78
+ atMs, category: typeof ev.api_refusal_category === "string" ? ev.api_refusal_category : null,
79
+ model: typeof ev.original_model === "string" ? ev.original_model : null,
80
+ });
81
+ return;
82
+ }
83
+ if (ev.type === "assistant") {
84
+ if (ev.message?.stop_reason === "refusal") rec.refusalStops += 1;
85
+ return;
86
+ }
87
+ if (ev.type === "user") {
88
+ if (CLASSIFIER_CUT_RE.test(userText(ev.message?.content))) push(rec, rec.classifierCuts, { atMs, synthetic: ev.isSynthetic === true });
89
+ return;
90
+ }
91
+ if (ev.type === "result") {
92
+ push(rec, rec.results, {
93
+ atMs,
94
+ index: Number.isFinite(ev.result_index) ? ev.result_index : rec.results.length + rec.dropped,
95
+ subtype: typeof ev.subtype === "string" ? ev.subtype : null,
96
+ isError: ev.is_error === true,
97
+ stopReason: typeof ev.stop_reason === "string" ? ev.stop_reason : null,
98
+ terminalReason: typeof ev.terminal_reason === "string" ? ev.terminal_reason : null,
99
+ origin: typeof ev.origin?.kind === "string" ? ev.origin.kind : null,
100
+ });
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Fold one parsed Codex `exec --json` event into the record. Codex states no refusal category and no
106
+ * classifier cut on its stream (measured, codex-cli 0.150–0.158), so what it can say is said: how the
107
+ * turn ended, every time it ended, and every stream error.
108
+ */
109
+ export function noteCodexEvent(rec, ev, atMs) {
110
+ if (!rec || !ev || typeof ev !== "object") return;
111
+ if (ev.type === "thread.started") { rec.starts += 1; return; }
112
+ if (ev.type === "turn.completed" || ev.type === "turn.failed" || ev.type === "error") {
113
+ push(rec, rec.results, {
114
+ atMs, index: rec.results.length + rec.dropped, subtype: ev.type,
115
+ isError: ev.type !== "turn.completed", stopReason: null, terminalReason: null, origin: null,
116
+ });
117
+ }
118
+ }
119
+
120
+ /** True when any result the session reached was an error, whatever the last one said. */
121
+ export const anyResultErrored = (rec) => Array.isArray(rec?.results) && rec.results.some((r) => r?.isError === true);
122
+
123
+ /**
124
+ * The record as the attempt row carries it. `interrupted` is the one-word answer a reader filters on: the
125
+ * session was cut, refused, restarted, or reached an error result, at least once.
126
+ */
127
+ export function sessionSummary(rec) {
128
+ if (!rec) return null;
129
+ const interrupted = rec.classifierCuts.length > 0 || rec.refusals.length > 0 || rec.refusalStops > 0
130
+ || rec.starts > 1 || anyResultErrored(rec);
131
+ return {
132
+ interrupted, starts: rec.starts, results: rec.results.map((r) => ({ ...r })),
133
+ classifierCuts: rec.classifierCuts.map((r) => ({ ...r })), refusals: rec.refusals.map((r) => ({ ...r })),
134
+ refusalStops: rec.refusalStops, ...(rec.dropped ? { rowsDropped: rec.dropped } : {}),
135
+ };
136
+ }
137
+
138
+ // ── how the session ended, in the program's own words ──────────────────────────────────────────────────
139
+ //
140
+ // The stop reason alone misleads on an error. Measured on an expired sign-in: the Claude program reported
141
+ // `subtype: "success"`, `is_error: true`, `stop_reason: "stop_sequence"` (the stop of its OWN synthetic
142
+ // message, whose model reads `<synthetic>`) and `terminal_reason: "api_error"`, with the reason in the
143
+ // result text: "Failed to authenticate: OAuth session expired and could not be refreshed". Six retries of
144
+ // one stage journalled six identical `stop_sequence` rows and said nothing about why. The stop reason stays
145
+ // verbatim, because on an error it describes the program's synthetic message and rewriting it would be
146
+ // inventing; these three go beside it, on both engines:
147
+ //
148
+ // terminalReason the kind of ending, as the program states it. null when it states none (Codex never does).
149
+ // apiErrorStatus the vendor's status when it gave one. null when it did not, which is not zero.
150
+ // errorText the program's words for the error, ONLY when the session ended in one, cut to a length a
151
+ // person reads. Never a word of ours in their place: an error that carried none reads null.
152
+ //
153
+ // The verdict does not move: `fail`, the retry ladder and the envelope's status read exactly what they
154
+ // read before. These record; they decide nothing.
155
+ export const ERROR_TEXT_CAP = 300;
156
+
157
+ const words = (t) => (typeof t === "string" && t.trim() ? t.slice(0, ERROR_TEXT_CAP) : null);
158
+
159
+ /** Claude: from the session's LAST result event (the one the verdict reads), or nulls when none arrived. */
160
+ export function claudeEnding(resultEvent) {
161
+ const r = resultEvent && typeof resultEvent === "object" ? resultEvent : null;
162
+ return {
163
+ terminalReason: typeof r?.terminal_reason === "string" ? r.terminal_reason : null,
164
+ apiErrorStatus: r?.api_error_status ?? null,
165
+ errorText: r?.is_error === true ? words(r.result) : null,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Codex: no terminal kind and no status on its stream. Its words are the failed turn's own message, or the
171
+ * stream error when the turn never completed; an `error` it recovered from (a reconnect, then a completed
172
+ * turn) is not why it ended. Reads the raw messages `parseCodexEvent` keeps, never its placeholders.
173
+ */
174
+ export function codexEnding(ev) {
175
+ const failed = ev?.turnFailedText ?? null, stream = ev?.streamErrorText ?? null;
176
+ const said = ev?.turnFailed ? words(failed) : (ev?.turnCompleted ? null : words(stream));
177
+ return { terminalReason: null, apiErrorStatus: null, errorText: said };
178
+ }
179
+
180
+ /** The three as an attempt row carries them: written every time, null where the engine said nothing. */
181
+ export function endingFields(json) {
182
+ return { terminalReason: json?.terminalReason ?? null, apiErrorStatus: json?.apiErrorStatus ?? null, errorText: json?.errorText ?? null };
183
+ }
184
+
185
+ /** A per-tool-name call count, folded one name at a time. */
186
+ export function countTool(byName, name) {
187
+ const k = String(name ?? "?") || "?";
188
+ byName[k] = (byName[k] ?? 0) + 1;
189
+ }
190
+
191
+ // ── the raw stream ───────────────────────────────────────────────────────────────────────────────────
192
+ //
193
+ // Every byte the program wrote on its protocol channel, one file per session, beside the dispatch that
194
+ // started it. Kept while the disk holding the run has room: below STREAM_FLOOR_BYTES free it is not
195
+ // written, and the record says so rather than leaving a file that stops halfway. A write that fails
196
+ // mid-session closes the file and says so too. Neither ever fails the turn.
197
+ export const STREAM_FLOOR_BYTES = 5 * 1024 ** 3;
198
+
199
+ /**
200
+ * Open a sink for `file`. Returns `{ write(chunk), close() }`; `close()` returns what landed:
201
+ * `{ file, bytes, present: true }`, or `{ file, present: false, reason }`. A null file is a null sink.
202
+ */
203
+ export function streamSink(file, { floorBytes = STREAM_FLOOR_BYTES, freeBytes = null } = {}) {
204
+ if (!file) return { write() {}, close: () => null };
205
+ let fd = null, bytes = 0, reason = null;
206
+ try {
207
+ mkdirSync(dirname(file), { recursive: true });
208
+ const free = freeBytes ?? (() => { const s = statfsSync(dirname(file)); return Number(s.bavail) * Number(s.bsize); })();
209
+ if (free < floorBytes) reason = `not written: the disk holding the run had ${Math.round(free / 1024 ** 2)} MiB free, under the ${Math.round(floorBytes / 1024 ** 3)} GiB floor`;
210
+ else fd = openSync(file, "w", 0o600);
211
+ } catch (e) { reason = `not written: ${String(e?.message ?? e).slice(0, 120)}`; }
212
+ return {
213
+ write(chunk) {
214
+ if (fd === null) return;
215
+ try { bytes += writeSync(fd, typeof chunk === "string" ? chunk : Buffer.from(chunk)); }
216
+ catch (e) { try { closeSync(fd); } catch { /* already gone */ } fd = null; reason = `cut short after ${bytes} bytes: ${String(e?.message ?? e).slice(0, 120)}`; }
217
+ },
218
+ close() {
219
+ if (fd !== null) { try { closeSync(fd); } catch { /* the bytes are on disk or they are not; bytes says which */ } fd = null; }
220
+ return reason ? { file, present: bytes > 0, bytes, reason } : { file, present: true, bytes };
221
+ },
222
+ };
223
+ }
@@ -730,8 +730,8 @@ export function validateJob(job, { atClaim = false } = {}) {
730
730
  warnings.push(...fit.warnings);
731
731
  }
732
732
  // platforms — marketplaces to sweep IN ADDITION to the account's own. ADDITIVE ONLY, and enforced as
733
- // such where the union happens: a client's platforms are a client MANDATE (the 2026-07-18 Racers
734
- // Paradise run searched house platforms instead of the client's), so a per-run list can widen the grid
733
+ // such where the union happens: a client's platforms are a client MANDATE (the 2026-07-18 Drivers
734
+ // Haven run searched house platforms instead of the client's), so a per-run list can widen the grid
735
735
  // and can never shrink it. Same per-entry rules as a profile's, shared via platformEntryErrors.
736
736
  if (job.platforms != null) {
737
737
  if (!Array.isArray(job.platforms)) errs.push("platforms must be an array of bare store domains (e.g. [\"gnc.com\"]) — they are ADDED to the account's marketplaces, never a replacement for them");
@@ -1780,7 +1780,7 @@ function validateNet(f, ord, mode) {
1780
1780
  // `findings?_` and would aim the repair turn at register-coverage-ledger.json — forbidding the model
1781
1781
  // to rewrite the only file that could fix it.
1782
1782
  // It reaches the wire as a validator `reason` (verify.mjs checkFindingsSibling), which is the only shape
1783
- // gateway.mjs:525 mints as `invalid_file:<path>:<token>`; a bespoke throw would match nothing.
1783
+ // gateway.mjs:1200 judgeArtifacts mints as `invalid_file:<path>:<token>`; a bespoke throw would match nothing.
1784
1784
  // The precedent is one line up the file: findings_mark_assessment_* was named plural for the same
1785
1785
  // routing reason.
1786
1786
  //
@@ -1942,14 +1942,14 @@ function recordUrlOrigin(value) {
1942
1942
  *. `checkRecordUrlHost` asks whether a link points at the wrong register, and `recordUrlOrigin`
1943
1943
  * returns null for anything with no host — so the host gate returns CLEAN for `#`. That is right for its
1944
1944
  * own purpose and it means nothing in the findings path was asking whether the link is a link. A value of
1945
- * `#` then composes `- Source: [EUIPO · 018575624](#)` — the delivered R5 shape is about, byte for
1945
+ * `#` then composes `- Source: [EUIPO · 099999992](#)` — the delivered R5 shape is about, byte for
1946
1946
  * byte, reached through a path nothing rejected.
1947
1947
  *
1948
1948
  * ABSENT IS NOT DEAD, and the distinction is the whole predicate. `null` and `""` are the sanctioned way
1949
1949
  * to say "this provider publishes no per-record page" — validateSource documents `""` in its own message
1950
1950
  * and renderSourceBullet composes nothing for it. Rejecting those would break the legitimate case this
1951
1951
  * issue exists to protect. Anything else that is not an http(s) URL is a value a seat typed in place of a
1952
- * link: `#`, `#details/trademarks/018575624`, a bare path, or whitespace that only looks like a value.
1952
+ * link: `#`, `#details/trademarks/099999992`, a bare path, or whitespace that only looks like a value.
1953
1953
  *
1954
1954
  * IT ENUMERATES RATHER THAN ASKING "IS THIS AN http(s) URL", and a shipped test is why. ` an
1955
1955
  * unparseable or non-http value is not a host claim and is left to the shape rules` deliberately ACCEPTS
@@ -288,8 +288,8 @@ export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_S
288
288
 
289
289
  // exactQueries = the strings dispatched as OR-stacked exact/near name searches (deduped, original excluded).
290
290
  //
291
- // Deduped ON THE DIACRITIC FOLD, not the raw string. The registers fold diacritics, so `paradisè`,
292
- // `paradisé`, `paradisê`, `paradisë` and `paradiše` are ONE query, not five — and one already covered by the
291
+ // Deduped ON THE DIACRITIC FOLD, not the raw string. The registers fold diacritics, so `havensidè`,
292
+ // `havensidé`, `havensidê`, `havensidë` and `havenšide` are ONE query, not five — and one already covered by the
293
293
  // element itself. Drivers Haven 2026-07-17 dispatched exactly those five as distinct terms; each returned
294
294
  // the same 424 hits, together they exhausted the run's record budget, all five stayed `unenumerated`, and
295
295
  // `coverage_clean_unverified_incomplete:primary-sweep` then blocked delivery six times on a gate that could
@@ -399,8 +399,8 @@ export function coverageGaps(band, { dispatched = [], explained = [] } = {}) {
399
399
  // `model` is the VALIDATED variant-manifest.json (variant-manifest-model.mjs). When supplied it is
400
400
  // AUTHORITATIVE for the dominant element and the prose is used only for the formative root — because the prose
401
401
  // parse is unsafe. `dominantElementFromManifest` captures to the first `.`/`;`/`(`/newline, so a manifest that
402
- // writes "Dominant element: HYDRA — the stem a family of marks shares" yields the whole clause, which
403
- // normalizeElement then compacts to `hydrathestemafamilyofmarksshares`. That pseudo-element got a full
402
+ // writes "Dominant element: AQUA — the stem a family of marks shares" yields the whole clause, which
403
+ // normalizeElement then compacts to `aquathestemafamilyofmarksshares`. That pseudo-element got a full
404
404
  // exhaustive edit-1 neighbourhood: 1,736 junk exact queries on AquaPlus 2026-07-17, 4,524 on the 07-16 run.
405
405
  // Measured 2026-07-18: 10 of 20 recent runs carried one of these. The JSON field is a validated scalar and
406
406
  // cannot swallow a sentence.
@@ -478,7 +478,7 @@ export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts
478
478
  // line variantCompletenessGaps' per-script floor draws when it says it VALIDATES, NEVER GENERATES.
479
479
 
480
480
  // A seed longer than this is a prose-parse artifact, not an element. The measured shape is the
481
- // swallowed clause — "hydrathestemafamilyofmarksshares", 32-36 characters against a 5-char element —
481
+ // swallowed clause — "aquathestemafamilyofmarksshares", 31-35 characters against a 4-char element —
482
482
  // which took a full exhaustive edit-1 neighbourhood and put 1,736 junk exact queries on the wire
483
483
  // (AquaPlus 2026-07-17). isStemOfNamedElement already rejects that shape for the formative root by
484
484
  // measuring it against the named elements; this is the same measure for a seed with nothing to
@@ -691,7 +691,7 @@ export function variantFloorFamilies(elements, { mark = "", droppedAxes = [], mi
691
691
  // MEASURED, not assumed (2026-08-03): the register compiler's `norm` and the shared `formKey`
692
692
  // both strip separators, so every member of this family keys IDENTICALLY to the mark and is
693
693
  // already searched as the mark's own exact entry — which is why compileRegisterPlan does not
694
- // re-dispatch them, and why doing so would be the PARADISE shape (five spellings, one result
694
+ // re-dispatch them, and why doing so would be the Drivers Haven shape (five spellings, one result
695
695
  // set, the run's record budget spent). Enumerated here because the family is genuinely
696
696
  // mechanical and the surfaces that do NOT fold spacing — the common-law grid, the connotation
697
697
  // sweep — are real; those consumers are NOT wired to it by this change.
@@ -794,7 +794,7 @@ export function mergeVariantFloor(floorFamilies, modelVariants, { rejectedSeeds
794
794
  /**
795
795
  * Is `root` a plausible formative root, or a prose-parse artifact?
796
796
  *
797
- * A stem is never LONGER than the element it stems from — VOLEM(5) for ZOLEMA(6), HYDR(4) for HYDRA(5),
797
+ * A stem is never LONGER than the element it stems from — VOLEM(5) for ZOLEMA(6), AQU(3) for AQUA(4),
798
798
  * VELTRI(6) for VELTRIN(7). Substring containment is NOT the test: a real root can differ in its leading
799
799
  * character (ZOLEMA → VOLEM reaches the VOLEMA family), which is exactly the widening the root exists for.
800
800
  * The swallowed-sentence artifacts are always the element PLUS a clause — 32-36 characters against a 5-char