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/INSTALL.md CHANGED
@@ -491,6 +491,25 @@ matches, the neutral Generic default applies.
491
491
  - **Your real customers live outside the repo.** Point `CLEAROTRON_CUSTOMERS_DIR` at your own private
492
492
  config store and the engine loads *those* accounts instead. **Same engine, different config path** —
493
493
  the code carries no customer identities.
494
+
495
+ Two things go with it, and both are refusals rather than preferences:
496
+
497
+ - **`PROFILE_REPO_ROOT` moves too.** The customer directory has to sit inside the repository that
498
+ variable names, because editing a profile is a commit. Point one somewhere new and leave the other
499
+ behind and the portal and the profile service both refuse to start, naming both variables.
500
+ - **That repository needs a `user.name` and a `user.email` of its own.** Saves are committed under
501
+ the name of whoever asked for them, but git also records who *made* the commit, and it will not
502
+ commit at all without one. A service account usually has no global git identity, so a store created
503
+ by hand needs its own:
504
+
505
+ ```bash
506
+ git init -b main /srv/clearotron-store
507
+ git -C /srv/clearotron-store config user.name "clearotron local install"
508
+ git -C /srv/clearotron-store config user.email "clearotron@example.com"
509
+ ```
510
+
511
+ `clearotron start` does this for the store it creates. A store you make yourself does not get it,
512
+ and the symptom is the first save failing at a commit rather than anything about profiles.
494
513
  - **Run data is external too.** Published reports, audits, and per-run state go to the archive pool at
495
514
  `CLEAROTRON_REPORTS_DIR`. Nothing customer-specific is committed to the repository.
496
515
 
package/bin/onboard.mjs CHANGED
@@ -97,7 +97,7 @@ import { processTable } from "../shared/process-table.mjs"; // — /proc is no
97
97
  import { programsFromAnotherCheckout } from "../shared/checkout-move.mjs";
98
98
  import { entrypointOf } from "../driver/systemd/install-census.mjs"; // one ExecStart parser
99
99
  import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
100
- import { whereSavesGo } from "../shared/store-in-repo.mjs"; // — doctor says where a portal save goes once it is committed
100
+ import { whereSavesGo, storeCommitRefusal, storeInRepo, storeOutsideRepoMessage, resolveStoreRepoRoot } from "../shared/store-in-repo.mjs"; // — doctor says where a portal save goes once it is committed, and why saved searches are off
101
101
  import { engineInventory, engineMode, ENGINE_MODES } from "../driver/config-inventory.mjs"; //
102
102
  import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
103
103
  import { runRequiredNames, missingRequirements, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
@@ -1799,6 +1799,41 @@ export async function runCheck() {
1799
1799
  }
1800
1800
  }
1801
1801
 
1802
+ // — SAVED SEARCHES, JUDGED BY THE RULE THE PORTAL APPLIES WHEN IT STARTS. The portal switches them off,
1803
+ // and answers every saved-search route "not found", when the store is unset or sits outside the
1804
+ // repository its saves are committed in. Its boot log said so and nothing an operator runs did, so an
1805
+ // install whose Custom searches could never load passed this command. Same resolver, same containment
1806
+ // check, read from the services' environment for the reason the roster lines give; when that could not
1807
+ // be read, the lines above already said so and nothing is judged here. A store that is configured but
1808
+ // holds a file that cannot be read fails every company's saved searches, in the portal and the
1809
+ // connector alike, so that is read here too.
1810
+ if (!hosted || serviceKnown) {
1811
+ const recipesSet = hosted ? effectiveForService("CLEAROTRON_RECIPES_DIR") : effective("CLEAROTRON_RECIPES_DIR");
1812
+ const recipesDir = recipesSet?.v || null;
1813
+ if (!recipesDir) {
1814
+ info("saved searches are off: CLEAROTRON_RECIPES_DIR is not set, so Custom searches in the portal and the "
1815
+ + "connector offer none. Name a directory inside a git repository to switch them on");
1816
+ } else {
1817
+ // Layered as effectiveForService layers it — this command's environment over the services' file — so
1818
+ // the repository root is read from the same place the store directory above was.
1819
+ const storeEnv = { ...(serviceFileEnv ?? {}), ...process.env };
1820
+ const resolved = resolveStoreRepoRoot({ names: ["RECIPE_REPO_ROOT", "PROFILE_REPO_ROOT"], fallback: REPO, env: storeEnv });
1821
+ const reach = storeInRepo(recipesDir, resolved.root);
1822
+ if (!reach.ok) {
1823
+ warn(`saved searches are OFF: ${storeOutsideRepoMessage({ storeVar: "CLEAROTRON_RECIPES_DIR", storeDir: reach.store, repoVar: "RECIPE_REPO_ROOT", repoRoot: reach.repo })} `
1824
+ + `The repository came from ${resolved.from}. Until this is fixed the portal answers every saved-search request as not found`);
1825
+ } else {
1826
+ const { loadRecipes } = await import("../driver/search-policy.mjs");
1827
+ let unreadable = null;
1828
+ try { loadRecipes({ dir: recipesDir, force: true }); } catch (e) { unreadable = String(e?.message ?? e).split("\n")[0]; }
1829
+ if (unreadable) {
1830
+ warn(`saved searches cannot be read from ${recipesDir}: ${unreadable}. Every company's saved searches fail `
1831
+ + "to load, in the portal and the connector, until that file is fixed");
1832
+ } else ok(`saved searches are read from ${recipesDir}, and saves are committed in ${reach.repo}`);
1833
+ }
1834
+ }
1835
+ }
1836
+
1802
1837
  // — WHICH DOCTRINE FILES THIS INSTALL OVERRIDES, AND WHETHER OURS HAVE MOVED UNDER THEM.
