clearotron 0.3.2-beta.6 → 0.3.2-beta.8

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 (98) hide show
  1. package/.env.example +24 -23
  2. package/INSTALL.md +142 -75
  3. package/README.md +3 -3
  4. package/bin/onboard.mjs +637 -216
  5. package/bin/start.mjs +133 -23
  6. package/bin/update.mjs +82 -11
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +26 -11
  9. package/docs/architecture/05-config-governance.md +17 -7
  10. package/driver/CHANGELOG.md +83 -0
  11. package/driver/band-size.mjs +59 -0
  12. package/driver/config-inventory.mjs +112 -9
  13. package/driver/connotation-search.mjs +45 -0
  14. package/driver/contract-arm2-baseline.json +1 -3
  15. package/driver/contract-e3-backlog.mjs +29 -29
  16. package/driver/contract-vocabulary.mjs +59 -25
  17. package/driver/door-gates.mjs +41 -7
  18. package/driver/driver.config.mjs +272 -59
  19. package/driver/engine/CONTRACT.md +10 -3
  20. package/driver/engine/README.md +2 -2
  21. package/driver/engine/anthropic-agent.mjs +77 -21
  22. package/driver/engine/auth.mjs +129 -10
  23. package/driver/engine/jx-turn.mjs +7 -6
  24. package/driver/engine/mcp/recording-server.mjs +13 -0
  25. package/driver/engine/openai-agent.mjs +4 -2
  26. package/driver/engine/probe.mjs +110 -23
  27. package/driver/findings-model.mjs +1 -1
  28. package/driver/flag-snapshot.mjs +28 -5
  29. package/driver/gateway.mjs +30 -21
  30. package/driver/jx-lanes.mjs +21 -2
  31. package/driver/jx-units.mjs +6 -3
  32. package/driver/jx.mjs +4 -2
  33. package/driver/matter-frame-record.mjs +90 -1
  34. package/driver/named-band.mjs +34 -2
  35. package/driver/package.json +1 -1
  36. package/driver/pipeline.mjs +391 -26
  37. package/driver/portal-config-view.mjs +30 -1
  38. package/driver/portal-report.mjs +15 -1
  39. package/driver/portal-service.mjs +46 -6
  40. package/driver/predelivery-lint.mjs +12 -2
  41. package/driver/publish/index.mjs +46 -5
  42. package/driver/publish/knockout.mjs +10 -1
  43. package/driver/publish/render-knockout.mjs +69 -7
  44. package/driver/publish/render.mjs +170 -59
  45. package/driver/publish/report-data.mjs +4 -1
  46. package/driver/publish/report-topbar.mjs +58 -0
  47. package/driver/publish/templates/report.css +28 -2
  48. package/driver/publish/xlsx.mjs +13 -1
  49. package/driver/register-availability.mjs +2 -2
  50. package/driver/register-coverage.mjs +94 -1
  51. package/driver/register-digest-record.mjs +236 -11
  52. package/driver/register-plan.mjs +170 -0
  53. package/driver/result-noun-fields.mjs +7 -4
  54. package/driver/run-economics.mjs +41 -10
  55. package/driver/run-requirements.mjs +173 -9
  56. package/driver/runner.mjs +3 -3
  57. package/driver/stages.mjs +12 -8
  58. package/driver/suite-census.json +162 -72
  59. package/driver/systemd/README.md +7 -4
  60. package/driver/terminal-clamp.mjs +107 -1
  61. package/driver/tokens.mjs +169 -3
  62. package/driver/unit-environment.mjs +42 -15
  63. package/driver/unit-inventory.mjs +19 -2
  64. package/driver/verify.mjs +50 -5
  65. package/mcp-server/CHANGELOG.md +8 -0
  66. package/mcp-server/http-server.mjs +4 -0
  67. package/mcp-server/lib/audit.mjs +11 -1
  68. package/mcp-server/lib/http-handler.mjs +6 -2
  69. package/mcp-server/package.json +1 -1
  70. package/mcp-server/server.mjs +16 -2
  71. package/package.json +1 -1
  72. package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
  73. package/portal-ui/dist/index.html +1 -1
  74. package/portal-ui/package.json +1 -1
  75. package/providers/clarivate/src/capabilities.js +5 -5
  76. package/providers/clarivate/src/core.js +1 -1
  77. package/providers/corsearch/src/core.js +2 -2
  78. package/providers/jx/README.md +2 -1
  79. package/providers/jx/src/turn-envelope.mjs +8 -3
  80. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  81. package/providers/oauth-mcp-bridge/package.json +1 -1
  82. package/providers/perplexity/src/core.js +3 -3
  83. package/providers/signa/src/capabilities.js +5 -6
  84. package/providers/signa/src/core.js +1 -1
  85. package/providers/uspto-local/README.md +1 -1
  86. package/scripts/authority-boundary-probe.mjs +4 -2
  87. package/scripts/env-audit.mjs +12 -6
  88. package/scripts/freeze-example-run.mjs +49 -16
  89. package/scripts/generated-files-are-current.mjs +69 -4
  90. package/scripts/release-duplicate-notes.mjs +246 -0
  91. package/scripts/release-publish-guard.mjs +64 -6
  92. package/scripts/report-print-check.mjs +194 -0
  93. package/scripts/settings-render-check.mjs +75 -2
  94. package/scripts/test-full.mjs +96 -3
  95. package/scripts/test-run.mjs +10 -0
  96. package/shared/deployment-box.mjs +7 -2
  97. package/shared/driver-dir.mjs +1 -1
  98. package/shared/names-in-force.mjs +1 -1
@@ -87,6 +87,36 @@ export function recordNamesDefect(record) {
87
87
  return typeof record?.defect === "string" && record.defect.trim().length > 0 && record?.delivered === true;
88
88
  }
89
89
 
