clearotron 0.3.3-beta.1 → 0.4.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/INSTALL.md +1 -14
  2. package/bin/brandowner.mjs +5 -5
  3. package/bin/connect.mjs +4 -4
  4. package/bin/onboard.mjs +5 -7
  5. package/bin/start.mjs +20 -5
  6. package/build-info.json +2 -2
  7. package/docs/releases/0.3.3.md +52 -0
  8. package/driver/CHANGELOG.md +62 -0
  9. package/driver/company-bundle.mjs +11 -18
  10. package/driver/door-call-verdict.mjs +27 -0
  11. package/driver/driver.config.mjs +1 -2
  12. package/driver/package.json +1 -1
  13. package/driver/pipeline.mjs +14 -5
  14. package/driver/portal-mcp-client.mjs +1 -1
  15. package/driver/portal-request-origin.mjs +79 -0
  16. package/driver/portal-service.mjs +43 -15
  17. package/driver/profile-page.html +9 -13
  18. package/driver/profile-service.mjs +25 -13
  19. package/driver/profiles.mjs +17 -4
  20. package/driver/publish/index.mjs +13 -1
  21. package/driver/publish/render.mjs +13 -2
  22. package/driver/publish/search-depth.mjs +4 -2
  23. package/driver/run-economics.mjs +7 -18
  24. package/driver/stages.mjs +1 -1
  25. package/driver/suite-census.json +61 -31
  26. package/driver/tokens.mjs +18 -10
  27. package/mcp-server/CHANGELOG.md +8 -0
  28. package/mcp-server/lib/audit.mjs +9 -2
  29. package/mcp-server/lib/http-handler.mjs +7 -3
  30. package/mcp-server/mint-token.mjs +8 -6
  31. package/mcp-server/package.json +1 -1
  32. package/mcp-server/server.mjs +10 -0
  33. package/package.json +1 -1
  34. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-DVtz44vH.js} +57 -8
  35. package/portal-ui/dist/index.html +1 -1
  36. package/portal-ui/package.json +1 -1
  37. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  38. package/providers/oauth-mcp-bridge/package.json +1 -1
  39. package/scripts/freeze-example-run.mjs +1 -1
  40. package/scripts/live-surface-check.mjs +11 -2
  41. package/scripts/release-entry-catch-up.mjs +142 -0
  42. package/shared/client-door.mjs +15 -8
  43. package/shared/scope.mjs +25 -10
  44. package/shared/store-in-repo.mjs +38 -17
package/INSTALL.md CHANGED
@@ -647,24 +647,11 @@ matches, the neutral Generic default applies.
647
647
  config store and the engine loads *those* companies instead. **Same engine, different config path** —
648
648
  the code carries no company identities.
649
649
 
650
- Two things go with it, and both are refusals rather than preferences:
650
+ One thing goes with it, and it is a refusal rather than a preference:
651
651
 
652
652
  - **`PROFILE_REPO_ROOT` moves too.** The company directory has to sit inside the repository that
653
653
  variable names, because editing a profile is a commit. Point one somewhere new and leave the other
654
654
  behind and the portal and the profile service both refuse to start, naming both variables.
655
- - **That repository needs a `user.name` and a `user.email` of its own.** Saves are committed under
656
- the name of whoever asked for them, but git also records who *made* the commit, and it will not
657
- commit at all without one. A service account usually has no global git identity, so a store created
658
- by hand needs its own:
659
-
660
- ```bash
661
- git init -b main /srv/clearotron-store
662
- git -C /srv/clearotron-store config user.name "clearotron local install"
663
- git -C /srv/clearotron-store config user.email "clearotron@example.com"
664
- ```
665
-
666
- `clearotron start` does this for the store it creates. A store you make yourself does not get it,
667
- and the symptom is the first save failing at a commit rather than anything about profiles.
668
655
  - **Run data is external too.** Published reports, audits, and per-run state go to the archive pool at
669
656
  `CLEAROTRON_REPORTS_DIR`. Nothing company-specific is committed to the repository.
670
657
 