1803
1838
  //
1804
1839
  // The report itself shipped in and worked, reachable only as `npm run doctrine-report` — a name
@@ -1846,7 +1881,21 @@ export async function runCheck() {
1846
1881
  }
1847
1882
 
1848
1883
  say("\n Register provider");
1849
- const prov = effective("CLEAROTRON_DATABASE");
1884
+ // WHAT THE SERVICES WILL SEARCH, READ AS THE ORDER-TIME CHECK BELOW READS IT. This section asked the
1885
+ // shell and this command's .env, so in a fresh terminal on a hosted box it told a working install that
1886
+ // no register was selected, one screen above "nothing a search is refused for is missing from the units'
1887
+ // environment": one install, two answers, and the fix it named was already set. effectiveForService is
1888
+ // the reader that check asks, so the two lines cannot disagree; where it cannot read the units, this
1889
+ // says it could not look rather than reporting the register absent.
1890
+ // Only a HOSTED box has a second environment to read; with no units, the services are started from
1891
+ // this command's own file, and `effective` has always read and labelled exactly that.
1892
+ const serviceValue = hosted ? effectiveForService : effective;
1893
+ const prov = serviceKnown ? serviceValue("CLEAROTRON_DATABASE") : null;
1894
+ if (!serviceKnown) {
1895
+ info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
1896
+ + "which register the services search, and whether its keys are set, is not judged here: a failure to look, not a finding");
1897
+ }
1898
+ else
1850
1899
  // `blocking`, not `warn` and not `problem`. The exit status is a CONTRACT — an absence reports and
1851
1900
  // exits 0, a misconfiguration exits 1, and onboard-wizard.test.mjs holds it — so this cannot become a
1852
1901
  // `problem` however much it stops the reader: an install that has not chosen a register yet is