90
+
91
+ /**
92
+ * The conditions `clientConditions` could NOT render, so the surface does not carry them. PURE.
93
+ *
94
+ * A REFUSAL NOBODY RECORDS IS THE DEFECT ONE LAYER ALONG. The fallback drops a token-bearing reason it
95
+ * cannot compose a sentence for, which is the right answer for the client and a silent loss for
96
+ * everyone else: the condition was in the run record, it applies, and the delivered page no longer says
97
+ * so. Before this, the operator's signal was the voice lint flagging the token on the page. Take the
98
+ * token off the page and that flag goes quiet — the gap would be closed and the disclosure would go
99
+ * with it, with nothing in between to say which. So the drop reports itself here, the lint reads it,
100
+ * and the run record keeps the reason either way.
101
+ *
102
+ * @param {{reasons?: string[], clauses?: string[]}} sidecar the parsed `_driver/verdict.json`
103
+ * @returns {string[]} the run-record reasons that reach no client surface
104
+ */
105
+ // IT DECIDES THE SAME THREE THINGS `clientConditions` DECIDES, and they have to keep agreeing: what
106
+ // counts as a stored clause, what counts as an empty reason, and which reasons the authority can
107
+ // render. The third cannot drift — both call `clauseFromReason` — and the first two are the two lines
108
+ // below. A change to either belongs in both, and arm 9 drives them together against one sidecar.
109
+ export function unrenderableConditions({ reasons, clauses } = {}) {
110
+ const rs = Array.isArray(reasons) ? reasons : [];
111
+ const cs = Array.isArray(clauses) ? clauses : [];
112
+ return rs.filter((r, i) => {
113
+ if (typeof cs[i] === "string" && cs[i].trim()) return false;
114
+ const text = String(r ?? "").trim();
115
+ if (!text) return false;
116
+ return !clauseFromReason(text) && ENGINE_TOKEN_RE.test(text);
117
+ }).map((r) => String(r).trim());
118
+ }
119
+
90
120
  /**
91
121
  * THE LEDE IS THE OPINION'S, BY DECISION AND NOT BY PUSH ORDER. The terminal guards run
92
122
  * earlier in the delivery block than the coverage floor, so their clauses landed at index 0 and the
@@ -107,6 +137,70 @@ export function orderClausesForLede(clauses, reasons, guardSet) {
107
137
  return { clauses: ordered.map((x) => x.c), reasons: ordered.map((x) => x.r) };
108
138
  }
109
139
 
140
+ /**
141
+ * THE CLAUSE AUTHORITY — one composer for the reader's sentence, called both where the numbers are
142
+ * known and where only the run-record reason survives. PURE.
143
+ *
144
+ * A run recorded before clauses were persisted carries `reasons` and no `clauses`, so every condition
145
+ * fell back to the run-record sentence and a republished archived report opened its Conditions list
146
+ * with `floor_duty_undischarged:4 of 430 floor row(s)…` — on the page and in the exported PDF. Dropping
147
+ * the condition instead loses a point the reader must weigh, and re-generating every archived run is
148
+ * not on offer. The third way is this: for both defects that can reach that list the reader's sentence
149
+ * is fully determined by the two numbers the reason already carries in its own prefix. An archived run
150
+ * therefore holds everything needed to compose the client's sentence; what it lacks is only the store.
151
+ *
152
+ * ONE DEFINITION, TWO ENTRY POINTS. The clamp site calls `clauseForDefect` with the counts it already
153
+ * holds; the republish path calls `clauseFromReason`, which reads the same two numbers off the reason's
154
+ * prefix and hands them to the same composer. A second spelling of either sentence would drift, and the
155
+ * drift would be invisible: both surfaces render, and only a reader comparing a fresh run against a
156
+ * republished one would ever see it.
157
+ *
158
+ * AN UNRECOGNISED SHAPE IS REFUSED, AND REFUSED HERE MEANS `null` — NEVER A THROW. This runs on the
159
+ * republish path. A throw there is the failure at the top of this file: a live run died at delivery
160
+ * after 5.55 hours and the client received nothing instead of a report naming one gap. The caller
161
+ * decides what the absence means.
162
+ */
163
+ const CLAUSE_AUTHORITY = Object.freeze({
164
+ synthesis_unaccounted_delivered: (n, m) =>
165
+ `${n} of the ${m} register records this search surfaced are neither addressed as findings nor expressly set aside in this report — they remain open points a reader must weigh`,
166
+ floor_duty_undischarged: (n, m) =>
167
+ `${n} of the ${m} live registrations identical or near-identical to the mark are not individually addressed in this report — each remains an open point a reader must weigh`,
168
+ });
169
+
170
+ /**
171
+ * The reader's sentence for a defect, from its counts. PURE.
172
+ *
173
+ * @param {string} defect the machine token, with or without its `:N` tail
174
+ * @param {number} n the count the defect names
175
+ * @param {number} m the population it is out of
176
+ * @returns {string|null} the client's sentence, or null for a shape this module cannot render
177
+ */
178
+ export function clauseForDefect(defect, n, m) {
179
+ const compose = CLAUSE_AUTHORITY[String(defect ?? "").split(":")[0].trim()];
180
+ if (!compose) return null;
181
+ const a = Number(n), b = Number(m);
182
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
183
+ return compose(a, b);
184
+ }
185
+
186
+ // The run-record reason's own opening: `<token>:<n> of <m> <noun>`. The NOUN is deliberately not part
187
+ // of the match — `record(s)` and `floor row(s)` are the two spellings today, and a third would make
188
+ // this stop recognising a shape it can render perfectly well. The token is what selects the sentence.
189
+ const REASON_PREFIX_RE = /^([a-z][a-z0-9_]*):(\d[\d,]*)\s+of\s+(\d[\d,]*)\s/i;
190
+
191
+ /**
192
+ * The reader's sentence for a condition that has only its run-record reason. PURE.
193
+ *
194
+ * @param {string} reason the run-record sentence
195
+ * @returns {string|null} the client's sentence, or null when the reason is not one this can render
196
+ */
197
+ export function clauseFromReason(reason) {
198
+ const m = String(reason ?? "").match(REASON_PREFIX_RE);
199
+ if (!m) return null;
200
+ const num = (t) => Number(String(t).replace(/,/g, ""));
201
+ return clauseForDefect(m[1], num(m[2]), num(m[3]));
202
+ }
203
+
110
204
  /**
111
205
  * THE CLIENT'S CONDITION LIST, from a verdict sidecar. PURE.
112
206
  *
@@ -135,6 +229,15 @@ export function orderClausesForLede(clauses, reasons, guardSet) {
135
229
  * · a clean reason with no clause survives → break: return "" for a missing clause, arm 2 red
136
230
  * · a legacy sidecar still yields its conditions → break: require the clauses key, arm 3 red
137
231
  * · clauses shorter than reasons loses nothing → break: map over clauses, arm 4 red
232
+ * · a pre-split reason renders as the lawyer's → break: return the reason, arm 5 red
233
+ * · an unrenderable token-bearing reason is gone → break: return it, arm 6 red
234
+ *
235
+ * THE FALLBACK NO LONGER PRINTS THE RUN RECORD. Where no clause was stored, the clause authority above
236
+ * composes one from the reason's own counts; where it cannot, the condition is DROPPED rather than
237
+ * rendered in engine voice. Dropping is a loss and it is the smaller one: a client who reads
238
+ * `floor_duty_undischarged:4` has been handed the engine's private vocabulary as their own advice. A
239
+ * reason carrying no engine identifier is a factual open-state already — the three machinery sites push
240
+ * the reason AS the clause — and it still survives untouched, which is arm 2.
138
241
  *
139
242
  * @param {{reasons?: string[], clauses?: string[]}} sidecar the parsed `_driver/verdict.json`
140
243
  * @returns {string[]} one condition per reason, in the sidecar's own order
@@ -144,6 +247,9 @@ export function clientConditions({ reasons, clauses } = {}) {
144
247
  const cs = Array.isArray(clauses) ? clauses : [];
145
248
  return rs.map((r, i) => {
146
249
  const clause = typeof cs[i] === "string" ? cs[i].trim() : "";
147
- return clause || String(r ?? "").trim();
250
+ if (clause) return clause;
251
+ const text = String(r ?? "").trim();
252
+ if (!text) return "";
253
+ return clauseFromReason(text) ?? (ENGINE_TOKEN_RE.test(text) ? "" : text);
148
254
  }).filter(Boolean);
149
255
  }
package/driver/tokens.mjs CHANGED
@@ -46,10 +46,10 @@
46
46
  import { readdirSync, readFileSync } from "node:fs";
47
47
  import { join } from "node:path";
48
48
  import { driverDir } from "../shared/driver-dir.mjs"; //
49
- import { resolveModel } from "./driver.config.mjs";
49
+ import { resolveModel, modelFamily } from "./driver.config.mjs";
50
50
  import { runLog, note } from "./log.mjs";
51
51
  import { writeRunStatus } from "./progress.mjs";
52
- import { stampRunEconomics, isCodeSide } from "./run-economics.mjs";
52
+ import { stampRunEconomics, isCodeSide, vendorOf } from "./run-economics.mjs";
53
53
 
54
54
  function emptyAcc() {
55
55
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, attempts: 0, thoughtTurns: 0 };
@@ -84,9 +84,27 @@ function modelKey(rec) {
84
84
  // them would break byModel summing to total, and an invisible gap is the failure this file already
85
85
  // fixed once for byEngine), and the key says what is missing rather than asserting an Anthropic
86
86
  // model produced them.
87
+ //
88
+ // A TURN THAT NAMED NO MODEL, a native-language row recording `modelActual: null` (see isAttemptRow),
89
+ // has no id at all. Its tokens still account, under a key that says the model is missing rather than
90
+ // one built from the absent field. Such a row always carries its vendor's stamp, so it reaches here.
91
+ if (typeof rec.model !== "string") return `${engine || "unknown"}/no-model-reported`;
87
92
  return `${engine}/unstamped:${rec.model}`;
88
93
  }
89
94
 
95
+ /**
96
+ * WHETHER A ROW IS A PROVIDER ATTEMPT, the one test rollupTokens and servedModels both apply. A row that
97
+ * names a model is one: every stage attempt row carries the tier it asked for, and a native-language row
98
+ * the id its turn reported. So is a native-language row whose turn ran and named no model, which records
99
+ * `modelActual: null` instead (jxModelFields in jx-lanes.mjs). Without that second half, such a turn's
100
+ * attempt and the tokens it reported were dropped from every total, and a run made only of such turns
101
+ * read as one where nothing was looked at. A row with neither field is not a turn: the run log's events,
102
+ * a tool call, a native-language call no provider served.
103
+ */
104
+ export function isAttemptRow(rec) {
105
+ return Boolean(rec) && (typeof rec.model === "string" || rec.modelActual === null);
106
+ }
107
+
90
108
  // usage shape (gateway.mjs): {input,output,cacheRead,cacheWrite}. There is deliberately NO reasoning-token