@@ -160,11 +160,10 @@ export async function add(argv, {
160
160
  contextPack = readFileSync(packFile, "utf8");
161
161
  }
162
162
 
163
- // THE ROSTER IS READ BEFORE THE CANDIDATE IS BUILT, because the Generic default lives in it. The
164
- // candidate is still judged as a member of the roster and not alone — see assertRosterAccepts.
163
+ // The candidate is judged as a member of the roster and not alone — see assertRosterAccepts.
165
164
  const { loadProfiles } = await import("../driver/profiles.mjs");
166
165
  const load = loadProfilesFn ?? loadProfiles;
167
- const platforms = resolvePlatforms(args.platforms, rosterAsItStands(store, load));
166
+ const platforms = resolvePlatforms(args.platforms);
168
167
 
169
168
  const profile = buildProfile({
170
169
  key: args.key, name: args.name.trim(), domains: args.domains,
@@ -180,10 +179,11 @@ export async function add(argv, {
180
179
  : `framework: ${framework.path} — THE GENERIC DEFAULT, applied because none was supplied. `
181
180
  + `Their matters will be rated under it until they give us theirs.`;
182
181
 
182
+ // NONE, when none was supplied (the owner's ruling of 2026-09-23): the brand owner starts with no
183
+ // marketplaces, and the line says what their searches still cover.
183
184
  const platformsLine = platforms.source === "supplied"
184
185
  ? `platforms: ${platforms.platforms.join(", ")} — as supplied`
185
- : `platforms: ${platforms.platforms.join(", ")} — THE GENERIC DEFAULT, applied because none was supplied. `
186
- + `Their searches cover these marketplaces until someone changes them in the portal.`;
186
+ : `platforms: none — searches use the general web, plus any stores chosen for each matter.`;
187
187
 
188
188
  if (args.dryRun) {
189
189
  out(`would create ${join(store, `${args.key}.json`)}`);
package/bin/connect.mjs CHANGED
@@ -135,10 +135,10 @@ export function systemdFailure(e, { step, unit = null } = {}) {
135
135
  const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
136
136
  const ENV_PATH = join(homedir(), ".env");
137
137
  // Where the revocation list lives when this install has never named one — created by connect so the
138
- // door is BORN consulting it (; measured: no denylist is configured on production,
139
- // and `isRevoked()` returns false on an UNSET path — still true, and deliberately so: a deployment that
140
- // never asked for a denylist is not taken down by one. It is an unreadable list that now refuses
141
- //. Assuming a path nobody set still makes every issued key unrevokable).
138
+ // door is BORN consulting it (; measured: no denylist is configured on production).
139
+ // `isRevoked()` now reads this same default when the setting is unset, and an absent default reads as
140
+ // "nothing revoked"; a list that is there and unreadable refuses. Naming the path is still what makes
141
+ // every door, whatever home it runs under, read the file a revocation is written to.
142
142
  // — one owner for this path. It was written out here, in disconnect and twice
143
143
  // in start; `start` named it and created nothing, which is how a revoked key kept answering 200.
144
144
  const DENYLIST_PATH = defaultDenylistPath(homedir());
package/bin/onboard.mjs CHANGED
@@ -3453,10 +3453,8 @@ export async function runCheck() {
3453
3453
  if (r.state === "valid") ok(line);
3454
3454
  else info(line);
3455
3455
  }
3456
- if (report.valid && !String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim()) {
3457
- problem("a valid key is on record but NO revocation list is configured (TRADEMARK_MCP_TOKEN_DENYLIST unset) "
3458
- + `— \`${invoke("disconnect")}\` could not actually revoke it. \`${invoke("connect")}\` arms one; set the variable or reconnect.`);
3459
- }
3456
+ // No "revocation list not configured" problem any more: with TRADEMARK_MCP_TOKEN_DENYLIST unset, every
3457
+ // door reads the install's default list (isRevoked, shared/scope.mjs), which is where disconnect writes.
3460
3458
  // THE PUBLISHED ADDRESS, AND WHETHER IT ANSWERS (acceptance 2). Reported here
3461
3459
  // rather than beside the unit, because the unit running and the address being reachable are
3462
3460
  // different facts and the second is the one a client depends on.
@@ -4496,9 +4494,9 @@ try {
4496
4494
  candidate.PROFILE_REPO_ROOT = cfg; // no alias row — this name is current
4497
4495
  for (const k of ["CLEAROTRON_CUSTOMERS_DIR", "PROFILE_REPO_ROOT"]) ok(`${k}=${candidate[k]}`);
4498
4496
  // A REPOSITORY ALREADY THERE IS ASKED NOW, while the operator is still here, whether it can record a
4499
- // save. `clearotron start` gives a store it creates an identity of its own; one made by hand has none
4500
- // unless somebody set it, and on a machine with no global identity the first company created in the
4501
- // portal is refused. Said here, with the command, rather than discovered on that first company.
4497
+ // save: one this account cannot use refuses the first company created in the portal. Said here, with
4498
+ // the command, rather than discovered on that first company. A missing git identity is not asked
4499
+ // about, because every save supplies the product's own committer.
4502
4500
  if (existsSync(join(cfgAbs, ".git"))) {
4503
4501
  const cannot = storeCommitRefusal(cfgAbs);
4504
4502
  if (cannot) warn(`${cannot.message}. Until then, creating a company in the portal is refused.`);
package/bin/start.mjs CHANGED
@@ -111,7 +111,7 @@ export async function runTables() {
111
111
  return { registers: PROVIDERS, engines: ENGINE_BINARIES, defaultEngine: RUN_DEFAULT_ENGINE, resolveEngine: resolveEngineProgram };
112
112
  }
113
113
  import { spawn, spawnSync, execFileSync } from "node:child_process";
114
- import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
114
+ import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal, storeCommitEnv } from "../shared/store-in-repo.mjs"; //
115
115
  import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
116
116
  import { wslTarget } from "../shared/wsl.mjs"; // — and which distribution a row should start the server in
117
117
  import { ensureDemoProgram } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
@@ -1531,9 +1531,9 @@ if (isMain) {
1531
1531
  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.`);
1532
1532
  }
1533
1533
  } else {
1534
- // AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save. A repository
1535
- // made by hand has no identity unless somebody gave it one, and on a machine with no global identity
1536
- // the first company created in the portal is then refused. The one created above sets its own.
1534
+ // AN ADOPTED STORE IS ASKED WHETHER IT CAN RECORD A SAVE, HERE, not at the first save: a directory
1535
+ // that is not a repository this account can use refuses every company created in the portal. A store
1536
+ // with no git identity is not refused; every save supplies the product's own committer.
1537
1537
  const cannot = storeCommitRefusal(paths.configStore);
1538
1538
  if (cannot) err(` WARNING: ${cannot.message}. Until then, creating a company or saving a search is refused.`);
1539
1539
  }
@@ -1556,7 +1556,7 @@ if (isMain) {
1556
1556
  // and the first save commits them with its own change.
1557
1557
  try {
1558
1558
  execFileSync("git", ["-C", paths.configStore, "add", "-A", "--", "profiles"], { stdio: "ignore" });
1559
- execFileSync("git", ["-C", paths.configStore, "commit", "-q", "-m", "the demo's company"], { stdio: "ignore" });
1559
+ execFileSync("git", ["-C", paths.configStore, "commit", "-q", "-m", "the demo's company"], { stdio: "ignore", env: storeCommitEnv(paths.configStore) });
1560
1560
  } catch { /* see above */ }
1561
1561
  say(` demo store ${paths.profiles} — ${copied.join(", ")}`);
1562
1562
  }
@@ -2298,6 +2298,7 @@ if (isMain) {
2298
2298
  // the `stopping` flag is what stops an orderly stop being reported as a crash.
2299
2299
  process.on("SIGINT", () => { void shutdown(0); });
2300
2300
  process.on("SIGTERM", () => { void shutdown(0); });
2301
+ for (const sig of windowCloseSignals()) process.on(sig, () => { void shutdown(0); });
2301
2302
 
2302
2303
  const healthy = async (url, rec) => {
2303
2304
  const deadline = Date.now() + 30_000;
@@ -2678,6 +2679,20 @@ export function backgroundOfferLines({ demo = false, keep = false, manager = nul
2678
2679
  ];
2679
2680
  }
2680
2681
 
2682
+ /**
2683
+ * The signal that means the window was closed, which a foreground start treats as a stop.
2684
+ *
2685
+ * SIGHUP, on every platform. Closing a terminal sends it, and with no handler Node's default ended this
2686
+ * process on the spot: the teardown never ran, and the children, which lead sessions of their own, never
2687
+ * received the hangup. The portal, the door and the worker went on running with nothing supervising them
2688
+ * and a search in flight carried on (measured on Linux, 2026-09-23), while the banner above says closing
2689
+ * the window stops everything this command started. Windows reports closing the console window as SIGHUP
2690
+ * too.
2691
+ */
2692
+ export function windowCloseSignals() {
2693
+ return ["SIGHUP"];
2694
+ }
2695
+
2681
2696
  /**
2682
2697
  * What a reader is told when a child this install cannot run without exits.
2683
2698
  *
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "d571d0ee20d96c7ce7f9c2c5bee0404db3357467",
3
- "version": "0.3.3-beta.1"
2
+ "commit": "1b114acfd84b91de944063c0f16a99d16652adce",
3
+ "version": "0.4.0-beta.0"
4
4
  }
@@ -0,0 +1,52 @@
1
+ <!-- SPDX-License-Identifier: AGPL-3.0-only -->
2
+ <!-- Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md -->
3
+
4
+ # 0.3.3 — what was checked before it was published
5
+
6
+ Tested with Claude Opus 5.5: faster than Opus 5, and about 30% cheaper. Naming an exact Claude model, Fable
7
+ included, now runs that model; a tier name still follows the newest.
8
+
9
+ A stable version is published only after a beta has run a real clearance end to end and has been
10
+ installed from scratch by somebody who had not used the product before. This is that record for 0.3.3.
11
+ It names what was run and who ran it by role and date, never by name, and it carries no mark and no
12
+ party to a search.
13
+
14
+ ## The beta it was cut from
15
+
16
+ 0.3.3 was cut from 0.3.3-beta.1, published on 23 September 2026 at commit `d571d0ee`, published to the
17
+ beta channel and installed on the staging deployment before this stable was cut.
18
+
19
+ | | |
20
+ |---|---|
21
+ | Candidate | `clearotron@0.3.3-beta.1` |
22
+ | Commit | `d571d0ee20d96c7ce7f9c2c5bee0404db3357467` |
23
+ | Registry integrity | `sha512-50rUxdGJcd6zWPAjNosU8MhMIsJKy8AUFO1UF5uLm4fktXsxbqDdebI13PoWC7OMEzubhP9kyWwHH4V4uOirdQ==` |
24
+
25
+ ## A real clearance, end to end
26
+
27
+ On 23 September 2026 the product's test team ran a full worldwide clearance in four classes against the
28
+ live register service, with Claude Opus 5.5 doing the reasoning, and it ran to a delivered report. It ran
29
+ on the beta's last build before publication, which differs from it only in parts a clearance does not
30
+ run.
31
+
32
+ ## Installs from scratch
33
+
34
+ - **Install to a delivered report.** On 23 September 2026, on 0.3.3-beta.1 (commit `d571d0ee`, registry
35
+ integrity as above), the product's test team installed the published beta as a new user with nothing
36
+ installed, on a Linux machine, following the public instructions and using its own credentials. Setup
37
+ installed the Claude program for that user alone and proved it with one turn, and `doctor` found nothing
38
+ a search would be refused for. A one-name knockout search, worldwide, was delivered **2 minutes 19
39
+ seconds** after it was ordered, with its report and its audit workbook, on Claude Opus 5.5 with no
40
+ retries.
41
+ - **macOS.** An automated job packs the candidate's own bytes, installs them for a user with nothing
42
+ installed, and runs `doctor` and the demo. The release runs it on this stable's bytes before publishing
43
+ them.
44
+
45
+ ## Between the beta and the stable
46
+
47
+ The code is the code 0.3.3-beta.1 shipped. Two commits separate that beta from this stable tag, and
48
+ neither changes what the product does: this record, and the version number itself.
49
+
50
+ That is worth saying because every commit between the beta somebody reviewed and the stable everybody
51
+ installs reaches users without having been reviewed as a release. Here that range is a document and a
52
+ version bump, and it is stated so it can be checked rather than taken on trust.
@@ -1,5 +1,67 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.0-beta.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Before you upgrade: a new operator key must now name the tools it may use, for example `--verbs start_run,stop_run`. Keys already issued keep working.
8
+
9
+ For operators: a revoked key stops working even where no revocation list was set up, because every connector now reads the install's own list.
10
+
11
+ Fixed: over the network, an operator key can no longer start a what-if that its assistant is not shown.
12
+
13
+ Fixed: the portal refuses a change sent from another website, or from another app on the same computer, including a sign-in.
14
+
15
+ ### Patch Changes
16
+
17
+ - Fixed: closing the window that runs `clearotron start` now stops Clearotron. Before, it kept running in the background.
18
+ - Fixed: Creating a company in the portal no longer stops with a request to run a git command.
19
+ - Fixed: A new company starts with no marketplaces. The company page offers the usual ones to add, and a company with none is still searched on the general web.
20
+
21
+ ## 0.3.3
22
+
23
+ ### Patch Changes
24
+
25
+ - Fixed: a run can name a Fable model by its published id, not only by the tier word, which used to fail outright.
26
+ - Fixed: the coverage table names the goods words of a search narrowed by goods, so it no longer reads the same as the main search.
27
+ - Fixed: the portal's run list shows a run's rating, never the reviewer's sign-off word, while the run is still in progress.
28
+ - Fixed: naming an exact Claude model now runs that model, instead of quietly following the tier to a newer one.
29
+ - Fixed: a list of names searched on Signa is now searched one name at a time, so one crowded name no longer stops the rest.
30
+ - Fixed: a register search the provider refuses as too wide is disclosed at once, with its size, instead of pausing the run to retry it.
31
+ - Fixed: the audit workbook now says when a surface refused a search and why, instead of reading the same as one never run.
32
+ - Fixed: the audit workbook now lists each wider register search the engine chose not to run, with the reason it gave.
33
+ - Fixed: a register search that fails on a provider error is tried once more in the run before it is disclosed as not searched.
34
+ - Fixed: a run now records the tier it asked for, not a model version nobody chose. The report still names the model that ran.
35
+ - Fixed: A two-letter mark's register searches, including those narrowed by the client's goods, now run instead of failing where the register's substring search needs three characters.
36
+ - For operators: the run record now distinguishes a question the search step never answered from one it answered with "nothing applies". They previously looked identical, so a step that could not answer looked like a matter with nothing to say.
37
+ - Fixed: a long list of names searched on Clarivate is now split into searches it accepts, not one it refuses as too wide.
38
+ - Fixed: setup and doctor now report a Claude program too old for the models a search asks for, instead of passing it as fine.
39
+ - Fixed: on the OpenAI engine, setup and `doctor --probe-engine` now catch a machine where codex refuses every tool call, before any search is paid for.
40
+
41
+ A search that meets it stops after one attempt and names the setting that fixes it.
42
+ - Fixed: a common-law search no longer repeats a finished stage because its write-up lacked one exact status word.
43
+
44
+ Each coverage status is now recorded as data rather than read from the wording.
45
+ - New: a search whose identical-mark question returns a count rather than a list now narrows that question until the register gives a list, and reads it.
46
+
47
+ It previously left that question unread and searched the wider families instead — compounds, foreign-script forms, neighbour lists — which is where the reading time went.
48
+
49
+ New: the words a search is narrowed to can now be chosen while the search is running, not only when it is first planned.
50
+ - Fixed: doctor no longer ticks a billing mode on a machine where no engine program resolves; it states it as information.
51
+ - Fixed: the identical mark is now read first on every search, before any wider question is asked.
52
+
53
+ New: a search can cover a further category the client's own goods reach, added with a stated reason.
54
+ - Fixed: a search request that describes the goods using the older wording now records those goods. It previously recorded none, so nothing downstream could narrow by what the matter actually covers.
55
+ - Fixed: a search can now be narrowed by what the goods are, which it could not be before. The step that chooses the search words had no way to hand them back, so every search ran without them.
56
+ - Fixed: the worker now reports itself alive throughout a search, not only between searches. Its liveness file went stale for the whole of a long search. A check reading it would call a healthy search dead, and might stop it.
57
+ - For operators: the published package now has a recorded size budget. Nothing about what ships changes; growth past a margin fails the build and names the largest files.
58
+ - Fixed: setup confirms a pasted key by its length alone, so no part of a key reaches a captured install log.
59
+ - Fixed: setup installs a version of the Claude program new enough to run the current top-tier model, which older versions refuse.
60
+ - Fixed: a territory name that no register can answer is now reported as an uncovered gap instead of being searched.
61
+ - Fixed: the assistant's run summary names the rating again, where it had printed "[object object]" in its place.
62
+ - Fixed: the clearance list is now requested at the same time as your sign-in details, instead of waiting for them.
63
+ - Fixed: the delivery email, run list and assistant now give the rating and the report's own conclusion. None of them says a matter is on hold.
64
+
3
65
  ## 0.3.3-beta.1
4
66
 
5
67
  ### Patch Changes
@@ -99,28 +99,21 @@ export function resolveFramework(requested, {
99
99
 
100
100
  // ── the profile this writes ────────────────────────────────────────────────────────────────────────
101
101
  /**
102
- * Which marketplaces this brand owner's searches cover — supplied, or the Generic default, SAID OUT LOUD.
102
+ * Which marketplaces this brand owner's searches cover: the ones supplied, or NONE.
103
103
  *
104
- * A customer bundle is a COMPLETE document in this design, not an overlay on generic: every shipped
105
- * profile carries its own `platforms`, and the loader requires a non-empty array on every file. The
106
- * command had no way to supply one and set none, so every bundle it wrote failed to load on this field
107
- * as well as on the `key` field above — two independent invalidities, and onboarding could not produce
108
- * a loadable brand owner at all.
104
+ * A customer bundle is a COMPLETE document in this design, not an overlay on generic: every profile
105
+ * carries its own `platforms` array, and the loader requires one on every file.
109
106
  *
110
- * Defaulting rather than refusing, and naming it, is this command's own established idiom: the same
111
- * ruling governs the framework one function down. Which marketplaces a client's clearance searches is
112
- * not a detail to decide silently, so an operator who supplies nothing is TOLD what they got and can
113
- * refine it in the portal.
107
+ * NONE, NOT THE GENERIC DEFAULT, by the owner's ruling of 2026-09-23. A company created with only a name
108
+ * used to be given the Generic default's marketplaces, which no screen listed and which the company page
109
+ * then refused to let anybody remove. A new company now starts with an empty list and somebody picks
110
+ * from the Generic default's marketplaces, which the portal offers as suggestions. With an empty list the
111
+ * general web search and the meaning checks still run, and so do any stores the engine chooses for a matter
112
+ * (pipeline.mjs deriveGridSpec).
114
113
  */
115
- export function resolvePlatforms(supplied, roster) {
114
+ export function resolvePlatforms(supplied) {
116
115
  if (supplied?.length) return { platforms: supplied, source: "supplied" };
117
- const house = roster?.get?.("generic")?.platforms ?? [];
118
- if (!house.length)
119
- throw new Refusal(
120
- "no --platforms was given and the Generic default carries none, so there is nothing to onboard this "
121
- + "brand owner with. Pass --platforms, or repair the generic profile in the store.",
122
- { code: "no_marketplaces" });
123
- return { platforms: [...house], source: "house default" };
116
+ return { platforms: [], source: "none" };
124
117
  }
125
118
 
126
119
  export function buildProfile({ key, name, domains, platforms, framework, industry,
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // A CALL TO THE OPS DOOR THAT THE DEPLOYMENT CHECK COULD NOT MAKE — "roster resolves" (list_profiles) and
5
+ // "ops-MCP reachable" (describe_options). Extracted so it can be driven without a door to call, as
6
+ // plan-run-agreement-verdict.mjs was for the third call.
7
+ //
8
+ // Both rows used to FAIL on every error but an unset door, so the check exited 1, "drifted", when nothing
9
+ // had been compared. Measured on pre-prod 0.3.3-beta.0: both rows FAILed on a 401, the door refusing the
10
+ // check's own key. On the test box the same door's rate limit (429) refused the third call of a burst of
11
+ // checks; that was the plan_run call, and these two go to the same door.
12
+ //
13
+ // THREE ANSWERS MEAN THE CHECK DID NOT LOOK: a rate limit (429), a refusal of this check's own key (401,
14
+ // 403), and a request that got no answer in time. Each is a marked skip, which exits 3. Everything else
15
+ // stays a FAIL: a 5xx or a malformed answer is the door answering badly. A door with nothing listening
16
+ // at all stays a FAIL too, because whether that is a finding is not settled, and this does not settle it.
17
+
18
+ /**
19
+ * @param {Error & {status?: number|null, timedOut?: boolean}} e what the call threw
20
+ * @param {{ asked: string, notCompared: string }} what the tool asked, and what was therefore not compared
21
+ * @returns {{state: "skip", blocked: true, message: string} | null} null: report it as the FAIL it was
22
+ */
23
+ export function doorCallVerdict(e, { asked, notCompared }) {
24
+ const refused = e?.status === 429 || e?.status === 401 || e?.status === 403 || e?.timedOut === true;
25
+ if (!refused) return null;
26
+ return { state: "skip", blocked: true, message: `could not ask ${asked}, so ${notCompared} — ${String(e?.message ?? e).slice(0, 200)}` };
27
+ }
@@ -693,8 +693,7 @@ export const MODELS = {
693
693
  // alias → full id; a value that's already a full provider/model id (contains "/") passes through.
694
694
  //
695
695
  // A BARE Anthropic id (dated or not — "claude-haiku-4-5-20251001", "claude-opus-5") normalises to the
696
- // catalog form too. The direct-API lanes (jx completions/judge/nativeread, driver.config JX_PROVIDERS)
697
- // name their model that way because that is what the Messages API takes, so without this one model named
696
+ // catalog form too. Without this, one model named
698
697
  // in two spellings — dated and undated — would key apart in a rollup. The date suffix is dropped;
699
698
  // anything that does not look like a bare claude id is returned untouched, so a genuinely unknown model
700
699
  // still keys as-is rather than being guessed at.
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.3-beta.1",
5
+ "version": "0.4.0-beta.0",
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": {
@@ -76,7 +76,7 @@ import { armCoverageForm, coverageFormInput, coverageFormPaths, coverageFormStam
76
76
  import { unionPlacementForm } from "./placement-union.mjs";
77
77
  import { readPlacementForm, readPlacementFormInput, writePlacementForm } from "./placement-form-io.mjs";
78
78
  import { dictatedPaths, findStrayArtifacts, treeSnapshot, findStrayInTree, matterSiblings, findStrayMatterSiblings } from "./stray-artifacts.mjs"; // — a run dir holds no document no stage dictated; — nor does the doctrine tree
79
- import { resolveProfile, resolveEffectiveProfile, derivedFloor, derivedBatchSize, applicantMatchesProfile, NEUTRAL_DELIVERY, deliveryForRun, recipeProseGuard, withRunPlatforms, profileStoreResolution } from "./profiles.mjs"; // adds profileStoreResolution — the CONFIG store's identity, beside the doctrine tree's
79
+ import { resolveProfile, resolveEffectiveProfile, derivedFloor, derivedBatchSize, gridBatchFor, applicantMatchesProfile, NEUTRAL_DELIVERY, deliveryForRun, recipeProseGuard, withRunPlatforms, profileStoreResolution } from "./profiles.mjs"; // adds profileStoreResolution — the CONFIG store's identity, beside the doctrine tree's
80
80
  import { resolveSearchPolicy, gateResolvedPolicy, loadRecipes, policyFor, isRegisterOnly, reportIdentityFor, depthFor } from "./search-policy.mjs";
81
81
  import { profileOrdinals } from "./profile-selection.mjs"; // lever 3 — driver selection
82
82
  // THE OFFERING'S own sentence about where the native-language investigation can be bought. It reaches a
@@ -979,7 +979,12 @@ function deriveGridSpec(ctx) {
979
979
  terms: null,
980
980
  spec_inputs: { registerOnly: Boolean(ctx.registerOnly), gridVariants: ctx.gridVariants?.length ?? 0, profilePlatforms: ctx.profile?.platforms?.length ?? 0 },
981
981
  };
982
- if (!ctx.registerOnly && ctx.gridVariants?.length && ctx.profile?.platforms?.length) {
982
+ // AN EMPTY MARKETPLACE LIST STILL WRITES A GRID. A company may pick no marketplaces (the owner's ruling of
983
+ // 2026-09-23), and the general-web cell and the meaning sweep ride in this spec: gating it on a non-empty
984
+ // list switched both off with the stores, and the downgrade clamp then read the missing spec as a failed
985
+ // sweep. The spec below is then the web cell plus whatever channels the matter frame names. Only a profile
986
+ // with no list at all (legacy) takes the spec-less path.
987
+ if (!ctx.registerOnly && ctx.gridVariants?.length && Array.isArray(ctx.profile?.platforms)) {
983
988
  const gridSpecPath = P.gridSpec;
984
989
  // #5 — required channels: a NAMED profile's curated platforms are authoritative. The GENERIC fallback
985
990
  // derives the channels from the MATTER FRAME's industry/goods reasoning (its "Search channels:" line) so
@@ -1041,7 +1046,8 @@ function deriveGridSpec(ctx) {
1041
1046
  terms: ctx.gridVariants,
1042
1047
  platforms: [...channels, "web"], // the dictated channels + the general-web cell
1043
1048
  output_path: P.commonLawGrid,
1044
- batch: ctx.profile?.batchSize ?? 14,
1049
+ // SIZED BY THE CELLS THIS GRID RUNS, never larger than the profile's own figure (gridBatchFor).
1050
+ batch: gridBatchFor(ctx.profile, channels.length + 1),
1045
1051
  // disposition_required (P2-C §8b leg 2): the receipt-presence stamp arming the commonLaw validator's
1046
1052
  // receipts-disposition arm (the D1 ledger_required pattern — every fresh spec carries it; pre-P2-C
1047
1053
  // archived specs lack it, so replay verdicts never flip). splitGridSpec spreads the connotation
@@ -10591,10 +10597,13 @@ async function pipelineInner(job, opts = {}) {
10591
10597
  // back-compat: a pre-doc-35 receipt keyed only on the cell set (no source channels)
10592
10598
  (priorReceipt.sig == null && !sourceChannels.length &&
10593
10599
  JSON.stringify((priorReceipt.requested ?? []).map(cellKey).sort()) === JSON.stringify(closable.map(cellKey).sort())));
10600
+ // SIZED BY THE CELLS A CLOSURE GRID RUNS, as the main grid is (gridBatchFor): its spec carries only the
10601
+ // platforms of the cells it closes.
10602
+ const closureBatch = (cells) => gridBatchFor(ctx.profile, new Set(cells.map((c) => c.platform)).size);
10594
10603
  if ((closable.length || sourceChannels.length) && !alreadyAttempted) {
10595
10604
  const requested = closable;
10596
10605
  const variants = [...new Set(closable.map((c) => c.variant))];
10597
- const batchSize = ctx.profile?.batchSize ?? 14; // WS-B: derived from the profile's platform count
10606
+ const batchSize = closureBatch(closable);
10598
10607
  const batches = Math.max(1, Math.ceil(variants.length / batchSize));
10599
10608
  const gridCalls = batches + (sourceChannels.length ? 1 : 0); // grid call(s) + a channel-sweep call (tokens-only: no $ estimate)
10600
10609
  note(`coverage closure: ${closable.length} machine-closable cell(s)${sourceChannels.length ? ` + ${sourceChannels.length} un-swept in-scope channel(s) [${sourceChannels.join(", ")}]` : ""} — one supplementary grid pass (${gridCalls} grid call(s))`);
@@ -10707,7 +10716,7 @@ async function pipelineInner(job, opts = {}) {
10707
10716
  }
10708
10717
  if (closable.length || exempt.length) {
10709
10718
  // Tokens-only (owner directive 2026-07-11): the offer names the work (grid calls), never a $ figure.
10710
- const batchesLeft = Math.max(1, Math.ceil([...new Set(closable.map((c) => c.variant))].length / (ctx.profile?.batchSize ?? 14)));
10719
+ const batchesLeft = Math.max(1, Math.ceil([...new Set(closable.map((c) => c.variant))].length / closureBatch(closable)));
10711
10720
  const offer = ` — closable on instruction (${batchesLeft} supplementary grid call(s))`;
10712
10721
  ctx.coverageNote = `marketplace cells not executed: ${[...closable, ...exempt].map(cellKey).join("; ")}` +
10713
10722
  (closable.length ? ` — attempted in-loop and still unreachable${offer}` : "") +
@@ -83,7 +83,7 @@ function post(urlStr, { headers = {}, body = "", wantId = null, timeoutMs = 3000
83
83
  });
84
84
  // — a timeout got no status and therefore no answer; it is marked at the throw rather than
85
85
  // recognised later by its message, so nothing has to keep a regex in step with a sentence.
86
- req.setTimeout(timeoutMs, () => { req.destroy(transportError(`MCP request timed out after ${timeoutMs}ms`, null)); });
86
+ req.setTimeout(timeoutMs, () => { req.destroy(Object.assign(transportError(`MCP request timed out after ${timeoutMs}ms`, null), { timedOut: true })); });
87
87
  req.on("error", (e) => reject(isSocketFailure(e) ? transportError(e.message, null) : e));
88
88
  req.end(body);
89
89
  });
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // A STATE CHANGE MUST COME FROM THIS PORTAL'S OWN PAGES. The portal checked nothing about where a POST
5
+ // came from: the session cookie's SameSite attribute was the whole defence in local mode, and behind a
6
+ // proxy it was the proxy's cookie. A page on another site, or another app on this machine on another
7
+ // port (a browser treats every port on one host as the same site), could make a signed-in browser change
8
+ // state here. So every state-changing request is checked against this portal's own host, in both modes.
9
+ //
10
+ // THE HOST IS THE REQUEST'S OWN, NOT A LIST. `Host` is what the browser addressed. A proxy in front can
11
+ // rewrite it, and then says the original in `X-Forwarded-Host`, which Caddy sets by default. A page on
12
+ // another site cannot set either header on a victim's browser (a custom header forces a preflight the
13
+ // portal never grants), so accepting a match on either is still a match on this portal's own name.
14
+ // Compared with its port: another app on this machine differs only in the port.
15
+ //
16
+ // A DEFAULT PORT IS NO PORT. `https://portal.example` and `https://portal.example:443` are one origin, and
17
+ // so are `http://h` and `http://h:80`. A browser's Origin never writes the default port, but a proxy or a
18
+ // tunnel in front may write it into `Host`, and a plain comparison would then refuse every save the
19
+ // portal's own pages make. So the port the Origin's scheme implies is taken off both sides before they are
20
+ // compared, and only that one: `:443` beside an `http` Origin is still another port.
21
+ //
22
+ // A REQUEST WITH NEITHER `Origin` NOR `Sec-Fetch-Site` IS NOT FROM A BROWSER. Every current browser sends
23
+ // one of them on a POST, and a script or `curl` sends neither. A script cannot be tricked into carrying
24
+ // someone else's session, so there is nothing to refuse, and its own credential still has to pass.
25
+
26
+ const STATE_CHANGING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
27
+ const first = (v) => String(Array.isArray(v) ? v[0] : (v ?? "")).split(",")[0].trim();
28
+
29
+ const DEFAULT_PORT = { "https:": "443", "http:": "80" };
30
+
31
+ /** `host` without the port `protocol` implies, when it carries exactly that one. */
32
+ function withoutDefaultPort(host, protocol) {
33
+ const port = DEFAULT_PORT[protocol];
34
+ return port && host.endsWith(`:${port}`) ? host.slice(0, -(port.length + 1)) : host;
35
+ }
36
+
37
+ /** The hosts this request says it was addressed to, lower-case, with their ports, less the default one. */
38
+ function ownHosts(headers, protocol) {
39
+ return new Set([first(headers?.host), first(headers?.["x-forwarded-host"])].filter(Boolean)
40
+ .map((h) => withoutDefaultPort(h.toLowerCase(), protocol)));
41
+ }
42
+
43
+ /**
44
+ * Why this request is refused as coming from somewhere other than this portal, or null to let it through.
45
+ * @param {{ method?: string, headers?: Record<string, string|string[]|undefined> }} req
46
+ * @returns {string|null}
47
+ */
48
+ export function crossSiteReason(req) {
49
+ if (!STATE_CHANGING.has(String(req?.method ?? "GET").toUpperCase())) return null;
50
+ const headers = req?.headers ?? {};
51
+ const origin = first(headers.origin);
52
+ if (origin) {
53
+ if (origin === "null") return "Origin is null";
54
+ let host, protocol;
55
+ try { ({ host, protocol } = new URL(origin)); } catch { return "Origin is not an address"; }
56
+ host = withoutDefaultPort(host.toLowerCase(), protocol);
57
+ const own = ownHosts(headers, protocol);
58
+ return own.has(host) ? null : `Origin ${host} is not this portal (${[...own].join(", ") || "no Host"})`;
59
+ }
60
+ const site = first(headers["sec-fetch-site"]).toLowerCase();
61
+ if (site) return site === "same-origin" || site === "none" ? null : `Sec-Fetch-Site is ${site}`;
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Whether a state-changing request carries a body that is not declared as JSON. The API reads every body as
67
+ * JSON; a form post (`text/plain`, `application/x-www-form-urlencoded`) is the shape a page on another site
68
+ * can send without a preflight, so it is refused here rather than parsed. A request with no body is not
69
+ * asked for a type: the portal's own pages send none on a POST that carries nothing.
70
+ */
71
+ export function bodyNotJson(req) {
72
+ if (!STATE_CHANGING.has(String(req?.method ?? "GET").toUpperCase())) return false;
73
+ const headers = req?.headers ?? {};
74
+ const length = Number(first(headers["content-length"]) || 0);
75
+ const hasBody = length > 0 || Boolean(first(headers["transfer-encoding"]));
76
+ if (!hasBody) return false;
77
+ const type = first(headers["content-type"]).split(";")[0].trim().toLowerCase();
78
+ return type !== "application/json";
79
+ }