@@ -1865,7 +1914,7 @@ export async function runCheck() {
1865
1914
  else {
1866
1915
  ok(`${spec.id} — ${spec.label} (${prov.from})`);
1867
1916
  for (const k of spec.credentials) {
1868
- const c = effective(k);
1917
+ const c = serviceValue(k);
1869
1918
  // issue 1871 — SET, not WORKING, and the line now says which. An operator reads a tick as "this
1870
1919
  // works"; this one is equally true of a valid key, an expired key, a key scoped to the wrong
1871
1920
  // account and forty characters of nonsense. --probe-providers is what settles it.
@@ -1876,7 +1925,7 @@ export async function runCheck() {
1876
1925
  // and never as a problem, but never silently either: the reader has to know which offices this
1877
1926
  // box will not reach before they read a report that says nothing was found there.
1878
1927
  for (const k of spec.optionalCredentials ?? []) {
1879
- const c = effective(k);
1928
+ const c = serviceValue(k);
1880
1929
  if (c) ok(`${k} present (${c.from}) — presence only; add --probe-providers to prove it retrieves`);
1881
1930
  else info(`${k} is NOT set — ${spec.id} will run without it and DISCLOSE the offices it cannot reach as deferred coverage. Set it to search them.`);
1882
1931
  }
@@ -1884,8 +1933,10 @@ export async function runCheck() {
1884
1933
  }
1885
1934
 
1886
1935
  say("\n Research provider");
1887
- const px = effective("PERPLEXITY_API_KEY");
1888
- if (px) ok(`PERPLEXITY_API_KEY present (${px.from}) presence only; add --probe-providers to prove it answers`);
1936
+ // The same reader as the register, for the same reason: this is a key the services use.
1937
+ const px = serviceKnown ? serviceValue("PERPLEXITY_API_KEY") : null;
1938
+ if (!serviceKnown) info("the units' environment could not be read, so whether the services hold PERPLEXITY_API_KEY is not judged here — a failure to look, not a finding");
1939
+ else if (px) ok(`PERPLEXITY_API_KEY present (${px.from}) — presence only; add --probe-providers to prove it answers`);
1889
1940
  else info("PERPLEXITY_API_KEY is not set — the three clearance searches carry the common-law grid and cannot switch it off, so a clearance stops before it starts, and names the missing key; a Knockout search still runs and discloses the half it skipped");
1890
1941
 
1891
1942
  // ── — THE LANES A PRODUCT DECLARES IT NEEDS, BEFORE A REPORT NAMES THEM ──
@@ -3644,6 +3695,14 @@ try {
3644
3695
  candidate["CLEAROTRON_CUSTOMERS_DIR"] = join(cfg, "profiles");
3645
3696
  candidate.PROFILE_REPO_ROOT = cfg; // no alias row — this name is current
3646
3697
  for (const k of ["CLEAROTRON_CUSTOMERS_DIR", "PROFILE_REPO_ROOT"]) ok(`${k}=${candidate[k]}`);
3698
+ // A REPOSITORY ALREADY THERE IS ASKED NOW, while the operator is still here, whether it can record a
3699
+ // save. `clearotron start` gives a store it creates an identity of its own; one made by hand has none
3700
+ // unless somebody set it, and on a machine with no global identity the first company created in the
3701
+ // portal is refused. Said here, with the command, rather than discovered on that first company.
3702
+ if (existsSync(join(cfgAbs, ".git"))) {
3703
+ const cannot = storeCommitRefusal(cfgAbs);
3704
+ if (cannot) warn(`${cannot.message}. Until then, creating a company in the portal is refused.`);
3705
+ }
3647
3706
 
3648
3707
  // CLEAROTRON_INSTRUCTIONS_DIR IS DELIBERATELY NOT WRITTEN (found in review).
3649
3708
  //
package/bin/start.mjs CHANGED
@@ -109,7 +109,7 @@ async function runTables() {
109
109
  return { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: RUN_DEFAULT_ENGINE };
110
110
  }
111
111
  import { spawn, spawnSync, execFileSync } from "node:child_process";
112
- import { storeInRepo, storeOutsideRepoMessage } from "../shared/store-in-repo.mjs"; //
112
+ import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
113
113
  import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
114
114
  import { mergeEnvFile } from "../shared/env-file-merge.mjs";
115
115
  import { mcpOriginFor } from "../shared/lane-address.mjs"; // — one author for the origin
@@ -1252,6 +1252,12 @@ if (isMain) {
1252
1252
  // search is what will fail.
1253
1253
  err(` WARNING: could not initialise the saved-search store at ${paths.configStore} (${String(e?.message ?? e)}). Searches will list and run; SAVING one will fail until this is a git repository.`);
1254
1254
  }
1255
+ } else {
1256
+ // AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save. A repository
1257
+ // made by hand has no identity unless somebody gave it one, and on a machine with no global identity
1258
+ // the first company created in the portal is then refused. The one created above sets its own.
1259
+ const cannot = storeCommitRefusal(paths.configStore);
1260
+ if (cannot) err(` WARNING: ${cannot.message}. Until then, creating a company or saving a search is refused.`);
1255
1261
  }
1256
1262
 
1257
1263
  // ── THE DEMO'S OWN STORE, WITH ITS COMPANY IN IT ────────────────────────────────────────────────
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "f24d87c471bd3073f3a56dbf0edff39d1a640175",
3
- "version": "0.3.0-beta.2"
2
+ "commit": "5c5342ab541f8240732467dfc8bbf3c065ef13f6",
3
+ "version": "0.3.0-beta.3"
4
4
  }
@@ -1,5 +1,27 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0-beta.3
4
+
5
+ ### Patch Changes
6
+
7
+ - a782aad: Fixed: Creating a company is refused, with nothing left behind, when the configuration store cannot record it. The company used to be created anyway, with no record of who made it or when, and its organisation was given access to it.
8
+
9
+ A store with no git identity is the usual cause on a new machine, and the refusal names the command that fixes it. Setup and `clearotron start` now check a store they adopt for this straight away.
10
+ - d1ef225: Fixed: A stage stopped at its time limit now records the output it actually produced. It used to record a small fraction, so a stage that was working read as one that had stalled.
11
+
12
+ The token totals `clearotron tokens` reports for runs with a stopped stage now include that output.
13
+ - 0ff42d1: Fixed: `clearotron doctor` now says when saved searches are switched off and why, and when a saved search file cannot be read.
14
+
15
+ An assistant asking for saved searches is told when they could not be read, instead of being told there are none.
16
+ - a782aad: Fixed: `clearotron doctor` reports the register and the research key the background services will use, read from the file they read. Run from a new terminal, it used to say no register was selected on an install whose searches were running.
17
+ - 0ff42d1: Fixed: Global config appears in the account menu only for people who can open it. Someone managing one organisation used to be offered it, and the page then said it was not available.
18
+
19
+ Clearances and People no longer ask for installation-wide data that a manager of one organisation cannot see.
20
+ - a782aad: Fixed: A clearance or knockout searched through Compumark now links each register record to the trade mark office's own page for it. Those records used to show an internal reference nobody could open.
21
+
22
+ The offices linked are the United States, the European Union, the United Kingdom, Canada, Australia, Switzerland, France, Norway, Sweden and WIPO. A record from any other office is cited by its number, and the report says why once, under the findings.
23
+ - a782aad: Fixed: Saved searches work on a fresh install and in the demo, for every company, Generic included. They used to fail to load for every company, with a message saying to try again shortly.
24
+
3
25
  ## 0.3.0-beta.2
4
26
 
5
27
  ### Minor Changes
@@ -1110,6 +1110,11 @@ export const PROVIDERS = {
1110
1110
  office: row?.office ?? null,
1111
1111
  application_date: row?.application_date ?? null,
1112
1112
  registration_date: row?.registration_date ?? null,
1113
+ // The office's own numbers: publish addresses the filing at the office's page from these, the
1114
+ // way it does for this register's clearance findings. Compumark states no filing route or IR
1115
+ // number of its own, so a Madrid designation is addressed as the national filing it is listed as.
1116
+ application_number: row?.application_number ?? null,
1117
+ registration_number: row?.registration_number ?? null,
1113
1118
  // hasPublicRecordUrl:false on this provider — there is no per-record page to link, and a
1114
1119
  // fabricated one would be worse than none.
1115
1120
  record_url: null,
@@ -467,19 +467,32 @@ export const anthropicAgentEngine = {
467
467
  let rateLimitEvent = null; // a 429 session-cap rejection rides a `rate_limit_event` (status:"rejected" + resetsAt)
468
468
  // Streamed-usage accumulator: what the turn PROVABLY moved, observed from the stream itself, so a
469
469
  // killed turn is never journalled as usage:null when millions of tokens moved (the "137 + usage:null
470
- // ⇒ mislabelled transient/lane-wedge" class). Per completed API call the `assistant` event's usage is
471
- // authoritative (summed); an in-flight call contributes its `message_start` usage + the latest
472
- // `message_delta` output count until its own assistant event supersedes them (no double count).
473
- const streamTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
474
- let pendingStart = null, pendingDeltaOut = 0;
470
+ // ⇒ mislabelled transient/lane-wedge" class).
471
+ //
472
+ // KEYED BY MESSAGE, AND EACH FIELD IS THE LARGEST READING SEEN FOR THAT MESSAGE. One API call's usage
473
+ // reaches the stream several times: on `message_start`, on every `assistant` event (one per content
474
+ // block), and on its `message_delta`, whose output count is the call's own running total. Summing the
475
+ // assistant events counted a call's input and cache once per block and its output at whatever it
476
+ // was when the block went out, and dropping the delta lost the rest: a turn killed after 75 calls
477
+ // journalled 810 output tokens. Which event carries the final count need not be known here, and the
478
+ // order is inferred rather than recorded, so every reading is kept and the largest wins. A delta names
479
+ // no message, so it belongs to the one its last `message_start` opened; an assistant event with no id
480
+ // and no open message is a call of its own, as it always was.
481
+ const perMessage = new Map();
482
+ let openMessage = null, anonMessages = 0;
483
+ const foldUsage = (key, u) => {
484
+ if (!u) return;
485
+ const was = perMessage.get(key);
486
+ perMessage.set(key, was
487
+ ? { input: Math.max(was.input, u.input), output: Math.max(was.output, u.output),
488
+ cacheRead: Math.max(was.cacheRead, u.cacheRead), cacheWrite: Math.max(was.cacheWrite, u.cacheWrite) }
489
+ : { input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite });
490
+ };
475
491
  const streamedUsage = () => {
476
- const p = mapUsage(pendingStart) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
477
- const u = {
478
- input: streamTotals.input + p.input,
479
- output: streamTotals.output + p.output + pendingDeltaOut,
480
- cacheRead: streamTotals.cacheRead + p.cacheRead,
481
- cacheWrite: streamTotals.cacheWrite + p.cacheWrite,
482
- };
492
+ const u = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
493
+ for (const m of perMessage.values()) {
494
+ u.input += m.input; u.output += m.output; u.cacheRead += m.cacheRead; u.cacheWrite += m.cacheWrite;
495
+ }
483
496
  u.total = u.input + u.output + u.cacheRead + u.cacheWrite;
484
497
  return u.total > 0 ? u : null; // zero observed movement stays null — a never-admitted turn must keep classifying as a lane wedge
485
498
  };
@@ -703,9 +716,9 @@ export const anthropicAgentEngine = {
703
716
  periodStart = now; periodChunk = chunkSeq;
704
717
  }
705
718
  syncOpenAsk();
706
- const u = mapUsage(ev.message?.usage);
707
- if (u) { streamTotals.input += u.input; streamTotals.output += u.output; streamTotals.cacheRead += u.cacheRead; streamTotals.cacheWrite += u.cacheWrite; }
708
- pendingStart = null; pendingDeltaOut = 0; // this call's usage is now authoritative — drop its partials
719
+ // This block's usage, folded into its own call: the largest reading wins, so a block's early count
720
+ // never replaces the delta's running total, and a second block of the same call adds nothing twice.
721
+ foldUsage(ev.message?.id ?? openMessage ?? `__assistant_${++anonMessages}`, mapUsage(ev.message?.usage));
709
722
  progress();
710
723
  }
711
724
  else if (ev.type === "user") {
@@ -737,8 +750,12 @@ export const anthropicAgentEngine = {
737
750
  }
738
751
  else if (ev.type === "stream_event") {
739
752
  const t = ev.event?.type;
740
- if (t === "message_start") { pendingStart = ev.event.message?.usage ?? null; progress(); }
741
- else if (t === "message_delta") { const o = Number(ev.event?.usage?.output_tokens); if (o > 0) pendingDeltaOut = o; progress(); }
753
+ if (t === "message_start") {
754
+ openMessage = ev.event.message?.id ?? `__start_${++anonMessages}`;
755
+ foldUsage(openMessage, mapUsage(ev.event.message?.usage));
756
+ progress();
757
+ }
758
+ else if (t === "message_delta") { foldUsage(openMessage ?? "__delta_without_start", mapUsage(ev.event?.usage)); progress(); }
742
759
  else if (t === "content_block_delta" || t === "content_block_start") progress();
743
760
  // THINKING GAUGE (partials): the earliest tells. Any one is sufficient; belt-and-braces so a
744
761
  // display-mode or CLI-version change cannot silently blind the gauge.
@@ -1052,6 +1052,10 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1052
1052
  grant: gatherAllowedTools,
1053
1053
  })
1054
1054
  : null;
1055
+ // The start of this attempt's window in the per-run refusal journals. Taken before the witness and
1056
+ // the snapshot, which only widens the window by their own time: an earlier attempt's refusals all
1057
+ // precede its own settle, so they stay outside it.
1058
+ const dispatchedAt = Date.now();
1055
1059
  for (const line of describeMethodologyDrift(witnessStageMethodology(runDir, name, effMessage, engineResolveSkill)))
1056
1060
  note(`[${name}] ${line}`);
1057
1061
  // — the frozen judged-by set, hashed into THIS PROCESS'S MEMORY before the seat runs. Never
@@ -1368,7 +1372,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1368
1372
  // ONE artifact judgement, shared by both rescues below (the rule: never a second, drifting copy of
1369
1373
  // the contract). Present + written by THIS attempt (the per-attempt snapshot — an inherited file and
1370
1374
  // equally an earlier attempt's file never rescue a failed turn) + passes the stage's own validator.
1371
- const attemptWroteTruth = () => {
1375
+ const attemptWroteTruth = (why = null) => {
1372
1376
  // — the rescues judge with `validate` directly rather than through judgeArtifacts, so the union
1373
1377
  // has to run here too or a rescued turn would be refused for rows the form already holds. Idempotent,
1374
1378
  // so the double call on the normal path costs a regeneration and changes nothing.
@@ -1381,16 +1385,21 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1381
1385
  // answer for a killed-in-the-gap attempt is no. Idempotent, so the double call costs a regeneration.
1382
1386
  const pu = syncPlacementForm(files);
1383
1387
  if (pu) lastPlacementUnion = pu;
1388
+ // `why`, when a caller passes one, is told WHICH file failed and on what, so a refusal can name both.
1384
1389
  return files.every((f) => {
1385
1390
  const now = statOf(f);
1386
- if (now === null || now === preArtifact.get(f)) return false; // absent, or not written by this attempt
1387
- if (validate) { const v = validate(f, readFileSync(f, "utf8")); if (!v.ok) return false; }
1391
+ const no = (cause, reason) => { if (why) Object.assign(why, { file: rel(f), cause, ...(reason ? { reason } : {}) }); return false; };
1392
+ if (now === null) return no("absent");
1393
+ if (now === preArtifact.get(f)) return no("not-written-by-this-attempt");
1394
+ if (validate) { const v = validate(f, readFileSync(f, "utf8")); if (!v.ok) return no("invalid", v.reason ? String(v.reason) : undefined); }
1388
1395
  return true;
1389
1396
  });
1390
1397
  };
1391
1398
  let rescued = null;
1392
1399
  let quiescentMs = null; //: how long the artifact had been untouched when the turn settled
1393
- let rescueRefused = null; //: WHICH of the rescue's three causes refused — on the row, not only in a note
1400
+ let rescueRefused = null; //: WHICH of the rescue's four causes refused — on the row, not only in a note
1401
+ let rescueRefusedFile = null, rescueRefusedReason = null; //: and which file, and on what
1402
+ let attemptRefusals = null; //: this dispatch's refusals of a tool-written artifact, on a timeout
1394
1403
  if (fail && /^nonzero_exit_/.test(fail) && files.length) {
1395
1404
  if (killClass || killSeen) {
1396
1405
  note(`[${name}] ${fail} with a kill-class attempt in this ladder — the exit-1 rescue stays CLOSED (a killed turn's artifact may be torn mid-write and a validator cannot prove it whole); failing honestly instead`);
@@ -1427,16 +1436,28 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1427
1436
  //
1428
1437
  // A stage with no validator, or whose artifact fails it, or which never wrote, is untouched.
1429
1438
  else if (fail === "timeout" && turn.signals?.hardWall && files.length && validate && wallRescueEnabled()) {
1430
- // — `unreadable` is tracked as its own fact rather than inferred from the -1 sentinel. The
1431
- // sentinel is not the only way this goes negative: an artifact written in the same instant the turn
1432
- // settled yields a small NEGATIVE elapsed, and the refusal message read that as "artifact
1433
- // unreadable" a diagnosis about the filesystem for a file that was perfectly readable and simply
1434
- // still being written. That is the under-quiescence case, and it is now named as one.
1435
- let unreadable = false;
1436
- const quiet = files.map((f) => { try { return settledAt - statSync(f).mtimeMs; } catch { unreadable = true; return -1; } });
1437
- quiescentMs = quiet.length ? Math.min(...quiet) : null;
1439
+ // — A FILE THAT COULD NOT BE STAT'ED IS NAMED, WITH WHY, rather than folded into a number. It
1440
+ // used to be a -1 in `quiescentMs` and the cause `artifact-unreadable`, and the commonest way to get
1441
+ // there is not a filesystem fault at all: the turn was killed before it wrote. Measured on two
1442
+ // stages of one run, 2026-09-10: both rows read `wrote: false`, `quiescentMs: -1`,
1443
+ // `artifact-unreadable`, for files that did not exist. An ABSENT file (ENOENT) is its own cause now;
1444
+ // `artifact-unreadable` stays for a stat that fails any other way, with its error code. Rows
1445
+ // written before this change say `artifact-unreadable` for both.
1446
+ //
1447
+ // `quiescentMs` IS A MEASUREMENT OR IT IS ABSENT. With a file unstat'able there is no quiescence of
1448
+ // the set to measure, so it stays null and the row omits it: a -1 reads as a number to anything that
1449
+ // averages or compares it. An mtime that lands after the settle instant (written in that same
1450
+ // instant, or a clock step) measures 0 rather than a negative; it is the under-quiescence case.
1451
+ let missing = null;
1452
+ const quiet = [];
1453
+ for (const f of files) {
1454
+ try { quiet.push(Math.max(0, settledAt - statSync(f).mtimeMs)); }
1455
+ catch (e) { if (!missing) missing = { file: rel(f), code: String(e?.code ?? "unknown") }; }
1456
+ }
1457
+ quiescentMs = missing || !quiet.length ? null : Math.min(...quiet);
1438
1458
  const bar = wallRescueQuiesceMs();
1439
- if (quiescentMs >= bar && attemptWroteTruth()) {
1459
+ const why = {};
1460
+ if (quiescentMs !== null && quiescentMs >= bar && attemptWroteTruth(why)) {
1440
1461
  rescued = fail;
1441
1462
  fail = null;
1442
1463
  note(`[${name}] hard-wall kill at ${Math.round(wall)}s, but every expected artifact was written by this attempt, passes its validator and had been untouched for ${Math.round(quiescentMs / 1000)}s when the turn settled — the stage FINISHED and the wall is a fact about the dispatch, not a failure of the stage (the resume's skip path would accept these same bytes)`);
@@ -1451,12 +1472,24 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1451
1472
  // rescue was refused on the third cause, its validator — and 31 minutes of finished work were
1452
1473
  // discarded and re-run cold. `grep -c rescue run.jsonl` on that run returns 0. Nothing in the
1453
1474
  // record said why, and reconstructing it took a file-mtime comparison against a preserved run dir.
1454
- rescueRefused = unreadable ? "artifact-unreadable"
1475
+ rescueRefused = missing ? (missing.code === "ENOENT" ? "artifact-absent" : "artifact-unreadable")
1455
1476
  : quiescentMs < bar ? "under-quiescence"
1456
1477
  : "not-written-by-this-attempt-or-invalid";
1457
- note(`[${name}] hard-wall kill at ${Math.round(wall)}s wall rescue REFUSED (${unreadable ? "artifact unreadable" : quiescentMs < bar ? `artifact touched ${Math.round(quiescentMs / 1000)}s before the kill, under the ${Math.round(bar / 1000)}s quiescence bar` : "not written by this attempt, or fails its validator"}); failing honestly as timeout`);
1478
+ //AND WHICH FILE, AND ON WHAT: an unstat'able file with its error code; under the bar, the file
1479
+ // touched last (`quiescentMs` carries the number); otherwise the first file the artifact judgement
1480
+ // failed, with its cause — `absent`, `not-written-by-this-attempt`, or the validator's own reason.
1481
+ if (missing) { rescueRefusedFile = missing.file; rescueRefusedReason = missing.code; }
1482
+ else if (quiescentMs < bar) rescueRefusedFile = rel(files[quiet.indexOf(quiescentMs)]);
1483
+ else { rescueRefusedFile = why.file ?? null; rescueRefusedReason = why.reason ?? why.cause ?? null; }
1484
+ note(`[${name}] hard-wall kill at ${Math.round(wall)}s — wall rescue REFUSED (${rescueRefused}: ${rescueRefusedFile ?? "no file named"}${rescueRefusedReason ? `, ${rescueRefusedReason}` : quiescentMs < bar ? `, touched ${Math.round(quiescentMs / 1000)}s before the kill, under the ${Math.round(bar / 1000)}s bar` : ""}); failing honestly as timeout`);
1458
1485
  }
1459
1486
  }
1487
+ // — WHAT A KILLED ATTEMPT WAS DOING, where its transport kept a record. A tool-written artifact's
1488
+ // refusal journal says how many times this attempt sent its record and was told no, and the reason the
1489
+ // last time. The missing-file row has carried that last reason since the journal existed; a timeout row
1490
+ // carried nothing, so an attempt that spent forty minutes being refused read the same as one that never
1491
+ // called. Counted from THIS dispatch only: the journal is per run, and every attempt appends to it.
1492
+ if (fail === "timeout" && files.length) attemptRefusals = refusalsInWindow(files, runDir, dispatchedAt, settledAt);
1460
1493
  // Arm the ladder-wide refusal for every LATER attempt. Read before `classifyWedge` below only because
1461
1494
  // the rescue above needs it; the two can never disagree in reach — isTaintRow excludes lane_wedge, and
1462
1495
  // a wedge is a `timeout` fail that breaks the ladder immediately, so no later attempt exists to judge.
@@ -1675,6 +1708,8 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1675
1708
  // arm did not apply (no wall, no validator, no declared file).
1676
1709
  quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined,
1677
1710
  rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
1711
+ rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
1712
+ refusedCalls: attemptRefusals ?? undefined, // — {count, last}: this dispatch's refusals, on a timeout
1678
1713
  //: the verbatim message this attempt was dispatched with — {file, sha, bytes, chars, kind}.
1679
1714
  // null when the run has no directory or the gate is off; {present:false, error} when the write
1680
1715
  // failed. An absence is a record here, never a silence.
@@ -1740,6 +1775,8 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1740
1775
  rescued: rescued ?? undefined, killed: killed || undefined,
1741
1776
  quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
1742
1777
  rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
1778
+ rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
1779
+ refusedCalls: attemptRefusals ?? undefined, // — see the per-stage row
1743
1780
  // — AND THE BILLING PAIR, by the same argument makes for the model pair one field up:
1744
1781
  // the spine carries it or the two logs disagree about what ran. This is the row a sweep across
1745
1782
  // archived runs actually reads, and the question "has this box ever billed API" could not be
@@ -2007,6 +2044,30 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
2007
2044
  return { ok: false, attempts: attempt, fail: lastFail, sessionKey: lastKey, modelWire: lastModelWire, modelUsed: lastModelUsed, attemptFails: [...attemptFails], warmEscalated: warmEscalatedAt > 0 || undefined, quantity: lastQuantity, reads: lastReads, readsTruncated: lastReadsTruncated, warm: lastWarm, wrote: lastWrote, formRepairs: formRepairsUsed };
2008
2045
  }
2009
2046
 
2047
+ /**
2048
+ * One dispatch's refusals, read from the refusal journals of the tool-written artifacts in `files`.
2049
+ * An entry is placed by its `at`; one with no readable `at` cannot be placed in a dispatch, so it is
2050
+ * counted apart rather than guessed into this one.
2051
+ * @returns {{ count: number, last: string|null, unattributed?: number } | null} null when no file in
2052
+ * `files` has a refusal journal, so "this stage keeps no journal" never reads as "nothing was refused".
2053
+ */
2054
+ function refusalsInWindow(files, runDir, from, to) {
2055
+ let journals = 0, count = 0, unattributed = 0, last = null, lastAt = -Infinity;
2056
+ for (const f of files) {
2057
+ const reader = toolWrittenArtifact(f)?.refusals;
2058
+ if (!reader) continue;
2059
+ journals++;
2060
+ for (const r of reader(runDir, f) ?? []) {
2061
+ const at = Date.parse(String(r?.at ?? ""));
2062
+ if (!Number.isFinite(at)) { unattributed++; continue; }
2063
+ if (at < from || at > to) continue;
2064
+ count++;
2065
+ if (at >= lastAt) { lastAt = at; last = String(r?.reason ?? ""); }
2066
+ }
2067
+ }
2068
+ return journals ? { count, last, ...(unattributed ? { unattributed } : {}) } : null;
2069
+ }
2070
+
2010
2071
  function rel(p) {
2011
2072
  const i = p.indexOf("/prelim-search/");
2012
2073
  return i >= 0 ? p.slice(i + 1) : p;
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.2",
5
+ "version": "0.3.0-beta.3",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -4589,7 +4589,7 @@ const PORT = PORT_CHOICE.port;
4589
4589
  const recipeCommit = committing(recipeRepoRoot, "saved-search");
4590
4590
  // — same, against the RECIPE repo root, which may differ from the profile one.
4591
4591
  const recAudit = makeCommittableAudit({ auditPath: recipeAuditPath, repoRoot: recipeRepoRoot });
4592
- const recipes = makeRecipeService({ recipesDir, profileDir, gitCommit: recipeCommit, audit: recAudit });
4592
+ const recipes = makeRecipeService({ recipesDir, profileDir, readLayered: true, gitCommit: recipeCommit, audit: recAudit });
4593
4593
  callRecipes = (method, path, body, identity) => recipes.route(method, path, { email: identity?.email }, body ?? {});
4594
4594
  log(`saved searches ON — store=${recipesDir} repo=${recipeRepoRoot}`);
4595
4595
  } else {
@@ -471,13 +471,41 @@ export function makeProfileService({
471
471
  throw e;
472
472
  }
473
473
 
474
+ // A CREATE THE STORE CANNOT RECORD IS REFUSED, NOT REPORTED AS DONE. This answered 201 with the
475
+ // company live and a `commitError` beside it, so a machine with no git identity got a company with no
476
+ // record of who made it or when, on its first company, and the store move had been made to close
477
+ // exactly that gap. The store is asked first, and a refusal writes nothing: no profile, no audit row
478
+ // and, since the organisation's grant is filed only on a 201, no grant.
479
+ const cannot = typeof gitCommit?.refusal === "function" ? gitCommit.refusal() : null;
480
+ if (cannot) {
481
+ return { status: 409, json: { error: `No company was created: ${cannot.message}, then try again.`, code: `store_${cannot.code.replace(/-/g, "_")}` } };
482
+ }
474
483
  const { files } = writeProfile({ profileDir, key: wanted, profile, contextPack: "" });
475
- // WRITTEN AND RECORDED ARE TWO EVENTS. The write is live the instant it renames; the commit can
476
- // fail on its own. Reporting them as one is how somebody is told nothing happened about a company
477
- // that is already governing runs.
478
484
  const message = `chore(clearotron): create company ${wanted} (via portal, by ${by})`;
479
485
  const { commit, commitError } = commitWithAuditRow({ audit, gitCommit, files, message, by,
480
486
  row: { event: "profile-create", key: wanted, by, fields: Object.keys(profile) } });
487
+ // AND ONE THAT FAILS ANYWAY IS WITHDRAWN. A hook or a full disk can refuse the commit after the store
488
+ // said it could record. The paths this create wrote were absent before it, so they are returned to
489
+ // absent, and a third audit row says so after the two the failed commit left.
490
+ if (commitError) {
491
+ // GIT'S LAST WORD, NOT ITS ECHO. The error opens with the whole command line it ran, message and
492
+ // author included, and ends with the refusal itself: a hook's own sentence, "No space left on
493
+ // device". The operator acts on the last line.
494
+ const cause = String(commitError).trim().split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? String(commitError);
495
+ let left = null;
496
+ try {
497
+ if (typeof gitCommit?.withdraw !== "function") throw new Error("this store cannot withdraw what it wrote");
498
+ gitCommit.withdraw(files);
499
+ } catch (e) { left = String(e?.message ?? e).slice(0, 200); }
500
+ try {
501
+ audit({ event: "profile-create-withdrawn", of: "profile-create", key: wanted, by,
502
+ note: left ? `the create could not be recorded and its files could not be removed (${left})` : "the create could not be recorded, so its files were removed" });
503
+ } catch { /* the refusal below is the report; a journal failure must not mask it */ }
504
+ if (left) {
505
+ return { status: 500, json: { key: wanted, error: `The company could not be recorded (${cause}), and its file could not be removed afterwards (${left}). It is on disk with no record behind it — tell an administrator.` } };
506
+ }
507
+ return { status: 409, json: { error: `No company was created: the store could not record it (${cause}). Nothing was left behind.`, code: "store_commit_failed" } };
508
+ }
481
509
  return { status: 201, json: {
482
510
  key: wanted, name, written: true, created: true, commit,
483
511
  // The receipt reads off THESE, so it can say which framework and how many marketplaces, and
@@ -22,7 +22,7 @@ import { reportIdentityFor, productCoverageNote, isRegisterOnly } from '../searc
22
22
  import { readRecordArtifacts, bindFindingsToRecords, joinEvidenceStatus } from '../registry-fidelity.mjs';
23
23
  import { deliveryFlagLines } from '../predelivery-lint.mjs';
24
24
  import { PROVIDERS, config } from '../driver.config.mjs';
25
- import { recordOriginsFor } from '../record-origins.mjs';
25
+ import { declaredRecordOrigins } from '../record-origins.mjs';
26
26
  import { NEUTRAL_DELIVERY, loadProfiles } from '../profiles.mjs';
27
27
  import { resolveDemoData, demoBannerMd } from './demo-marking.mjs'; // — one demo question, every product; 2134 — every SURFACE
28
28
  import { engineCommit } from '../engine-build.mjs';
@@ -546,7 +546,7 @@ export async function regenSurfaces(poolRoot) {}
546
546
  * all three client surfaces state the same link. That agreement is the point:
547
547
  * a repair that reached the report and not the workbook would put two
548
548
  * different registers in one delivery.
549
- * @param {string[]|null} origins recordOriginsFor(the run's own provider). `null` = the run has no fetch
549
+ * @param {string[]|null} origins declaredRecordOrigins(the run's own provider). `null` = the run has no fetch
550
550
  * receipts (legacy/archived): a NO-OP, byte-identical output, because a run
551
551
  * that never named its register cannot be judged against one. `[]` is a
552
552
  * different answer — this provider publishes no per-record page at all, so no
@@ -773,7 +773,8 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
773
773
  //
774
774
  // So this REPAIRS rather than refuses, keyed on the RUN's own provider (the receipts above), never on
775
775
  // today's CLEAROTRON_DATABASE — a republished archive keeps its own register.
776
- const runOrigins = fetchReceipts ? recordOriginsFor(String(fetchReceipts[0]?.provider ?? '').toLowerCase()) : null;
776
+ // Receipts that name no provider are read as no receipts: the gate is off, as it is for a legacy run.
777
+ const runOrigins = fetchReceipts ? declaredRecordOrigins(fetchReceipts[0]?.provider) : null;
777
778
  const foreignRecordLinks = normalizeRecordLinks(findings, runOrigins);
778
779
  // — the SAME list reaches the renderer. normalizeRecordLinks repairs foreign ABSOLUTE links on
779
780
  // register-sourced findings; render.mjs separately CONSTRUCTS links from bare record paths, and until
@@ -859,7 +860,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
859
860
  const esPath = driverDir(runDir ?? dirname(reportMd), 'enforcer-signals.json');
860
861
  if (existsSync(esPath)) { const es = JSON.parse(readFileSync(esPath, 'utf8')); if (Array.isArray(es)) enforcerSignals = es; }
861
862
  } catch { /* absent — no telemetry lines */ }
862
- const officeLinks = officeLinksFor(findings, recordsByUri, fetchReceipts); bindFindingsToRecords(findings, recordsByUri);
863
+ const officeLinks = officeLinksFor(findings, recordsByUri, runOrigins); bindFindingsToRecords(findings, recordsByUri);
863
864
  // 404-card caveat (2026-07-22): the V4-2 closure pass persisted every cited record its targeted
864
865
  // fetch definitively could not retrieve (predelivery-lint.json artifactSet.recordFetchFailures);
865
866
  // the evidence join stamps `_recordFetchFailure` from it so the card render carries the
@@ -1621,12 +1622,14 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1621
1622
 
1622
1623
  // ── The office's own page for each fetched record, where the run's register publishes none of its own ──
1623
1624
  // Addressed from the record's numbers (office-record-links.mjs), never from the model or the handle, and
1624
- // null on every other register, so those runs publish exactly as before. The tally goes to meta.json and
1625
- // the log, so a register whose numbers never fit shows up as a count rather than as silence. Kept down
1626
- // here, below every line the rest of the tree cites by number.
1625
+ // null on every register that publishes pages of its own, so those runs publish exactly as before. Which
1626
+ // registers those are is `runOrigins`, the same answer the record-URL normalisation above keys on, so the
1627
+ // two cannot disagree about one register. The tally goes to meta.json and the log, so a register whose
1628
+ // numbers never fit shows up as a count rather than as silence. Kept down here, below every line the rest
1629
+ // of the tree cites by number.
1627
1630
  import { recordLinksFor } from './office-record-links.mjs';
1628
- function officeLinksFor(findings, recordsByUri, fetchReceipts) {
1629
- const links = recordLinksFor(findings, recordsByUri, fetchReceipts?.[0]?.provider);
1631
+ function officeLinksFor(findings, recordsByUri, runOrigins) {
1632
+ const links = recordLinksFor(findings, recordsByUri, runOrigins);
1630
1633
  if (links) console.log(`[record-links] ${links.summary}`);
1631
1634
  return links;
1632
1635
  }
@@ -401,8 +401,9 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
401
401
  // THE OFFICE'S OWN PAGE FOR EACH LISTED FILING, where the run's register publishes none of its own:
402
402
  // the addressing the clearance gives its register findings (office-record-links.mjs), set on the
403
403
  // sidecar here for the same reason the normalisation above is. Keyed on the run's own provider, and
404
- // the tally goes to meta.json, so numbers that never fit show as a count rather than as silence.
405
- const officeLinks = addressListedFilings(registerRecords);
404
+ // the tally goes to meta.json, so numbers that never fit show as a count rather than as silence. A
405
+ // sidecar that names no provider is left as it was, as the normalisation above leaves it.
406
+ const officeLinks = addressListedFilings(registerRecords, declaredRecordOrigins(registerRecords?.provider));
406
407
  if (officeLinks) note(`[record-links] ${officeLinks.summary}`);
407
408
 
408
409
  // ── Predelivery lint — the APPLICABLE subset, FLAGS not FAILS (2026-07-31) ─────────────────────────
@@ -753,3 +754,4 @@ export function knockoutDocumentRoutes(reports, { auditFile = null } = {}) {
753
754
  // The office's own page for each listed filing (office-record-links.mjs). Kept down here, below every
754
755
  // line the rest of the tree cites by number.
755
756
  import { addressListedFilings, reasonCellFor } from './office-record-links.mjs';
757
+ import { declaredRecordOrigins } from '../record-origins.mjs';