91
109
  // field: the old `reasoning`/`reasoningTokens` pair was an unfillable slot — no reasoning count exists
92
110
  // anywhere in the claude payload (not result.usage, not usage.iterations), so no shipped adapter ever
@@ -128,7 +146,7 @@ export function rollupTokens(runDir) {
128
146
  if (!ln.trim()) continue;
129
147
  let rec;
130
148
  try { rec = JSON.parse(ln); } catch { continue; }
131
- if (!rec || typeof rec.model !== "string") continue; // only stage-attempt records carry a model
149
+ if (!isAttemptRow(rec)) continue; // only provider attempts carry tokens (see isAttemptRow)
132
150
  const t = tokensOf(rec.usage);
133
151
  const accs = [total, (byStage[stage] ??= emptyAcc()), (byModel[modelKey(rec)] ??= emptyAcc())];
134
152
  // Engine and billing mode are split out because a token is not a portable unit of cost: a turn on a
@@ -168,6 +186,154 @@ export function rollupTokens(runDir) {
168
186
  return { total, byStage, byModel, byEngine, byAuthMode };
169
187
  }
170
188
 
189
+ /**
190
+ * THE MODELS THAT SERVED THIS RUN, as the engine reported them: distinct ids, in the order each first
191
+ * served a turn. Read from every attempt row's `modelActual`, the id the wire named: the stage rows
192
+ * gateway.mjs writes and the native-language rows jx.mjs and jx-units.mjs write, one list across both. Never
193
+ * the tier a stage asked for in place of a model the wire named: a tier goes to the CLI as the vendor's
194
+ * alias, so the request says nothing about which model ran, and this is the record that does. The tier
195
+ * stands in only for a name no client may read (below).
196
+ *
197
+ * THREE-VALUED. `null` when there is no attempt row to read (no telemetry directory, or no row in it is
198
+ * a provider turn), so nothing was looked at. `[]` when attempt rows exist and none names a served model
199
+ * a client may read (an engine that does not report one, a turn killed before it said, a turn the Claude
200
+ * program answered itself), on a stage turn and a native-language turn alike. One more case reads `[]`:
201
+ * a Claude turn served under a deployment name whose requested tier the tier reader cannot place (a request
202
+ * outside opus, sonnet, haiku and fable) is left off, because the name must not be printed and there is no
203
+ * tier word to print instead. In a run that mixes such turns with others, the list names only the others.
204
+ * So does a turn stamped by an engine whose vendor the closed table in run-economics.mjs does not name:
205
+ * nobody can say whose model served it. An empty list is never a guess.
206
+ *
207
+ * WHAT IS LISTED IS WHAT A CLIENT MAY READ, mapped here and nowhere else (servedName below), so meta.json,
208
+ * report-data.json and the report's closing line carry one list and cannot disagree. Through a cloud, a
209
+ * turn reports either that cloud's spelling of a Claude model or a name the company gave its own
210
+ * deployment. The first is listed as the Claude id it names; the second is listed as the tier the turn
211
+ * asked for ("Opus"), never as the name. The attempt row itself keeps what the program reported.
212
+ */
213
+ export function servedModels(runDir) {
214
+ const dDir = driverDir(runDir);
215
+ let files;
216
+ try { files = readdirSync(dDir).filter((f) => f.endsWith(".jsonl") && f !== "run.jsonl"); }
217
+ catch { return null; }
218
+ const firstSeen = new Map(); // id → the earliest row timestamp that named it
219
+ let attempts = 0;
220
+ for (const file of files) {
221
+ let raw;
222
+ try { raw = readFileSync(join(dDir, file), "utf8"); } catch { continue; }
223
+ for (const ln of raw.split("\n")) {
224
+ if (!ln.trim()) continue;
225
+ let rec;
226
+ try { rec = JSON.parse(ln); } catch { continue; }
227
+ // The same test rollupTokens applies for an attempt row, less the driver's own code-side rows:
228
+ // no provider served those, so they cannot stand for "a turn ran and named no model".
229
+ if (!isAttemptRow(rec) || isCodeSide(rec)) continue;
230
+ attempts += 1;
231
+ const id = typeof rec.modelActual === "string" ? rec.modelActual.trim() : "";
232
+ // `<synthetic>` is the Claude CLI's name for a message it wrote itself, measured in testing on a
233
+ // turn a cloud refused for a missing deployment (2026-09-14). No model served that turn, so a
234
+ // bracketed marker is never listed as one.
235
+ if (!id || /^<.*>$/.test(id)) continue;
236
+ // Keyed on the name a client reads, so two deployments serving one tier, or one model reached
237
+ // through two clouds, are listed once.
238
+ const name = servedName(rec, id);
239
+ if (!name) continue;
240
+ const ts = String(rec.ts ?? "");
241
+ if (!firstSeen.has(name) || ts < firstSeen.get(name)) firstSeen.set(name, ts);
242
+ }
243
+ }
244
+ if (!attempts) return null;
245
+ return [...firstSeen].sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)).map(([id]) => id);
246
+ }
247
+
248
+ // Imported here, beside its one reader: the tier every native-language step asks for (see servedName).
249
+ import { JX_TIER } from "./engine/jx-turn.mjs";
250
+
251
+ // AMAZON'S SPELLING OF A CLAUDE ID: an optional cross-region prefix (`us.`, `eu.`, `apac.`, `global.`), the
252
+ // vendor prefix `anthropic.`, and a version suffix (`-v1:0`), optionally at the end of an inference
253
+ // profile's full address (`arn:aws:bedrock:<region>:<account>:inference-profile/…`), whose account number
254
+ // is the company's and is dropped with the rest. `us.anthropic.claude-opus-4-1-20250805-v1:0` is
255
+ // `claude-opus-4-1-20250805`. Anchored on `anthropic.claude-`, so no other vendor's id is rewritten.
256
+ const AMAZON_CLAUDE_ID_RE = /^(?:arn:aws[\w-]*:bedrock:[^/]*\/)?(?:[a-z]{2,6}(?:-[a-z]+)?\.)?anthropic\.(claude-[a-z0-9.-]+?)(?:-v\d+(?::\d+)?)?$/i;
257
+ // GOOGLE'S SPELLING, `claude-opus-4-1@20250805`. It already names the model; it is listed as the dated id
258
+ // `claude-opus-4-1-20250805` because that is the same model's name on Anthropic's own API and on Amazon's,
259
+ // so a model reached through two routes is one entry rather than two spellings of one model. An older
260
+ // model's Google name carries a version mark before the date (`claude-3-5-sonnet-v2@20241022`), the same
261
+ // mark Amazon writes as `-v2:0`; it is not in the model's own name, so it goes too.
262
+ const GOOGLE_CLAUDE_ID_RE = /^(claude-[a-z0-9.-]+?)(?:-v\d+)?@(\d{8})$/i;
263
+ // THE SHAPE OF A CLAUDE MODEL ID, which is what lets an id be printed as itself. A family and one or two
264
+ // version numbers (`claude-opus-4-1`), or the older order of version before family
265
+ // (`claude-3-5-sonnet`); then an optional date; then an optional context-window mark (`[1m]`), which
266
+ // the program may report beside the model and is kept as reported. Tested lower-cased, after the cloud
267
+ // spellings above are rewritten. A prefix test is not enough: a company may name its own deployment
268
+ // `claude-acme-prod`, or wrap its own name in Amazon's form, and neither is a Claude model. A family with
269
+ // no version, `claude-opus`, names no model either: it is the name an operator types for a deployment of
270
+ // that tier, and printed as itself it would put that name on the report, and a Sonnet deployment's name
271
+ // on a turn that asked for Haiku. A `latest` alias, `-latest` or Google's `@latest`, is a pointer the
272
+ // provider moves, never the name of the model a turn reports, so it reads as the tier too.
273
+ const CLAUDE_MODEL_ID_RE = /^claude-(?:(?:opus|sonnet|haiku|fable)(?:-\d{1,2}){1,2}|\d(?:-\d)?-(?:opus|sonnet|haiku))(?:-\d{8})?(?:\[\d+[km]\])?$/;
274
+ const CLAUDE_TIERS = new Set(["opus", "sonnet", "haiku", "fable"]); // fable is reached through the synthesis override
275
+ // A FABLE REQUEST IS READ HERE, NOT BY modelFamily. modelFamily is also the gateway's family comparison on every
276
+ // turn, and it places opus, sonnet and haiku only: an id naming fable stays unknown there, so it can never
277
+ // refuse a turn (driver.config.mjs says why). The report still needs the tier word of a fable turn served under
278
+ // a company's deployment name, so it is read for the report alone, placed where the family reader places the
279
+ // other three: the whole request, or first in it or after a `/`, with or without `claude-`. So `fable` and a
280
+ // request in a pinned id's spelling (`claude-fable-5-1`) both read fable, and `acme-fable` does not. A turn
281
+ // served as a fable id never reaches this: that id is a Claude model's name and prints as itself. modelFamily is
282
+ // asked first, so a request it places reads as that tier even when it also names fable (`fable-x/sonnet` is
283
+ // Sonnet), and one it cannot place falls through to this reader (`sonnet/fable-x` is Fable).
284
+ const FABLE_REQUEST_RE = /(?:^|\/)(?:claude-)?fable(?:[-.]|$)/i;
285
+ const requestedTier = (asked) =>
286
+ modelFamily(asked) ?? (FABLE_REQUEST_RE.test(String(resolveModel(asked) ?? "")) ? "fable" : null);
287
+
288
+ /** A Claude model id in any cloud's spelling, as its own lower-case name, or null when it is not one. */
289
+ function claudeModelId(id) {
290
+ const raw = String(id ?? "").trim();
291
+ const amazon = AMAZON_CLAUDE_ID_RE.exec(raw);
292
+ const google = amazon ? null : GOOGLE_CLAUDE_ID_RE.exec(raw);
293
+ const named = (amazon ? amazon[1] : google ? `${google[1]}-${google[2]}` : raw).toLowerCase();
294
+ return CLAUDE_MODEL_ID_RE.test(named) ? named : null;
295
+ }
296
+
297
+ /**
298
+ * The name a client reads for one served id, or null when it must not be listed.
299
+ *
300
+ * A CLAUDE ID, in any cloud's spelling, is the model it names. ANY OTHER ID ON A CLAUDE TURN names no
301
+ * Claude model, and on Azure Foundry that is the name a company gave its deployment (`acme-prod-opus`): a
302
+ * company's internal name, and never one to print on its client's report. The turn is listed as the tier
303
+ * it asked for instead, which is what the company deployed under that name.
304
+ *
305
+ * WHOSE TURN IT WAS IS THE VENDOR'S QUESTION, answered by the one closed table of engines (vendorOf in
306
+ * run-economics.mjs), not by a list kept here. An OpenAI turn's id is listed as reported: a Codex id is the
307
+ * model's own name. An Anthropic turn under any engine name is mapped as above; a second list of Claude
308
+ * engines here printed a deployment name, as reported, for every engine it left out. An engine the table
309
+ * does not name is left off: printing its id would make a vendor claim nobody can check.
310
+ *
311
+ * A ROW WITH NO ENGINE STAMP reads as Claude's, as modelKey above reads it, EXCEPT when the id is plainly
312
+ * another vendor's (a `gpt-` or o-series id, by modelFamily's OpenAI reader): printing that as a Claude
313
+ * tier would put a false vendor on a client's report, where a wrong guess in modelKey costs only a key.
314
+ */
315
+ function servedName(rec, id) {
316
+ const claude = claudeModelId(id);
317
+ if (claude) return claude;
318
+ const engine = typeof rec.engine === "string" ? rec.engine : "";
319
+ const family = engine ? null : modelFamily(id);
320
+ const vendor = engine ? vendorOf(engine) : family && !CLAUDE_TIERS.has(family) ? "openai" : "anthropic";
321
+ if (vendor === "openai") return id;
322
+ if (vendor !== "anthropic") return null;
323
+ // THE TIER THE TURN ASKED FOR, told apart by the kind of row, which its engine stamp names. A stage row
324
+ // (engine "anthropic-agent", another Anthropic engine, or no stamp) records that request as `model`
325
+ // ("opus"), and that holds even when the served id is spelled the same as the request, as it is for a
326
+ // deployment named after its tier. A native-language row (engine "anthropic") records its SERVED id as `model`, beside
327
+ // `modelActual` (jxModelFields), so reading it as the request would hand back the deployment name;
328
+ // every one of those steps asks for JX_TIER. A request in a cloud's spelling is read as the Claude id it
329
+ // names first. modelFamily reads opus, sonnet and haiku, and requestedTier adds fable, for the report
330
+ // alone. A tier neither can place returns null and the id is left off: listing nothing is honest, and
331
+ // listing the name is the leak this prevents.
332
+ const asked = engine === "anthropic" ? JX_TIER : rec.model;
333
+ const tier = requestedTier(claudeModelId(asked) ?? asked);
334
+ return CLAUDE_TIERS.has(tier) ? tier[0].toUpperCase() + tier.slice(1) : null;
335
+ }
336
+
171
337
  /**
172
338
  * Stamp the rollup onto the run: the `token-rollup` event in _driver/run.jsonl plus `status.json.tokens`.
173
339
  *
@@ -51,36 +51,63 @@ const OPTIONAL = "-";
51
51
  * @returns {{path: string}|{unresolved: string}}
52
52
  */
