clearotron 0.3.0-beta.2 → 0.3.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/e2e.mjs CHANGED
@@ -1002,11 +1002,59 @@ export function doorUnavailableLabel(a, { max = 90 } = {}) {
1002
1002
  return "no status and no reason recorded";
1003
1003
  }
1004
1004
 
1005
- export function doorAsymmetry(answers) {
1005
+ /**
1006
+ * ── THE ASYMMETRY RULE IS WRITTEN FOR A CASE BOTH DOORS SHOULD REFUSE ───────────────────────────
1007
+ *
1008
+ * "The one that ACCEPTED is the defect" rests on a premise nobody wrote down: that the case EXPECTS a
1009
+ * refusal. Eight of R0's nine cases do, so the premise held by accident for as long as the ninth could
1010
+ * not be measured.
1011
+ *
1012
+ * R0e is the one admit case. Its own `expect.terminal` is `"delivered"` and it asserts the run resolves
1013
+ * to `generic`. On 2026-09-10, the first round in which both doors actually ANSWERED it, the cli door
1014
+ * accepted and delivered `profileKey: "generic"` — the case's whole contract, met — and the ops-mcp door
1015
+ * refused at `assertScopedProfileKey`, a deliberate cross-customer bypass gate doing its job for an
1016
+ * accounts-scoped session. Both doors were right. The rule named the one that behaved.
1017
+ *
1018
+ * The three earlier rounds hid it: the ops-mcp door was lost to the transport every time, so the case
1019
+ * never reached this comparison as two real answers.
1020
+ *
1021
+ * SO THE POLARITY IS NOT INVERTED HERE, and that matters. "On an admit case the door that REFUSED is the
1022
+ * defect" would blame a security gate for holding. What is wrong is the VERDICT, not its direction: on an
1023
+ * admit case this comparison does not know which door is at fault, and it says so instead of guessing.
1024
+ * A case that declares which doors can admit it (`doors`) stops the guess entirely.
1025
+ */
1026
+ export function doorAsymmetry(answers, { expectTerminal = null } = {}) {
1006
1027
  const compared = answers.filter((a) => doorAnswerClass(a) === DOOR_ANSWER.ANSWERED);
1007
1028
  const unavailable = answers.filter((a) => doorAnswerClass(a) === DOOR_ANSWER.INFRA_UNAVAILABLE);
1008
1029
  const agreed = compared.length < 2 ? true : compared.every((a) => a.ok === compared[0].ok);
1009
- return { agreed, compared, unavailable, reducedCoverage: unavailable.length > 0 };
1030
+ return { agreed, compared, unavailable, reducedCoverage: unavailable.length > 0, admit: isAdmitCase(expectTerminal) };
1031
+ }
1032
+
1033
+ /** A case whose own contract is that a door ACCEPTS. `delivered` and `duplicate` both admit; the rest refuse. */
1034
+ export function isAdmitCase(expectTerminal) {
1035
+ const t = String(expectTerminal ?? "").trim().toLowerCase();
1036
+ return t === "delivered" || t === "duplicate";
1037
+ }
1038
+
1039
+ /**
1040
+ * The sentence a disagreement gets — ONE author, because the run printed it and the report pushed it into
1041
+ * `toInvestigate` from two separate literals, and a fix to either would have left the other saying the
1042
+ * old thing.
1043
+ *
1044
+ * `accepted`/`refused` read `ok` at run time and `accepted` on the receipt; both spellings are the same
1045
+ * fact and one formatter has to serve both surfaces or they drift.
1046
+ */
1047
+ export function doorDisagreementLine(caseId, answers, { expectTerminal = null } = {}) {
1048
+ const took = (a) => (a?.ok ?? a?.accepted) === true;
1049
+ const accepted = answers.filter(took).map((a) => a.door);
1050
+ const refused = answers.filter((a) => !took(a)).map((a) => a.door);
1051
+ const both = `accepted by ${accepted.join(", ") || "(none)"}, refused by ${refused.join(", ") || "(none)"}`;
1052
+ if (!isAdmitCase(expectTerminal))
1053
+ return `${caseId}: THE DOORS DISAGREE — ${both}; the door that ACCEPTED is the defect`;
1054
+ return `${caseId}: THE DOORS DISAGREE on a case whose own expect.terminal ADMITS — ${both}. `
1055
+ + `The accepting door met this case's stated contract; the refusing door answered a question the case did not ask. `
1056
+ + `WHICH IS AT FAULT IS NOT DECIDED HERE — a door may refuse for a reason this case cannot satisfy at that door, `
1057
+ + `and naming the accepter would blame the door that behaved. Declare \`doors\` on the case to say which doors can admit it.`;
1010
1058
  }
1011
1059
 
1012
1060
  const enqueue = (job, door) =>
@@ -1115,6 +1163,46 @@ const OPS = {
1115
1163
  length: (v, want) => ({ ok: Array.isArray(v) && v.length === want, saw: Array.isArray(v) ? `${v.length}` : JSON.stringify(v) }),
1116
1164
  };
1117
1165
 
1166
+ /**
1167
+ * The file `delivery-settled` reads, and the ONLY one it reads.
1168
+ *
1169
+ * Named rather than inlined because two places have to agree about it: the op itself, and the check that
1170
+ * asks every scenario whether the path it declared for an op is one that op can read. A literal in both
1171
+ * is the shape that produced the defect this constant exists to prevent.
1172
+ */
1173
+ export const DELIVERY_STATUS_FILE = "status.json";
1174
+
1175
+ /**
1176
+ * Which ops read a FIXED file rather than the path the scenario declares, and what that file is.
1177
+ *
1178
+ * DERIVED CHECKS NEED THIS TABLE, not a hand-written list of known-bad scenarios: a scenario nobody
1179
+ * thought to add is exactly the one that goes unnoticed, and R13 went unnoticed for four rounds.
1180
+ */
1181
+ export const FIXED_FILE_OPS = Object.freeze({ "delivery-settled": DELIVERY_STATUS_FILE });
1182
+
1183
+ /**
1184
+ * Every scenario case whose declared `path` names a file its `op` does not read.
1185
+ *
1186
+ * Not a pass/fail — the op reads the right file either way. It is a REPORT, so a scenario stating a wrong
1187
+ * belief about an op is visible to whoever runs it. Built from the scenario set, never from a list of
1188
+ * names: the point is to find the next one.
1189
+ */
1190
+ export function pathsAnOpDoesNotRead(scenarios) {
1191
+ const out = [];
1192
+ for (const s of scenarios ?? []) {
1193
+ const cases = s?.cases ?? (s?.expect ? [{ id: s.id, expect: s.expect }] : []);
1194
+ for (const c of cases) {
1195
+ for (const a of c?.expect?.assert ?? []) {
1196
+ const fixed = FIXED_FILE_OPS[a?.op];
1197
+ if (!fixed) continue;
1198
+ const declared = String(a?.path ?? "").split(":")[0];
1199
+ if (declared && declared !== fixed) out.push({ scenario: s.id, case: c.id ?? s.id, op: a.op, declared, reads: fixed });
1200
+ }
1201
+ }
1202
+ }
1203
+ return out;
1204
+ }
1205
+
1118
1206
  function evalAssertion(a, runDir) {
1119
1207
  const [file, field] = String(a.path ?? "").split(":");
1120
1208
  const full = join(runDir, file || "");
@@ -1335,8 +1423,21 @@ function evalAssertion(a, runDir) {
1335
1423
  // exist. A run that flipped sendPending without writing one would have passed the old assertion and
1336
1424
  // fails this one.
1337
1425
  if (a.op === "delivery-settled") {
1338
- const st = readJson(full);
1339
- if (!st) return { ok: false, saw: "status.json absent or unparseable" };
1426
+ // — THIS OP READS `status.json`, WHATEVER PATH THE SCENARIO DECLARED, and the error text three
1427
+ // lines below always said so while the read above it took `full`. R13 declared `_driver/delivery.json`,
1428
+ // which parses cleanly and carries no `sendPending`, so the assertion read `undefined`, failed a run
1429
+ // that had delivered correctly, and pushed a product defect that did not exist into `INVESTIGATE`.
1430
+ // It went unseen for four rounds because R13 had been REPORTED once.
1431
+ //
1432
+ // Reading the run's own status is not a correction of the scenario — it is what the op has always
1433
+ // meant. The scenario's `path` is still ANSWERED FOR rather than ignored: `declaredPathNote` says so
1434
+ // in the line, so a scenario stating a belief about this op that is wrong is visible to the reader
1435
+ // who runs it, not only to whoever next reads this function.
1436
+ const st = readJson(join(runDir, DELIVERY_STATUS_FILE));
1437
+ const declaredPathNote = (file && file !== DELIVERY_STATUS_FILE)
1438
+ ? ` — NOTE: this scenario declares \`path: "${file}"\` for \`delivery-settled\`, which reads \`${DELIVERY_STATUS_FILE}\` and nothing else. The declared path was not read.`
1439
+ : "";
1440
+ if (!st) return { ok: false, saw: `${DELIVERY_STATUS_FILE} absent or unparseable${declaredPathNote}` };
1340
1441
  // — THERE IS NO MODE TO READ ANY MORE, and this is the one place where that mattered rather
1341
1442
  // than being a comment fix. This used to open `process.env.CLEAROTRON_DELIVERY || "email"` and take a
1342
1443
  // `sendPending === false` branch for anything that was not `handoff`. With the variable deleted that
@@ -1392,7 +1493,7 @@ function evalAssertion(a, runDir) {
1392
1493
  const { packets, unreadable } = outboxPackets({ runIds: runIdForms(st, runDir) });
1393
1494
  return { ok: st.sendPending === true && packets.length > 0,
1394
1495
  saw: `mode=handoff sendPending=${JSON.stringify(st.sendPending)} packet=${
1395
- packets.length ? packets.join(", ") : unreadable ? `NOT LOOKED FOR — ${unreadable}` : "NONE WRITTEN"}` };
1496
+ packets.length ? packets.join(", ") : unreadable ? `NOT LOOKED FOR — ${unreadable}` : "NONE WRITTEN"}${declaredPathNote}` };
1396
1497
  }
1397
1498
 
1398
1499
  // `absent` — the file must NOT exist, and its absence is the asserted state, not a silent pass.
@@ -1811,7 +1912,7 @@ async function cmdRun(id) {
1811
1912
  // ever wants the launch to stop.
1812
1913
  printPreviousRoundNotice(s);
1813
1914
 
1814
- const jobs = s.job ? [{ id: s.id, job: s.job }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true }));
1915
+ const jobs = s.job ? [{ id: s.id, job: s.job }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true, expectTerminal: (c.expect ?? {}).terminal ?? null }));
1815
1916
 
1816
1917
  // door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the #98
1817
1918
  // asymmetry. Every case goes through EVERY drivable door and the answers are compared. runner.mjs's
@@ -1827,7 +1928,7 @@ async function cmdRun(id) {
1827
1928
  // over whatever was there, so the second run of a pair destroyed the first round's token and the
1828
1929
  // first half became unreportable with nothing saying so.
1829
1930
  const round = { token: RUN_TOKEN, startedAt: new Date().toISOString(), startedAtSource: "run", doors, cases: [] };
1830
- for (const { id: caseId, job, what, oneMatterAcrossDoors } of jobs) {
1931
+ for (const { id: caseId, job, what, oneMatterAcrossDoors, expectTerminal } of jobs) {
1831
1932
  // The round token goes on the BASE ref, so the door suffix (and R0d's opt-out from it) still decides
1832
1933
  // whether the doors are one matter or two. See refForRun and refForDoor.
1833
1934
  const roundRef = refForRun(job.ref);
@@ -1857,7 +1958,7 @@ async function cmdRun(id) {
1857
1958
  console.log(` [${a.door}] ${a.ok ? "accepted" : "refused"}: ${a.out.split("\n").slice(0, 2).join(" ").slice(0, 200)}`);
1858
1959
  }
1859
1960
  }
1860
- if (!agreed) console.log(` ⚠ THE DOORS DISAGREE on this case — the one that ACCEPTED is the defect`);
1961
+ if (!agreed) console.log(` ⚠ ${doorDisagreementLine(caseId, answers, { expectTerminal })}`);
1861
1962
  // — named, never silent. A door lost to the transport costs this case a share of its door
1862
1963
  // coverage, and that is a fact about the ROUND, not about the product. Saying nothing here is how
1863
1964
  // the R0e 429 cost a third of a scenario's coverage without appearing anywhere.
@@ -3083,9 +3184,13 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3083
3184
  .filter((x) => x.out.length);
3084
3185
  console.log(`\ndoors: ${rec.doors.join(", ") || "(not recorded)"} — ${rec.cases.length} case(s), ${dis.length} disagreement(s), ${unavailable.length} case(s) with reduced door coverage`);
3085
3186
  for (const c of dis) {
3187
+ // — the RECEIPT does not carry the case's contract, so it is joined back to the scenario by
3188
+ // id. A receipt written before this field existed reads null and gets the old sentence, which is
3189
+ // the right reading for it rather than a retrospective one.
3190
+ const expectTerminal = ((s.cases ?? []).find((x) => x.id === c.id)?.expect ?? {}).terminal ?? null;
3086
3191
  const accepted = c.answers.filter((a) => a.accepted).map((a) => a.door);
3087
3192
  console.log(` ⚠ ${c.id}: accepted by ${accepted.join(", ") || "(none)"}, refused by ${c.answers.filter((a) => !a.accepted).map((a) => a.door).join(", ") || "(none)"}`);
3088
- toInvestigate.push(`${c.id}: THE DOORS DISAGREE — accepted by ${accepted.join(", ")}; the door that ACCEPTED is the defect`);
3193
+ toInvestigate.push(doorDisagreementLine(c.id, c.answers, { expectTerminal }));
3089
3194
  }
3090
3195
  for (const { c, out } of unavailable) {
3091
3196
  const who = out.map((a) => `${a.door} (${doorUnavailableLabel(a)})`).join(", ");
@@ -221,14 +221,31 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
221
221
  // the truncation as a lower request count — the failure mode that would make this whole check lie in the
222
222
  // safe-looking direction. Wait until the server has been silent for `quiet` ms, capped so a screen that
223
223
  // requests forever fails loudly instead of hanging.
224
- const settle = async ({ quiet = 900, cap = 12000 } = {}) => {
224
+ //
225
+ // SILENCE BEFORE THE FIRST REQUEST IS NOT QUIET, WHERE A REQUEST IS OWED. `Page.navigate` returns as soon
226
+ // as the navigation is committed, long before the app's bundle has run, so the quiet clock above used to
227
+ // start while the server had heard nothing at all — and a first fetch that arrived later than `quiet`
228
+ // closed the window empty. Every request that followed was then counted into the NEXT window, so the
229
+ // whole drive read one bucket late: visit 1 empty, the revisit holding what boot had asked for. On a warm
230
+ // machine the bundle runs in well under a second and it never showed; on a slow runner it failed nine
231
+ // assertions about a product that was behaving. Reproduced by delaying the bundle 1.5s at this fixture.
232
+ //
233
+ // `expectTraffic` is for a window that follows a FULL NAVIGATION — the shell's boot and the Result
234
+ // screen's first visit, the two places this drive calls `Page.navigate`. Each reloads the bundle, so each
235
+ // has the same gap between the navigation committing and the app's first fetch. A client-side visit runs
236
+ // on the bundle already loaded and has no such gap, which is why it is not the default: a revisit is
237
+ // allowed to ask for nothing, and that is the very thing this file measures, so a window there must still
238
+ // be able to close on silence.
239
+ const settle = async ({ quiet = 900, cap = 12000, expectTraffic = false } = {}) => {
225
240
  const started = Date.now()
241
+ const before = hits.length
226
242
  let last = hits.length
227
243
  let lastChange = Date.now()
228
244
  for (;;) {
229
245
  await sleep(100)
230
246
  if (hits.length !== last) { last = hits.length; lastChange = Date.now() }
231
- if (Date.now() - lastChange >= quiet) return true
247
+ const heard = hits.length > before
248
+ if ((heard || !expectTraffic) && Date.now() - lastChange >= quiet) return true
232
249
  if (Date.now() - started >= cap) return false
233
250
  }
234
251
  }
@@ -290,7 +307,8 @@ const record = {}
290
307
  // The shell, once. Everything after this is client-side.
291
308
  epoch = 'boot'
292
309
  await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'revisit-render-check' })
293
- if (!(await settle())) say(false, 'the shell never went quiet within 12s — it is still requesting')
310
+ if (!(await settle({ expectTraffic: true })))
311
+ say(false, 'the shell did not finish loading within 12s — it never reached the fixture server, or never stopped requesting')
294
312
  const bootPath = await where()
295
313
  say(bootPath === '/portal/home', `the shell loaded on /portal/home (got ${bootPath})`)
296
314
 
@@ -384,7 +402,7 @@ await twice('clearances', () => clickNav('Clearances'), '/portal/clearances')
384
402
  // runs AppShell's popstate listener rather than its click funnel.
385
403
  epoch = 'result:1'
386
404
  await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'revisit-render-check' })
387
- const rq1 = await settle()
405
+ const rq1 = await settle({ expectTraffic: true }) // a full navigation, like boot
388
406
  const rp1 = await where()
389
407
  const rt1 = await screenText()
390
408
  say(rp1 === `/portal/result/${RUN_ID}`, `result: visit 1 landed on the run (got ${rp1})`)
@@ -101,7 +101,7 @@ function isPlumbing(node, child) {
101
101
  // A ternary BRANCH is plumbing; a ternary TEST is a decision, and classify() handles that separately.
102
102
  // This was the whole of the unresolved bucket — three sites, all one shape:
103
103
  //
104
- // const wrote = files.length ? files.some(…) : null; gateway.mjs:1407
104
+ // const wrote = files.length ? files.some(…) : null; gateway.mjs (by name)
105
105
  // const inScope = scope.size ? tokens.some(…) : (…); reasoning-tripwires.mjs:82
106
106
  // const reached = b.layer === "national" ? (…) : regions.some(…); register-plan.mjs:318
107
107
  //
@@ -142,7 +142,7 @@ export function storeOutsideRepoMessage({ storeVar, storeDir, repoVar, repoRoot
142
142
  // knows both. So the appender returns a path only when that path is committable, and the core commits
143
143
  // what it is handed.
144
144
 
145
- import { appendFileSync } from "node:fs";
145
+ import { appendFileSync, rmSync } from "node:fs";
146
146
 
147
147
  /**
148
148
  * A repo root is a PATH, and every helper below interpolates it into a git invocation. Anything else
@@ -289,6 +289,39 @@ import { execFileSync } from "node:child_process";
289
289
  export const isTransientGitFault = (detail) =>
290
290
  /index\.lock|another git process seems to be running|Unable to create/i.test(String(detail ?? ""));
291
291
 
292
+ /**
293
+ * Why a commit into `repoRoot` would be refused, asked BEFORE anything is written; null when it would not
294
+ * be. Read-only: `rev-parse` and `git var` change nothing. `{ code, detail, message }`, where `message` names
295
+ * the store and the command that clears it, in terms an operator acts on rather than git's own words:
296
+ *
297
+ * not-a-repository — the store is not inside a git repository this process can use;
298
+ * no-identity — git has no committer identity here. That is the default state of any machine where
299
+ * nobody ran `git config user.email`, a fresh Windows install among them. A save names
300
+ * its author; the COMMITTER is the machine's, and git refuses a commit it cannot name
301
+ * one for. `git -c user.email=…` on a one-off seed commit does not help: `-c` configures
302
+ * that invocation, not the repository, so every save after it fails the same way.
303
+ */
304
+ export function storeCommitRefusal(repoRoot, { env = process.env } = {}) {
305
+ requireRepoRootPath(repoRoot, "storeCommitRefusal");
306
+ const ask = (...args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env });
307
+ const said = (e) => String(e?.stderr || e?.message || e).trim().split("\n").filter(Boolean);
308
+ try { ask("rev-parse", "--git-dir"); }
309
+ catch (e) {
310
+ const detail = said(e)[0]?.slice(0, 200) ?? "";
311
+ const fix = /dubious ownership/i.test(detail)
312
+ ? `run \`git config --global --add safe.directory ${repoRoot}\` as the account the service runs as`
313
+ : `run \`git init\` in ${repoRoot}, or point PROFILE_REPO_ROOT at the repository that holds the store`;
314
+ return { code: "not-a-repository", detail, message: `the store at ${repoRoot} is not a git repository this install can record into (${detail}) — ${fix}` };
315
+ }
316
+ try { ask("var", "GIT_COMMITTER_IDENT"); }
317
+ catch (e) {
318
+ return { code: "no-identity", detail: said(e).pop()?.slice(0, 200) ?? "",
319
+ message: `the store at ${repoRoot} has no git identity, so nothing saved to it can be recorded — run `
320
+ + `\`git -C ${repoRoot} config user.email "you@example.com"\` and \`git -C ${repoRoot} config user.name "Your Name"\`` };
321
+ }
322
+ return null;
323
+ }
324
+
292
325
  export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retries = 3, waitMs = 50 }) {
293
326
  requireRepoRootPath(repoRoot, "makeStoreCommit");
294
327
  const git = (...args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" }).toString().trim();
@@ -309,7 +342,7 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
309
342
  const napping = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* best effort */ } };