53
53
  function expandSpecifiers(raw, home) {
54
- const path = String(raw).replace(/%h/g, home ?? "");
55
- if (!home && /%h/.test(raw)) return { unresolved: raw };
56
- // %% is an escaped percent and is legal; anything else left over is a specifier we do not implement.
57
- const leftover = path.replace(/%%/g, "").match(/%[A-Za-z]/);
58
- return leftover ? { unresolved: raw } : { path };
54
+ // ONE PASS, LEFT TO RIGHT, as systemd reads them. `%%` is an escaped percent and becomes one `%`, so
55
+ // `%%h` is a literal `%h`, never the home. Replacing `%h` first and unescaping after read `%%h` as a
56
+ // `%` followed by the home, and left `50%%` doubled, so a value reached doctor and connect in a form
57
+ // the service was never given. Any other letter after a `%` is a specifier this reader does not
58
+ // implement.
59
+ let unresolved = false;
60
+ const path = String(raw).replace(/%([%A-Za-z])/g, (whole, c) => {
61
+ if (c === "%") return "%";
62
+ if (c === "h" && home) return home;
63
+ unresolved = true;
64
+ return whole;
65
+ });
66
+ return unresolved ? { unresolved: raw } : { path };
59
67
  }
60
68
 
61
69
  /**
62
- * Merge one unit file's environment directives IN FILE ORDER.
70
+ * Merge one unit file's environment directives THE WAY SYSTEMD MERGES THEM: every `Environment=`
71
+ * assignment first, then every `EnvironmentFile=`'s contents over them, the files in the order listed.
63
72
  *
64
- * systemd applies `EnvironmentFile=` and `Environment=` as it encounters them, and a later assignment
65
- * overrides an earlier one. Reading the whole file and applying the two kinds in separate passes would
66
- * be a different resolution order from the one the running service got — which is exactly the class of
67
- * bug this module exists to close, so the order is preserved rather than approximated.
73
+ * WHERE A LINE SITS DOES NOT DECIDE IT. systemd.exec(5) on `EnvironmentFile=`: "Settings from these files
74
+ * override settings made with Environment=." This reader used to apply the two kinds in file order, so a
75
+ * name set by both came back with the unit's value whenever its `Environment=` line followed the file,
76
+ * which is how every shipped unit is written, while the service ran with the file's. A PATH in the
77
+ * settings file was the case that showed: doctor looked for the engine's program on the unit's PATH,
78
+ * found it, and passed a machine whose services would not find it. The renderer's header
79
+ * (driver/systemd/render-units.mjs) states the same rule, and one unit loads no settings file because of it.
80
+ *
81
+ * Within each kind a later assignment still overrides an earlier one.
68
82
  *
69
83
  * @param {string} unitText the unit file's contents
70
84
  * @param {(path: string) => string|null} readEnvFile returns the file's text, or null if unreadable
71
- * @returns {{env: Object, missing: string[]}} `missing` names REQUIRED files that could not be read
85
+ * @returns {{env: Object, missing: string[]}} `missing` names REQUIRED files that could not be read, and
86
+ * assignments whose value carries a specifier that could not be expanded
72
87
  */
73
88
  function applyUnit(unitText, readEnvFile, home) {
74
89
  const env = {};
90
+ const fromFiles = {};
75
91
  const missing = [];
76
92
  for (const raw of String(unitText ?? "").split("\n")) {
77
93
  const line = raw.trim();
78
94
  // `Environment=` may carry several assignments on one line; systemd splits on whitespace.
95
+ //
96
+ // ITS VALUES ARE EXPANDED TOO, by the same rule as a file path. Every shipped unit writes
97
+ // `Environment=PATH=%h/.local/bin:%h/.npm-global/bin:…`, and systemd hands the service that PATH with
98
+ // the home filled in. Passed through as written, it named a folder called `%h/.local/bin` that exists
99
+ // nowhere, so a check that looked for the engine's program on the units' PATH found nothing on a
100
+ // machine whose searches found it and ran. A value that cannot be expanded is a hole in the picture,
101
+ // for the reason the file branch below gives: a literal `%h` answers "absent" for a reader that
102
+ // failed.
79
103
  const direct = /^Environment=(.*)$/.exec(line);
80
104
  if (direct) {
81
105
  for (const pair of direct[1].trim().split(/\s+/)) {
82
106
  const m = /^"?([A-Za-z_][A-Za-z0-9_]*)=(.*?)"?$/.exec(pair);
83
- if (m) env[m[1]] = m[2];
107
+ if (!m) continue;
108
+ const value = expandSpecifiers(m[2], home);
109
+ if (value.unresolved !== undefined) missing.push(`${m[1]}=${value.unresolved} (unresolved systemd specifier)`);
110
+ else env[m[1]] = value.path;
84
111
  }
85
112
  continue;
86
113
  }
@@ -106,10 +133,10 @@ function applyUnit(unitText, readEnvFile, home) {
106
133
  if (!optional) missing.push(path);
107
134
  continue;
108
135
  }
109
- Object.assign(env, parseEnvFile(text));
136
+ Object.assign(fromFiles, parseEnvFile(text));
110
137
  }
111
138
  }