310
343
  const detailOf = (e) => String(e?.stderr ?? e?.message ?? e);
311
344
 
312
- return ({ files, message, author }) => {
345
+ const commit = ({ files, message, author }) => {
313
346
  // Asked BEFORE anything composes a diff, so the caller gets the refusal rather than a fallback-mode
314
347
  // parse error. Throwing here also means the write is never followed by a silent half-save: the
315
348
  // caller's own catch is what turns this into a failed save.
@@ -350,6 +383,21 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
350
383
  }
351
384
  }
352
385
  };
386
+ // ── TWO MORE ANSWERS, FOR A CREATE, WHICH IS REFUSED RATHER THAN LEFT HALF-MADE ──────────────────────
387
+ //
388
+ // A save of something that already exists stays as above: live, staged, completed by the next save.
389
+ // A CREATE is different, because "before" exists: the paths it wrote were absent. So a create asks
390
+ // `refusal()` first and writes nothing when the store cannot record it, and when the commit fails anyway
391
+ // (a hook, a full disk), `withdraw(files)` returns those paths to absent: out of the index, so the next
392
+ // save's completion step cannot commit a company that does not exist, and off the disk. Nothing else is
393
+ // touched, and the audit row the create staged stays staged, because it records what happened.
394
+ commit.refusal = () => storeCommitRefusal(repoRoot);
395
+ commit.withdraw = (files) => {
396
+ const paths = (files ?? []).map((f) => resolve(repoRoot, f));
397
+ if (paths.length) git("rm", "--cached", "--quiet", "--ignore-unmatch", "--", ...paths);
398
+ for (const p of paths) rmSync(p, { force: true });
399
+ };
400
+ return commit;
353
401
  }
354
402
 
355
403
  export function resolveStoreRepoRoot({ names, fallback = null, env = process.env } = {}) {