112
- return { env, missing };
139
+ return { env: { ...env, ...fromFiles }, missing };
113
140
  }
114
141
 
115
142
  /**
@@ -143,7 +170,7 @@ export function unitEnvironment({ units = [], readEnvFile = () => null, home = n
143
170
  // name we did not find might live in it — and reporting those as absent would be the original bug
144
171
  // with a smaller blast radius. The whole picture is refused instead.
145
172
  return { known: false, env, read,
146
- why: `the units require environment file(s) this command could not read: ${[...new Set(holes)].join(", ")}` };
173
+ why: `the units require environment file(s) or values this command could not read: ${[...new Set(holes)].join(", ")}` };
147
174
  }
148
175
  return { known: true, env, read, why: null };
149
176
  }
@@ -73,7 +73,24 @@
73
73
  // it for current state.)
74
74
 
75
75
  /** Where a unit is expected to be installed. "none" is a claim, not an absence — see ORPHANED below. */
76
- export const BOXES = Object.freeze(["prod", "test", "dev"]);
76
+ export const BOXES = Object.freeze(["prod", "preprod", "test", "dev"]);
77
+
78
+ /**
79
+ * Whose DECLARED units a box is expected to carry, where that is not its own name.
80
+ *
81
+ * `runsOn` is a MEASURED claim — this file says so in as many words: an entry gains a box the day an
82
+ * enumeration of that box shows the unit, never the day somebody intends it. So pre-prod cannot be
83
+ * written into `runsOn` from a machine that has not enumerated pre-prod, and it must not be: that would
84
+ * turn a measurement into a plan, which is the one thing these entries are not.
85
+ *
86
+ * What CAN be stated from here is the expectation. Pre-prod is a packaged install of the same product on
87
+ * its own account, with the same doors and the same worker, so what it is expected to carry is what
88
+ * production is expected to carry. The expectation derives; the measurement stays measured; and a unit
89
+ * genuinely absent on pre-prod is reported rather than skipped, which is the whole point of the box
90
+ * being able to name itself.
91
+ */
92
+ const EXPECTS_LIKE = Object.freeze({ preprod: "prod" });
93
+ const expectationBox = (box) => EXPECTS_LIKE[box] ?? box;
77
94
 
78
95
  // ── RESOLVED UNITS (ruling 2026-08-25 — option B) ──────────────────────
79
96
  //
@@ -773,7 +790,7 @@ export function unitInventoryVerdict({
773
790
  // unit that is gone from a box is the ruling taking effect, not drift; reporting it as a fault trains
774
791
  // a reader to skim the arm that would have caught a real one. Both are still REPORTED — the
775
792
  // distinction is which of them is a fault.
776
- const declaredHere = (u) => box && u.runsOn.includes(box) && !liveBases.includes(u.unit);
793
+ const declaredHere = (u) => box && u.runsOn.includes(expectationBox(box)) && !liveBases.includes(u.unit);
777
794
  const absent = box
778
795
  ? inventory.filter((u) => declaredHere(u) && !u.retired).map((u) => u.unit).sort()
779
796
  : [];
package/driver/verify.mjs CHANGED
@@ -13,7 +13,8 @@ import { driverDir } from "../shared/driver-dir.mjs"; //
13
13
  import { findReceiptViolations, findGridLedgerViolations, findPlatformIdentityViolations, parsePrRiskQueries, MEANING_SEAT, erroredConnotationQueriesAmong } from "./common-law-receipts.mjs";
14
14
  // Conversion 2 — the discriminator the two rulings above key on. PURE-ish: one existsSync-shaped read.
15
15
  import { matterFrameWasRecorded, frameRatifiedForms } from "./matter-frame-record.mjs";
16
- import { findConnotationViolations, parsePrRiskResults, MEANING_ANGLES_RE,
16
+ import { findConnotationViolations, parsePrRiskResults, prRiskPopulation,
17
+ CONNOTATION_UNMATCHED_MARK, CONNOTATION_NO_RESEMBLANCE_MARK, MEANING_ANGLES_RE,
17
18
  parseDispositionForm, CONNOTATION_UNRULED_REASONS, queryKey } from "./connotation-search.mjs";
18
19
  import { formSidecarName, formSidecarPath } from "./disposition-union.mjs";
19
20
  // B — the transport's own four failure states. The audit reads the run's records; this file locates them.
@@ -41,6 +42,7 @@ import { parseNamedBand, findCollapsedBands } from "./named-band.mjs";
41
42
  import { parseBlindFrameModel } from "./blind-frame-model.mjs";
42
43
  import { parseFrameDiff } from "./frame-diff-model.mjs";
43
44
  import { parseVariantManifestModel, variantRomanizationGaps, variantCompletenessGaps, variantTermShapeGaps } from "./variant-manifest-model.mjs";
45
+ import { digestAccountingGap } from "./register-digest-record.mjs";
44
46
 
45
47
  // ── WS-B: the run-scoped profile sidecar ────────────────────────────────────────────────────────────
46
48
  // _driver/profile.json carries the run's frozen customer values (floor, platform list) for these
@@ -365,6 +367,13 @@ function commonLawMeaningSeat(p, c) {
365
367
  let recordedRaw;
366
368
  try { recordedRaw = parsePrRiskResults(ledgerRaw).map((e) => String(e?.query ?? "")); }
367
369
  catch (e) { return fail(`grid_ledger_unparseable:${String(e.message).slice(0, 80)}`); }
370
+ // THE REFUSAL NAMES THE FILE IT JOINED AGAINST, because "recorded" is not one question in a run.
371
+ // A run answers "what did this half record" in four places that each mean something different — this
372
+ // results ledger, its gap rows, the obligations sidecar, and the final-state receipts audit — and a
373
+ // sentence that says only "recorded" invites a reader to answer from whichever they happen to open.
374
+ // Two readers did exactly that on one clearance and reached three different wrong mechanisms, each
375
+ // from a true measurement of a real record.
376
+ const LEDGER = `common-law-grid.half-${MEANING_SEAT}.json`;
368
377
  const recordedQ = new Set(recordedRaw.map(queryKey));
369
378
  const dropped = dictated.filter((q) => !recordedQ.has(queryKey(q)));
370
379
  if (dropped.length) {
@@ -435,17 +444,27 @@ function commonLawMeaningSeat(p, c) {
435
444
  .flatMap((b) => (Array.isArray(b?.gaps) ? b.gaps : []));
436
445
  const reportedError = new Map(
437
446
  erroredConnotationQueriesAmong(dropped, { gaps: gapRows }).map((e) => [e.query, e.error]));
447
+ // AND IT SAYS WHEN THE READER REDUCED WHAT IT READ. `parsePrRiskResults` folds rows onto the raw
448
+ // query text, so its output is smaller than the ledger whenever a query was recorded twice. Every
449
+ // count taken during one evening's diagnosis was post-fold and nobody had named the raw population,
450
+ // which made "the seat wrote 59 rows" and "59 survived the fold" the same number and different
451
+ // facts: one query recorded twice while another was skipped reads exactly like one simply skipped.
452
+ const pop = prRiskPopulation(ledgerRaw);
453
+ const foldNote = pop.repeated > 0
454
+ ? ` (${LEDGER} carries ${pop.rows} row(s) that fold to ${pop.distinct} distinct query(ies): `
455
+ + `${pop.repeated} repeat a query already counted, so a repeat here may stand where a dictated query is missing)`
456
+ : "";
438
457
  const parts = dropped.slice(0, 3).map((q) => {
439
458
  const reported = reportedError.get(q);
440
459
  // Named separately because the remedy is different: the search was attempted and the provider
441
460
  // declined it, so re-running it unchanged is the one repair that cannot work.
442
- if (reported) return `${abbrev(q, 40)} [the provider REPORTED an error on this query: ${abbrev(reported, 60)}]`;
461
+ if (reported) return `${abbrev(q, 40)} [not recorded in ${LEDGER} because the provider REPORTED an error on it: ${abbrev(reported, 60)}]`;
443
462
  const n = nearest(q);
444
463
  return n
445
- ? `${abbrev(q, 40)} [unmatched; nearest recorded: ${abbrev(n, 40)}]`
446
- : `${abbrev(q, 40)} [no recorded query resembles this one]`;
464
+ ? `${abbrev(q, 40)} ${CONNOTATION_UNMATCHED_MARK} ${abbrev(n, 40)}] in ${LEDGER} — that is evidence a query LIKE it was recorded there, not that these two are the same query`
465
+ : `${abbrev(q, 40)} ${CONNOTATION_NO_RESEMBLANCE_MARK} in ${LEDGER}, which is the only file this gate joins against`;
447
466
  });
448
- return fail(`connotation_query_unrecorded:${parts.join(",")}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
467
+ return fail(`connotation_query_unrecorded:${parts.join(",")}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}${foldNote}`);
449
468
  }
450
469
  if (spec?.connotation?.disposition_required === true) {
451
470
  const recorded = parsePrRiskResults(ledgerRaw);
@@ -2069,6 +2088,32 @@ export const validators = {
2069
2088
  ], "findings+ledger"),
2070
2089
  hasCoverageLedgerRow(c) ? ok() : fail("no_coverage_status_row"));
2071
2090
  if (!structural.ok) return structural;
2091
+ // ── EVERY RECORD THE RUN CARRIED IN ENDS SOMEWHERE — CHECKED AT THE EXIT, NOT ONLY AT THE CALL ──
2092
+ //
2093
+ // The call-time refusal is scoped to the batch it judges, which is what lets a dense band be
2094
+ // recorded at all: a 1,161-record band does not fit in one turn, and the stage failed on one for 35
2095
+ // minutes without writing a document. It buys that at a price, and this is where the price is paid.
2096
+ // Once batch 1 is accepted the findings document EXISTS, so a seat that stopped after batch 6 no
2097
+ // longer fails as a missing artifact — it ships a document holding half the band, with every call it
2098
+ // made reading as accepted. Nothing else would notice: this stage's other arms read the document's
2099
+ // shape, and half a band is the same shape as a whole one.
2100
+ //
2101
+ // ARMED BY THE SAME ERA STAMP as the call-time rule, so an archived run carries no stamp and replays
2102
+ // to the verdict it always had. A STAMPED run whose transport stored no model is a driver fault and
2103
+ // is named as one, on `coverage_form_missing`'s precedent below and for its reason: an absent
2104
+ // artifact must never read as a satisfied one. A throw fails closed for the same reason — this gate
2105
+ // going quiet is indistinguishable from a complete digest, which is the state it exists to refuse.
2106
+ {
2107
+ let gap;
2108
+ try { gap = digestAccountingGap(dirname(p)); }
2109
+ catch (e) { return fail(`registerdigest_accounting_unreadable:${short(String(e?.message ?? e))} (driver-written — this is a bug, not a model defect)`.slice(0, 200)); }
2110
+ if (gap.armed && gap.unaccounted === null)
2111
+ return fail("registerdigest_accounting_unreadable: stamped for per-record accounting with no owed list in the driver's facts (driver-written — this is a bug, not a model defect)");
2112
+ if (gap.armed && gap.no_model)
2113
+ return fail("registerdigest_model_missing: stamped for per-record accounting and the typed transport stored no model, while the findings document exists (driver-written — this is a bug, not a model defect)");
2114
+ if (gap.armed && gap.unaccounted.length)
2115
+ return fail(`registerdigest_unaccounted_records:${gap.unaccounted.length} of ${gap.owed.length} — ${gap.unaccounted.slice(0, 6).join(",")}${gap.unaccounted.length > 6 ? ` (+${gap.unaccounted.length - 6} more)` : ""}`.slice(0, 200));
2116
+ }
2072
2117
  // ── THE COVERAGE FORM, AND THE FOUR STATES THAT ARE NOT THE SAME FACT ──────────────────────────
2073
2118
  // not required — no era stamp: EVERY ARCHIVED RUN, and nothing else since M6. A run
2074
2119
  // whose plan apparatus is out of reach used to land here too; it now gets a
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.8
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.7
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.6
4
12
 
5
13
  No changes in this release.
@@ -362,6 +362,10 @@ if (isMain) {
362
362
  // read inside it, so the factory keeps one rule and the caller names which door it is.
363
363
  createSession: (sessions, scope, owner) => createSession(sessions, scope, owner, { networkDoor: false }),
364
364
  authHeader: AUTH_HEADER, firmDomains: ALLOWED_DOMAINS, log,
365
+ // THE THIRD DOOR, and it has to name itself. It is not the staff surface and it is not the network
366
+ // client surface; a key presented here arrives over a local socket. Until this was passed, every
367
+ // audit line this door wrote carried no door at all and read as the staff surface by elimination.
368
+ door: "key",
365
369
  });
366
370
  openKeyDoor({ handler: keyHandler, path: KEY_SOCKET, log })
367
371
  .catch((e) => { log(`FATAL: could not open the key socket at ${KEY_SOCKET} — ${e.message}`); process.exit(1); });
@@ -40,6 +40,16 @@ export function summarize(body) {
40
40
  return out;
41
41
  }
42
42
 
43
+ /**
44
+ * The value written when a caller names no door. A door-less line USED TO BE POSSIBLE and one writer
45
+ * produced them: the key door built its handler without saying which surface it was, so every line it
46
+ * wrote omitted the field while the lines either side of it carried it. Absence then read as the staff
47
+ * surface, because that was the only other thing it could have been, so the trail quietly attributed a
48
+ * client's calls to staff. The field is now always written and an unnamed door is loud rather than
49
+ * missing — a reader can search for this value, which is not true of a key that is not there.
50
+ */
51
+ export const UNNAMED_DOOR = "unnamed";
52
+
43
53
  export function appendAudit({ email, sub, body, status, transport, door, path = DEFAULT_AUDIT_PATH }) {
44
54
  // `sub` = the inner-token PRINCIPAL (ops-token issuance, INSTALL.md §8) — distinguishes
45
55
  // two automations sharing a transport identity. null for internal/user sessions without a sub claim.
@@ -59,7 +69,7 @@ export function appendAudit({ email, sub, body, status, transport, door, path =
59
69
  // WRITTEN ONLY WHEN GIVEN, the same rule as `transport` and for the same reason: the existing log
60
70
  // shape must not move for records that have no answer to this. A door that does not name itself is a
61
71
  // record with no `door` key, not a record claiming to be from nowhere.
62
- const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}), ...(door ? { door } : {}) }) + "\n";
72
+ const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}), door: door || UNNAMED_DOOR }) + "\n";
63
73
  try { mkdirSync(dirname(path), { recursive: true }); appendFileSync(path, line); } catch { /* best-effort */ }
64
74
  }
65
75