clearotron 0.3.3 → 0.4.0-beta.1
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/.env.example +9 -0
- package/INSTALL.md +1 -14
- package/bin/brandowner.mjs +5 -5
- package/bin/connect.mjs +4 -4
- package/bin/onboard.mjs +5 -7
- package/bin/start.mjs +20 -5
- package/bin/update.mjs +6 -1
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +2 -6
- package/driver/CHANGELOG.md +28 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/band-shape.mjs +8 -8
- package/driver/blind-frame-model.mjs +1 -1
- package/driver/common-law-receipts.mjs +2 -2
- package/driver/commonlaw-carry.mjs +2 -2
- package/driver/company-bundle.mjs +11 -18
- package/driver/connotation-search.mjs +4 -4
- package/driver/contract-e3-backlog.mjs +3 -3
- package/driver/declination-call.mjs +1 -1
- package/driver/declination-tool.mjs +1 -1
- package/driver/dev-portal.mjs +1 -1
- package/driver/door-call-verdict.mjs +27 -0
- package/driver/driver.config.mjs +17 -11
- package/driver/e2e/README.md +1 -1
- package/driver/engine/mcp/clarivate-server.mjs +2 -1
- package/driver/engine/mcp/corsearch-server.mjs +1 -0
- package/driver/engine/mcp/euipo-server.mjs +1 -0
- package/driver/engine/mcp/free-tier-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/perplexity-server.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/signa-server.mjs +1 -0
- package/driver/engine/mcp/supplemental.mjs +1 -1
- package/driver/engine/mcp/uspto-local-server.mjs +1 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/feedback-issues.mjs +1 -1
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +3 -3
- package/driver/flag-snapshot.mjs +1 -1
- package/driver/floor-duty.mjs +2 -2
- package/driver/form-neighbourhood.mjs +47 -15
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/gateway.mjs +5 -5
- package/driver/jx-lanes.mjs +1 -1
- package/driver/known-conflicts.mjs +18 -0
- package/driver/log.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +129 -95
- package/driver/pipeline.mjs +69 -32
- package/driver/placement-carry.mjs +2 -2
- package/driver/placement-form.mjs +1 -1
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-request-origin.mjs +79 -0
- package/driver/portal-service.mjs +45 -17
- package/driver/predelivery-lint.mjs +10 -10
- package/driver/profile-page.html +9 -13
- package/driver/profile-service.mjs +25 -13
- package/driver/profiles.mjs +17 -4
- package/driver/progress.mjs +1 -1
- package/driver/provider-usage.mjs +24 -1
- package/driver/publish/index.mjs +17 -5
- package/driver/publish/knockout.mjs +3 -2
- package/driver/publish/render-knockout.mjs +1 -1
- package/driver/publish/render.mjs +14 -3
- package/driver/publish/search-depth.mjs +4 -2
- package/driver/recall-reconciliation.mjs +1 -1
- package/driver/record-carry.mjs +6 -6
- package/driver/recording-agreement.mjs +2 -2
- package/driver/reference-score.mjs +27 -27
- package/driver/register-count.mjs +56 -1
- package/driver/register-digest-record.mjs +1 -1
- package/driver/register-plan.mjs +5 -5
- package/driver/register-records.mjs +10 -1
- package/driver/registry-fidelity.mjs +4 -4
- package/driver/repair-composers.mjs +6 -6
- package/driver/run-economics.mjs +8 -19
- package/driver/screen-gate.mjs +1 -1
- package/driver/skills/blind-frame/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-search/report-prose.md +5 -5
- package/driver/skills/clearance-search/synthesis-rules.md +4 -4
- package/driver/skills/clearance-variants/SKILL.md +2 -2
- package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/skills/knockout-assess/SKILL.md +9 -9
- package/driver/skills/matter-frame/watchlist-reference.md +1 -1
- package/driver/skills/narrative-refutation/SKILL.md +2 -2
- package/driver/skills/placement-inquiry/SKILL.md +1 -1
- package/driver/stage-context.mjs +4 -4
- package/driver/stages.mjs +23 -15
- package/driver/suite-census.json +138 -42
- package/driver/systemd/clearotron-client-mcp.service +24 -0
- package/driver/systemd/clearotron-mcp-face.service +24 -0
- package/driver/systemd/clearotron-portal.service +24 -0
- package/driver/systemd/clearotron-worker.service +24 -0
- package/driver/tokens.mjs +26 -17
- package/driver/turnaround-bands.mjs +1 -1
- package/driver/unit-inventory.mjs +3 -3
- package/driver/variant-manifest-model.mjs +1 -1
- package/driver/verify.mjs +2 -2
- package/driver/whatif-memo-run.mjs +1 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/audit.mjs +9 -2
- package/mcp-server/lib/http-handler.mjs +7 -3
- package/mcp-server/mint-token.mjs +8 -6
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +11 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/README.md +1 -1
- package/providers/_shared/answer-memory.mjs +199 -0
- package/providers/_shared/ledger-path.mjs +1 -1
- package/providers/_shared/ledger.mjs +47 -5
- package/providers/_shared/script-form.mjs +24 -5
- package/providers/_shared/term-shape.mjs +5 -5
- package/providers/clarivate/src/capabilities.js +11 -0
- package/providers/clarivate/src/core.js +140 -13
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/capabilities.js +24 -0
- package/providers/signa/src/core.js +66 -0
- package/scripts/deprecate-below.mjs +114 -2
- package/scripts/freeze-example-run.mjs +1 -1
- package/scripts/live-surface-check.mjs +11 -2
- package/scripts/release-entry-catch-up.mjs +211 -0
- package/scripts/release-note-required.mjs +102 -6
- package/scripts/release-rehearsal-version.mjs +60 -0
- package/scripts/release-sbom.mjs +104 -0
- package/scripts/release-visible-check.mjs +7 -5
- package/scripts/score.mjs +3 -3
- package/shared/brand.mjs +1 -1
- package/shared/client-door.mjs +15 -8
- package/shared/driver-dir.mjs +20 -9
- package/shared/names-in-force.mjs +2 -0
- package/shared/scope.mjs +25 -10
- package/shared/store-in-repo.mjs +38 -17
|
@@ -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
|
+
}
|
|
@@ -168,6 +168,7 @@ import { engineCommit } from "./engine-build.mjs"; // — the S
|
|
|
168
168
|
import { engineCommitDate, engineProvenance } from "./engine-build.mjs";
|
|
169
169
|
import { classifySkillsStore } from "./skills-store-provenance.mjs";
|
|
170
170
|
import { makeStaticHandler, reportCsp, docCsp } from "./portal-static.mjs";
|
|
171
|
+
import { crossSiteReason, bodyNotJson } from "./portal-request-origin.mjs";
|
|
171
172
|
import { bundleVerdict, healthUi } from "../shared/bundle-freshness.mjs"; // one definition of a usable bundle, shared with `doctor`
|
|
172
173
|
import { readReport, reportsOf, resolveReportFile, batchSummaryOf } from "./portal-report.mjs";
|
|
173
174
|
import { readArchivedSet, updateArchived } from "./publish/archive-tags.mjs";
|
|
@@ -915,10 +916,10 @@ export const PORTAL_JOB_FIELDS = Object.freeze({
|
|
|
915
916
|
+ "which exists because the hand-emitting email-loop agent can only `write` files. The portal composer "
|
|
916
917
|
+ "sends structured fields and writes no sidecars, so a job it built is never in that shape — carrying "
|
|
917
918
|
+ "the flag would have it claim an intake it did not use, and the door's own check would then look for "
|
|
918
|
-
+ "files nobody wrote
|
|
919
|
+
+ "files nobody wrote.",
|
|
919
920
|
msgId: "the email door's message id. This door has no message: the composer IS the request, and there "
|
|
920
921
|
+ "is no thread to thread it into. It was declared carried and reached the job as a hardcoded null "
|
|
921
|
-
+ "that the trigger hop then stripped, which is a declaration measuring nothing
|
|
922
|
+
+ "that the trigger hop then stripped, which is a declaration measuring nothing.",
|
|
922
923
|
conversationId: "the email door's thread id, and the portal has no thread. Same story as `msgId`: "
|
|
923
924
|
+ "declared carried, written null, stripped again. A reply lands in the portal, not in a mailbox.",
|
|
924
925
|
customer: "the APPLICANT, and the account already resolved it — see the jobFor header. Letting a body "
|
|
@@ -1142,7 +1143,7 @@ export function makeConnectorKeyRevoker({ env = process.env, home = null } = {})
|
|
|
1142
1143
|
const base = home ?? homedir();
|
|
1143
1144
|
const recorded = recordedKeysFor(g, email);
|
|
1144
1145
|
const plan = disablePlan({ env, unitDir: join(base, ".config", "systemd", "user"), exists: existsSync,
|
|
1145
|
-
identity: email, recorded, denylistPath: denylistPathFor(env, base) });
|
|
1146
|
+
identity: email, recorded, denylistPath: denylistPathFor(env, base), home: base });
|
|
1146
1147
|
if (!plan.possible) return { grants: g, revoked: 0, jtis: [], lateArm: false, says: plan.says };
|
|
1147
1148
|
if (plan.lateArm) return { grants: g, revoked: 0, jtis: plan.jtis, lateArm: true, says: plan.says ?? [] };
|
|
1148
1149
|
// THE RECORD IS NEVER STRUCK FROM HERE, and the ledger step is dropped rather than no-opped.
|
|
@@ -1614,6 +1615,7 @@ export function makePortalService({
|
|
|
1614
1615
|
// identity gets {} here exactly as it does for names and keeps reading the staff-only roster.
|
|
1615
1616
|
let accountNames = {};
|
|
1616
1617
|
let accountFacts = {};
|
|
1618
|
+
let meRoster = null; // read once for this answer: the names below and the house marketplaces
|
|
1617
1619
|
// GENERIC IS NAMED TOO when the person sees an organisation's Generic. It is not a company, so it is
|
|
1618
1620
|
// never in `accounts`, and without a name here the switcher printed the raw key at everyone below
|
|
1619
1621
|
// everything. An organisation with no company yet is the case with an empty `accounts`, so the
|
|
@@ -1623,7 +1625,7 @@ export function makePortalService({
|
|
|
1623
1625
|
if (named.length) {
|
|
1624
1626
|
try {
|
|
1625
1627
|
const { companyFactsOf } = await import("./profiles.mjs");
|
|
1626
|
-
const profiles = await loadProfilesImpl();
|
|
1628
|
+
const profiles = meRoster = await loadProfilesImpl();
|
|
1627
1629
|
for (const key of named) {
|
|
1628
1630
|
const profile = profiles.get(key);
|
|
1629
1631
|
const name = profile?.name;
|
|
@@ -1662,9 +1664,19 @@ export function makePortalService({
|
|
|
1662
1664
|
// READ ONCE. The payload names it and the program reading below is gated on it; two calls to
|
|
1663
1665
|
// `flagView` here would be two reads of the same file that could disagree with each other.
|
|
1664
1666
|
const meEngineMode = flagView(poolRoot).engineMode;
|
|
1667
|
+
// THE HOUSE MARKETPLACES, offered as suggestions wherever a company's marketplaces are edited. A new
|
|
1668
|
+
// company starts with none (the owner's ruling of 2026-09-23) and somebody picks; these are the
|
|
1669
|
+
// Generic default's own list, read from the store so an install that changed it offers its own.
|
|
1670
|
+
// Deployment-wide and the same for everyone, like concurrentRuns. [] when the store cannot be read:
|
|
1671
|
+
// the box still takes any marketplace typed into it.
|
|
1672
|
+
let houseMarketplaces = [];
|
|
1673
|
+
try {
|
|
1674
|
+
const house = (meRoster ?? await loadProfilesImpl()).get("generic")?.platforms;
|
|
1675
|
+
if (Array.isArray(house)) houseMarketplaces = house.filter((d) => typeof d === "string" && d.trim());
|
|
1676
|
+
} catch { /* a suggestion list is a nicety; the door is not */ }
|
|
1665
1677
|
return { status: 200, json: { email: principal.email, ...principalView(principal, grantsHere, accountNames),
|
|
1666
1678
|
accounts: principal.accounts, accountNames, accountFacts,
|
|
1667
|
-
concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode,
|
|
1679
|
+
concurrentRuns: concurrentRunsCap(), brand: ORGANISATION_NAME, engineMode: meEngineMode, houseMarketplaces,
|
|
1668
1680
|
// WHERE A PERSON ASKS FOR A CHANGE TO THEIR SIGN-IN, beside the brand and read where it is read:
|
|
1669
1681
|
// an href Preferences links "Clearotron administrator" to, or null, and then the words are plain.
|
|
1670
1682
|
administratorContact: ADMINISTRATOR_CONTACT,
|
|
@@ -3267,15 +3279,12 @@ async function connectorDoorKind(url) {
|
|
|
3267
3279
|
const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
|
|
3268
3280
|
if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
|
|
3269
3281
|
} catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
|
|
3270
|
-
// WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL
|
|
3271
|
-
//
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
|
|
3276
|
-
// The connector is a separate unit with its own environment; this answers only for here.
|
|
3277
|
-
const keysRevocable = Boolean(revokeConnectorKeys)
|
|
3278
|
-
&& String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim() !== "";
|
|
3282
|
+
// WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL. This used to be whether a revocation list was
|
|
3283
|
+
// named in this process's environment, because a door with none named checked no list. Every door
|
|
3284
|
+
// now reads the install's default list when none is named (isRevoked, shared/scope.mjs), and that
|
|
3285
|
+
// is where a revocation from here is written, so keys can be withdrawn whenever this process has
|
|
3286
|
+
// a revoker at all.
|
|
3287
|
+
const keysRevocable = Boolean(revokeConnectorKeys);
|
|
3279
3288
|
return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn, keysRevocable }) };
|
|
3280
3289
|
}
|
|
3281
3290
|
// /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
|
|
@@ -3641,9 +3650,10 @@ export function accountCapAdvice(posture) {
|
|
|
3641
3650
|
}
|
|
3642
3651
|
return "Re-mint with `mcp-server/mint-token.mjs --scope ops"
|
|
3643
3652
|
+ `${posture.sub ? ` --sub ${posture.sub}` : ""}`
|
|
3644
|
-
//
|
|
3645
|
-
//
|
|
3646
|
-
|
|
3653
|
+
// A FULL-OPS TOKEN (`verbs: null`) IS RE-ISSUED WITH THE PORTAL'S OWN VERBS. It used to get no
|
|
3654
|
+
// `--verbs` at all, so as not to narrow it; mint-token now refuses an ops token that names none, so that
|
|
3655
|
+
// command would fail, and re-issuing verb-less tokens with verbs is the point. The portal calls these two.
|
|
3656
|
+
+ ` --verbs ${(posture.verbs ?? ["start_run", "stop_run"]).join(",")}`
|
|
3647
3657
|
+ " --accounts <keys>` to make it two walls.";
|
|
3648
3658
|
}
|
|
3649
3659
|
|
|
@@ -4288,6 +4298,18 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4288
4298
|
});
|
|
4289
4299
|
}
|
|
4290
4300
|
|
|
4301
|
+
// ── A STATE CHANGE FROM ANOTHER SITE IS REFUSED BEFORE ANYTHING ELSE LOOKS AT IT ────────────────
|
|
4302
|
+
//
|
|
4303
|
+
// Before identity, so the sign-in and sign-out forms are covered as well as the API: a page elsewhere
|
|
4304
|
+
// that signs a browser in to an account of its choosing is the same attack as one that acts under the
|
|
4305
|
+
// browser's own session. The rule and why it compares hosts is in portal-request-origin.mjs.
|
|
4306
|
+
const crossSite = crossSiteReason(req);
|
|
4307
|
+
if (crossSite) {
|
|
4308
|
+
log(`refused a ${req.method} to ${url.pathname} from another site: ${crossSite}`);
|
|
4309
|
+
journal(403, "cross-site request");
|
|
4310
|
+
return send(res, 403, { error: "cross_site" });
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4291
4313
|
// ──: THE LOCAL SIGN-IN DOOR — before identity, because it is how identity is obtained ─────
|
|
4292
4314
|
//
|
|
4293
4315
|
// Mounted only when the local identity provider is running. On a Cloudflare-fronted instance
|
|
@@ -4420,6 +4442,12 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4420
4442
|
journal(429, "rate_limited", identity?.email);
|
|
4421
4443
|
return send(res, 429, { error: "rate_limited" });
|
|
4422
4444
|
}
|
|
4445
|
+
// A BODY THAT DOES NOT SAY IT IS JSON IS NOT READ AS JSON. A form post is the one body another site
|
|
4446
|
+
// can send without asking first; the portal's own pages always declare JSON when they send a body.
|
|
4447
|
+
if (bodyNotJson(req)) {
|
|
4448
|
+
journal(415, "body is not JSON", identity?.email);
|
|
4449
|
+
return send(res, 415, { error: "unsupported_media_type" });
|
|
4450
|
+
}
|
|
4423
4451
|
// A FIXED reason, not `e.message`. The parser's message is about the bytes it was given, and this
|
|
4424
4452
|
// row must not become a place a caller can write into by sending a body that fails to parse.
|
|
4425
4453
|
let body;
|
|
@@ -490,7 +490,7 @@ export function candidateSelfChecks({ reportMd }) {
|
|
|
490
490
|
|
|
491
491
|
// B2 (report confabulation backstop) — per-card record provenance. The single-pass report renderer once
|
|
492
492
|
// pasted one finding's body (its record link, owner, filing dates) into ANOTHER finding's card (the
|
|
493
|
-
// ashen-lattice confabulation:
|
|
493
|
+
// ashen-lattice confabulation: DuPharVel's body landed in cards #2 and #5). registry-record-match cannot
|
|
494
494
|
// catch it: a card carrying finding-M's whole body also cites finding-M's URI, so its claims agree with the
|
|
495
495
|
// cited record — but the card sits under finding-N's identity. This check binds each card to ITS finding (by
|
|
496
496
|
// the hidden `- ord: N` line the per-card renderer emits, else owner-name containment for a legacy/monolithic
|
|
@@ -595,7 +595,7 @@ export function recordVerificationClaimChecks({ text, recordsByUri, surface = "r
|
|
|
595
595
|
// Two failures, one owner set, one paragraph scan.
|
|
596
596
|
//
|
|
597
597
|
// (1) THE UNVERIFIED "REPORTEDLY". A delivered card said a named incumbent's registration "has
|
|
598
|
-
// reportedly been used by the USPTO to refuse other
|
|
598
|
+
// reportedly been used by the USPTO to refuse other WAVO-formative marks". The record that
|
|
599
599
|
// settles it was on disk the whole time (its refusalInformation lists six refused applications) —
|
|
600
600
|
// so the claim had two honest endings available, a verified finding citing the record or a stated
|
|
601
601
|
// open question, and it took neither. Second sighting: the same shape reached an animal-health
|
|
@@ -819,7 +819,7 @@ const OWNER_NEGATIVE_RE = /\bno\s+(?:[\w'-]+[,]?\s+){0,6}?(?:marks?|registration
|
|
|
819
819
|
// Class 5." ← the negative is about the APPLICANT
|
|
820
820
|
// The check cannot block delivery, so the cost is a warm redo whose instruction invites rewriting
|
|
821
821
|
// correct prose — a doomed redo, the TONICA lesson. The real delivered true positive keeps both
|
|
822
|
-
// halves in ONE sentence ("General sweeps for Kestrel Beverages and Hochland Energie … found no
|
|
822
|
+
// halves in ONE sentence ("General sweeps for Kestrel Beverages and Hochland Energie … found no WAVO-initial …
|
|
823
823
|
// register mark in Classes 5 or 32"), so the narrowing costs nothing it was catching.
|
|
824
824
|
// The ESCAPES stay paragraph-scoped on purpose: disclosing the gap anywhere in the claim unit is an
|
|
825
825
|
// honest ending for the whole unit, and narrowing an exemption is how a false NEGATIVE gets made.
|
|
@@ -827,9 +827,9 @@ const OWNER_NEGATIVE_RE = /\bno\s+(?:[\w'-]+[,]?\s+){0,6}?(?:marks?|registration
|
|
|
827
827
|
// THE SPLIT IS NOT wipoLanguageChecks' SPLIT, and the difference is a corporate abbreviation. A bare
|
|
828
828
|
// `(?<=[.!?])\s+` boundary cuts owner names in half — and register owners are FULL of periods:
|
|
829
829
|
// "Fairmile Snack Foods Corp. holds no filings in Class 5."
|
|
830
|
-
// "Marumi Foods Inc. found no
|
|
830
|
+
// "Marumi Foods Inc. found no WAVO-formative marks in Class 32."
|
|
831
831
|
// "Yamagata Holdings Co., Ltd. has no registrations in the searched classes."
|
|
832
|
-
// "Harbourline Pte. Ltd. owns no
|
|
832
|
+
// "Harbourline Pte. Ltd. owns no WAVO mark in Class 32."
|
|
833
833
|
// Every one of those is the exact claim this check exists to catch, and every one of them splits
|
|
834
834
|
// after the suffix — owner in segment 1, negative in segment 2, no flag. Narrowing to the sentence
|
|
835
835
|
// would have bought two false positives at the price of a whole class of false NEGATIVES, on the
|
|
@@ -845,7 +845,7 @@ const OWNER_NEGATIVE_RE = /\bno\s+(?:[\w'-]+[,]?\s+){0,6}?(?:marks?|registration
|
|
|
845
845
|
// clean negative shipped over an owner nobody enumerated.
|
|
846
846
|
//
|
|
847
847
|
// One case the follower rule cannot see, and it is the commonest Asian register form: a legal-form
|
|
848
|
-
// abbreviation followed by ANOTHER one — "Harbourline Pte. Ltd. owns no
|
|
848
|
+
// abbreviation followed by ANOTHER one — "Harbourline Pte. Ltd. owns no WAVO mark" splits at
|
|
849
849
|
// "Pte." because "Ltd." is capitalised. That pair is NOT ambiguous (no sentence ends in "Pte." and
|
|
850
850
|
// begins with "Ltd."), so it gets a named list rather than a heuristic, and the list is deliberately
|
|
851
851
|
// only the STACKED pair: "Inc. There are no filings…" stays two sentences, because there the capital
|
|
@@ -858,10 +858,10 @@ const OWNER_NEGATIVE_RE = /\bno\s+(?:[\w'-]+[,]?\s+){0,6}?(?:marks?|registration
|
|
|
858
858
|
// legal form that is ordinary in this domain, so the misses are recall loss in the guard whose whole
|
|
859
859
|
// purpose is catching an unverified negative about a named owner:
|
|
860
860
|
// N1a "Bahlsen GmbH & Co. KG holds no filings…" KG/KGaA were absent from LEGAL_FORM, so
|
|
861
|
-
// "Henkel AG & Co. KGaA found no
|
|
862
|
-
// "Ritter Sport Co. KG owns no
|
|
861
|
+
// "Henkel AG & Co. KGaA found no WAVO-formative…" "Co." got no sentinel and the capitalised
|
|
862
|
+
// "Ritter Sport Co. KG owns no WAVO mark…" follower split owner from negative.
|
|
863
863
|
// N1b "Dr. Oetker AG holds no filings…" the follower rule only ever handled a
|
|
864
|
-
// "St. Michel Biscuits found no
|
|
864
|
+
// "St. Michel Biscuits found no WAVO-formative…" TRAILING legal form. A LEADING abbreviation
|
|
865
865
|
// (and "Cia. Hering Ltda.", "Warner Bros. Entertainment", "Mt. Franklin Beverages") splits the
|
|
866
866
|
// owner's own name in half before the negative is ever reached.
|
|
867
867
|
// Three rules close them, each doing something the other two cannot:
|
|
@@ -1312,7 +1312,7 @@ export function correctionConsistencyChecks({ reportMd, clientSummaryMd, auditMd
|
|
|
1312
1312
|
// finding's `band` (the framework in force's own word, via the run's frozen manifest); on legacy runs
|
|
1313
1313
|
// from the canonical composite→tier table. Pure rewriter (the autoCorrectRegistry pattern): joins each
|
|
1314
1314
|
// `## ` Marks block to a live finding DETERMINISTICALLY (wp50: ord line → exact mark → unique
|
|
1315
|
-
// containment; the old first-match containment join bound "DEMVENZY —
|
|
1315
|
+
// containment; the old first-match containment join bound "DEMVENZY — Norvanta" to the VENZY finding and
|
|
1316
1316
|
// would have ENFORCED the wrong tier) and overwrites its `- risk:` value; the exec-summary `- risk:` is
|
|
1317
1317
|
// overwritten from the worst live band (v4) / max composite (legacy). Returns { text, corrections }.
|
|
1318
1318
|
export function applyClientTierCorrections(csMd, findings, manifest = null) {
|
package/driver/profile-page.html
CHANGED
|
@@ -214,7 +214,7 @@ function renderEditor(key, p, contextPack, derived, framework, isNew) {
|
|
|
214
214
|
$("editor").innerHTML = `
|
|
215
215
|
${isNew ? f("Customer key"+REQ, "f_key", "", `<span class="reqtag">Required.</span> A short lowercase id — letters, numbers and hyphens only. It becomes the customer's filename and can't be changed later.`, "e.g. acme-health") : `<div class="ro">Editing <b><span data-anon="mark" data-anon-key="${esc(key)}">${esc(key)}</span></b></div>`}
|
|
216
216
|
${f("Customer name"+REQ, "f_name", p.name||"", `<span class="reqtag">Required.</span> The customer's name. We use it to recognise their own marks so we never flag the customer against itself.`, undefined, true)}
|
|
217
|
-
${ta("Marketplaces and registers to search"
|
|
217
|
+
${ta("Marketplaces and registers to search", "f_platforms", (p.platforms||[]).join("\n"), `One website per line (e.g. amazon.com). These are where we look for real-world use of a mark. Don't add 'web' — the general web is always searched.`, "", true)}
|
|
218
218
|
${coverageNote}
|
|
219
219
|
<details class="more"${advancedSet?" open":""}>
|
|
220
220
|
<summary>More options</summary>
|
|
@@ -344,18 +344,15 @@ function clientValidate({ key, profile }) {
|
|
|
344
344
|
else if (!KEY_RE.test(key)) problems.push({ id:"f_key", message:"The customer key can only use lowercase letters, numbers and hyphens (2–39 characters)." });
|
|
345
345
|
}
|
|
346
346
|
if (!profile.name) problems.push({ id:"f_name", message:"Enter the customer’s name." });
|
|
347
|
+
// An empty list is valid: a company may pick no marketplaces (the owner's ruling of 2026-09-23).
|
|
347
348
|
const plats = profile.platforms || [];
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (!DOMAIN_RE.test(dom) || /\s/.test(dom)) { problems.push({ id:"f_platforms", message:`“${x}” doesn’t look like a website address — use a domain like amazon.com.` }); continue; }
|
|
356
|
-
if (seen.has(dom)) { problems.push({ id:"f_platforms", message:`“${x}” is listed twice.` }); continue; }
|
|
357
|
-
seen.add(dom);
|
|
358
|
-
}
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
for (const x of plats) {
|
|
351
|
+
const dom = x.toLowerCase();
|
|
352
|
+
if (dom === "web") { problems.push({ id:"f_platforms", message:"Don’t list “web” — the general web is always searched. Remove it." }); continue; }
|
|
353
|
+
if (!DOMAIN_RE.test(dom) || /\s/.test(dom)) { problems.push({ id:"f_platforms", message:`“${x}” doesn’t look like a website address — use a domain like amazon.com.` }); continue; }
|
|
354
|
+
if (seen.has(dom)) { problems.push({ id:"f_platforms", message:`“${x}” is listed twice.` }); continue; }
|
|
355
|
+
seen.add(dom);
|
|
359
356
|
}
|
|
360
357
|
return problems;
|
|
361
358
|
}
|
|
@@ -366,7 +363,6 @@ function prettyServerError(msg) {
|
|
|
366
363
|
const M = String(msg||"");
|
|
367
364
|
const MAP = [
|
|
368
365
|
[/name \(string\) is required/i, "f_name", "Enter the customer’s name."],
|
|
369
|
-
[/platforms must be a non-empty array/i, "f_platforms", "Add at least one marketplace or register to search — a website like amazon.com, one per line."],
|
|
370
366
|
[/must not list "web"/i, "f_platforms", "Don’t list “web” — the general web is always searched. Remove it."],
|
|
371
367
|
[/must be a bare store DOMAIN/i, "f_platforms", "One of the marketplaces isn’t a website address — use domains like amazon.com, one per line."],
|
|
372
368
|
[/is duplicated/i, "f_platforms", "A marketplace is listed twice — remove the duplicate."],
|
|
@@ -176,6 +176,22 @@ function bandsBandRows(deck, manifest) {
|
|
|
176
176
|
return out;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// NO STORE PATH AND NO LOAD-LOG WORDING REACHES A BROWSER. The profile validator opens every sentence with
|
|
180
|
+
// the file it judged (`profiles/<key>.json: `), which is right in a terminal and in the load log and is a
|
|
181
|
+
// server path on the company page, so the door takes the prefix off. The three marketplace-entry refusals
|
|
182
|
+
// are then worded for the browser too: their load-log words ("the grid program domain-restricts to it",
|
|
183
|
+
// "inflates the floor") are engine vocabulary, and the staff profile editor already carries a sentence for
|
|
184
|
+
// each (profile-page.html's MAP), copied here unchanged. Every other sentence passes as the validator wrote it.
|
|
185
|
+
const BROWSER_WORDING = [
|
|
186
|
+
[/^platforms must not list "web"/, "Don’t list “web” — the general web is always searched. Remove it."],
|
|
187
|
+
[/^platforms entry .* must be a bare store DOMAIN/, "One of the marketplaces isn’t a website address — use domains like amazon.com, one per line."],
|
|
188
|
+
[/^platforms entry .* is duplicated/, "A marketplace is listed twice — remove the duplicate."],
|
|
189
|
+
];
|
|
190
|
+
export const forBrowser = (errors) => (errors ?? []).map((e) => {
|
|
191
|
+
const bare = String(e).replace(/^profiles\/[^\s:]*\.json: /, "");
|
|
192
|
+
return BROWSER_WORDING.find(([re]) => re.test(bare))?.[1] ?? bare;
|
|
193
|
+
});
|
|
194
|
+
|
|
179
195
|
/**
|
|
180
196
|
* A refusal, worded for the person in the browser.
|
|
181
197
|
*
|
|
@@ -200,18 +216,14 @@ export function browserRefusal(e) {
|
|
|
200
216
|
return { error: "refused", code: "domain_claimed", domain: d.domain, heldBy: d.heldBy,
|
|
201
217
|
message: `${d.domain} is already used by another company. An address can only belong to one, `
|
|
202
218
|
+ `because the engine uses it to tell them apart. Remove it here, or take it off the other company first.` };
|
|
203
|
-
case "no_marketplaces":
|
|
204
|
-
return { error: "refused", code: "no_marketplaces",
|
|
205
|
-
message: "This installation has no default marketplaces set up, so there is nothing to search. "
|
|
206
|
-
+ "Add at least one marketplace for this company, or ask an administrator to set the defaults." };
|
|
207
219
|
case "framework_missing":
|
|
208
220
|
return { error: "refused", code: "framework_missing",
|
|
209
221
|
message: "The risk framework this company was pointed at is not on this installation, so nothing "
|
|
210
222
|
+ "was created. Rating a company under a framework nobody chose is worse than refusing." };
|
|
211
223
|
case "invalid_bundle":
|
|
212
224
|
// The validator collects, so these are already per-field sentences written for a reader.
|
|
213
|
-
return { error: "refused", code: "invalid_bundle", errors: d.errors
|
|
214
|
-
message: (d.errors
|
|
225
|
+
return { error: "refused", code: "invalid_bundle", errors: forBrowser(d.errors),
|
|
226
|
+
message: forBrowser(d.errors).join(" ") || String(e.message) };
|
|
215
227
|
default:
|
|
216
228
|
return { error: "refused", message: String(e.message) };
|
|
217
229
|
}
|
|
@@ -439,13 +451,12 @@ export function makeProfileService({
|
|
|
439
451
|
try { assertProfileKey(wanted); }
|
|
440
452
|
catch (e) { return { status: 400, json: { error: "bad_key", message: String(e.message) } }; }
|
|
441
453
|
|
|
442
|
-
const existing = resolveExisting();
|
|
443
454
|
let framework, platforms, profile;
|
|
444
455
|
try {
|
|
445
456
|
// ALWAYS SET, NEVER ABSENT — the company's own when one is supplied, the house default named
|
|
446
457
|
// otherwise. This is the whole reason a create does not go through save.
|
|
447
458
|
framework = resolveFramework(isStr(body?.frameworkPath) ? body.frameworkPath : null);
|
|
448
|
-
platforms = resolvePlatforms(Array.isArray(body?.platforms) ? body.platforms : null
|
|
459
|
+
platforms = resolvePlatforms(Array.isArray(body?.platforms) ? body.platforms : null);
|
|
449
460
|
profile = buildProfile({
|
|
450
461
|
key: wanted, name,
|
|
451
462
|
domains: Array.isArray(body?.matchDomains) ? body.matchDomains : [],
|
|
@@ -511,7 +522,8 @@ export function makeProfileService({
|
|
|
511
522
|
// The receipt reads off THESE, so it can say which framework and how many marketplaces, and
|
|
512
523
|
// whether each was chosen or defaulted, rather than restating what was typed.
|
|
513
524
|
framework: { path: framework.path, defaulted: framework.source === "default" },
|
|
514
|
-
|
|
525
|
+
// Never defaulted any more: a company created without marketplaces has none (resolvePlatforms).
|
|
526
|
+
marketplaces: { count: platforms.platforms.length, defaulted: false },
|
|
515
527
|
...(commitError ? { commitError: `created and LIVE, but the git commit failed (${commitError}) — the audit line records the gap` } : {}),
|
|
516
528
|
} };
|
|
517
529
|
}
|
|
@@ -621,14 +633,14 @@ export function makeProfileService({
|
|
|
621
633
|
const v = validateProfileEdit(errKey, overlayBody, ctx, { sparse: true });
|
|
622
634
|
let keyError = null;
|
|
623
635
|
if (!exists) { try { assertProfileKey(project); } catch (e) { keyError = String(e.message); } }
|
|
624
|
-
const errors = keyError ? [keyError, ...v.errors] : v.errors;
|
|
636
|
+
const errors = forBrowser(keyError ? [keyError, ...v.errors] : v.errors);
|
|
625
637
|
return { status: 200, json: { ok: errors.length === 0, errors, isNew: !exists } };
|
|
626
638
|
}
|
|
627
639
|
|
|
628
640
|
// save — the validated AUTO-COMMIT write (sparse validators; customer-only keys 400 here)
|
|
629
641
|
if (!exists) { try { assertProfileKey(project); } catch (e) { return { status: 400, json: { error: String(e.message) } }; } }
|
|
630
642
|
const v = validateProfileEdit(errKey, overlayBody, ctx, { sparse: true });
|
|
631
|
-
if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: v.errors } };
|
|
643
|
+
if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: forBrowser(v.errors) } };
|
|
632
644
|
// The write lands FIRST (atomic temp+rename); a git failure after it must never hide the mutation
|
|
633
645
|
// behind a 500 — the overlay is LIVE the moment it is renamed (every loadProjects door reads
|
|
634
646
|
// force-fresh), so the response says written:true with the commit error named, and the audit line
|
|
@@ -670,7 +682,7 @@ export function makeProfileService({
|
|
|
670
682
|
// a brand-new key must also be a safe slug (it becomes a filename); surface that in the dry run
|
|
671
683
|
let keyError = null;
|
|
672
684
|
if (!exists) { try { assertProfileKey(key); } catch (e) { keyError = String(e.message); } }
|
|
673
|
-
const errors = keyError ? [keyError, ...v.errors] : v.errors;
|
|
685
|
+
const errors = forBrowser(keyError ? [keyError, ...v.errors] : v.errors);
|
|
674
686
|
return { status: 200, json: { ok: errors.length === 0, errors, isNew: !exists } };
|
|
675
687
|
}
|
|
676
688
|
|
|
@@ -684,7 +696,7 @@ export function makeProfileService({
|
|
|
684
696
|
preserveUncontrolled(profile, exists ? profiles.get(key) : null);
|
|
685
697
|
const effective = preserveCodeOwned(profile, exists ? profiles.get(key) : null);
|
|
686
698
|
const v = validateProfileEdit(key, effective, contextPack);
|
|
687
|
-
if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: v.errors } };
|
|
699
|
+
if (!v.ok) return { status: 400, json: { error: "validation_failed", errors: forBrowser(v.errors) } };
|
|
688
700
|
|
|
689
701
|
const { files } = writeProfile({ profileDir, key, profile: effective, contextPack });
|
|
690
702
|
// The write is atomic and LIVE the instant it renames — the next run freezes THIS file. An
|
package/driver/profiles.mjs
CHANGED
|
@@ -175,13 +175,22 @@ export function derivedBatchSize(profile) {
|
|
|
175
175
|
return Math.max(1, Math.floor(gridCellBudget(profile) / derivedFloor(profile)));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// THE TERMS ONE GRID CALL MAY CARRY when each term runs `cellsPerTerm` cells: the cell budget divided by the
|
|
179
|
+
// cells the grid actually runs, never more than the profile's own figure. The profile's figure assumes its
|
|
180
|
+
// own stores plus the web cell, so it is too large for a grid that runs more per term: a matter frame's
|
|
181
|
+
// channels, or a closure over a store the matter chose for a company that picked no marketplaces, whose
|
|
182
|
+
// figure is the whole budget in terms. One oversized call truncates (the 224-cell note above).
|
|
183
|
+
export function gridBatchFor(profile, cellsPerTerm) {
|
|
184
|
+
return Math.max(1, Math.min(profile?.batchSize ?? 14, Math.floor(gridCellBudget(profile) / Math.max(1, cellsPerTerm))));
|
|
185
|
+
}
|
|
186
|
+
|
|
178
187
|
// The per-entry platform rules as DATA rather than as throws, so every door applies the identical checks
|
|
179
188
|
// and phrases the failure in its own register: a profile load DIES (a broken profile bricks every run
|
|
180
189
|
// under it), a job CLARIFIES (one request, answerable by asking). Same rules either way — a domain that is
|
|
181
190
|
// a foot-gun in a profile is the same foot-gun arriving on a job.
|
|
182
191
|
//
|
|
183
|
-
// SHAPE stays with the caller deliberately: a profile REQUIRES a
|
|
184
|
-
// platforms are optional and additive. Only the per-entry vocabulary is shared.
|
|
192
|
+
// SHAPE stays with the caller deliberately: a profile REQUIRES a platforms array (possibly empty), while a
|
|
193
|
+
// job's platforms are optional and additive. Only the per-entry vocabulary is shared.
|
|
185
194
|
export function platformEntryErrors(list, { label = "platforms" } = {}) {
|
|
186
195
|
const errs = [];
|
|
187
196
|
const seen = new Set();
|
|
@@ -518,9 +527,13 @@ function validateProfileShape(key, p, { sparse = false } = {}) {
|
|
|
518
527
|
}
|
|
519
528
|
// platforms is REQUIRED on a whole profile but OPTIONAL on an overlay; the foot-gun guards below run whenever
|
|
520
529
|
// it is present (a project that re-states its marketplaces must state them just as carefully).
|
|
530
|
+
//
|
|
531
|
+
// AN EMPTY LIST IS VALID, by the owner's ruling of 2026-09-23: a company may pick no marketplaces, and a
|
|
532
|
+
// new one starts that way. What stays required is the ARRAY — an absent key is not "none", it is a
|
|
533
|
+
// profile nobody finished (derivedFloor reads absent as the historical default of six).
|
|
521
534
|
if (!sparse || p.platforms != null) {
|
|
522
|
-
if (!Array.isArray(p.platforms) || !p.platforms.
|
|
523
|
-
die("platforms must be
|
|
535
|
+
if (!Array.isArray(p.platforms) || !p.platforms.every((x) => typeof x === "string" && x.trim()))
|
|
536
|
+
die("platforms must be an array of store-domain strings");
|
|
524
537
|
// a one-character authoring slip here bricks every run under the profile (the floor counts entries
|
|
525
538
|
// verbatim while the grid dedupes) — reject the known foot-guns at load time. Shared with the job
|
|
526
539
|
// doors via platformEntryErrors so a domain rule can never drift between where it is stored and
|
package/driver/progress.mjs
CHANGED
|
@@ -224,7 +224,7 @@ export function writeRunStatus(ctx, patch = {}, runDirOverride = null, { critica
|
|
|
224
224
|
try {
|
|
225
225
|
note(`[status] NO RUN DIRECTORY — the state write to ${JSON.stringify(patch.state)} did not happen. `
|
|
226
226
|
+ `The run's status.json still says whatever it said last, which for a park or a terminal means it `
|
|
227
|
-
+ `says "running". Caller passed neither a runDirOverride nor a ctx.run.runDir
|
|
227
|
+
+ `says "running". Caller passed neither a runDirOverride nor a ctx.run.runDir.`);
|
|
228
228
|
} catch { /* the note sink is best-effort too — never let reporting a lost write lose the run */ }
|
|
229
229
|
}
|
|
230
230
|
return;
|
|
@@ -134,7 +134,8 @@ function emptyTally() {
|
|
|
134
134
|
ledger: { configured: false, present: false, readable: false, rowsScanned: 0 },
|
|
135
135
|
retries: 0, // Σ max(0, attempts-1) — wasted retry calls
|
|
136
136
|
errors: 0, // calls that returned ok:false (incl. transport throws → http_status 0)
|
|
137
|
-
cache_hits: 0, // calls served from
|
|
137
|
+
cache_hits: 0, // calls served from the run memory instead of the register (providers/_shared/answer-memory.mjs)
|
|
138
|
+
records_reused: 0, // records a call took from the run's own store rather than fetching again
|
|
138
139
|
bytes: 0, // total response bytes (proxy for payload cost)
|
|
139
140
|
// The dedup opportunity, split so Workstream B's scope is decided from data, not guessed:
|
|
140
141
|
duplicate_fetches: 0, // record_fetch on a URI already fetched this run (real network ones)
|
|
@@ -183,6 +184,28 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
|
|
|
183
184
|
if (KINDS.includes(row.tool)) out[row.tool]++;
|
|
184
185
|
else out.unclassified++;
|
|
185
186
|
|
|
187
|
+
// A ROW THAT NAMES ITS RECORDS IS COUNTED BY RECORD, whatever tool it rode. A register that hydrates
|
|
188
|
+
// records in batches (Clarivate's /text) logs one row for up to 100 records, under whichever tool asked
|
|
189
|
+
// — a screen, a plan execution, a listing — so the `record_fetch`-and-one-URI test below never saw
|
|
190
|
+
// them, and the count read 0 on every such run whatever was fetched twice. Records served from the
|
|
191
|
+
// run's own store ride a cache-hit row and count as reused, never as fetched.
|
|
192
|
+
if (Array.isArray(row.records)) {
|
|
193
|
+
if (isCacheHit) { out.records_reused += row.records.length; continue; }
|
|
194
|
+
const sess = rowSession(row);
|
|
195
|
+
for (const id of row.records) {
|
|
196
|
+
const target = String(id ?? "").toLowerCase();
|
|
197
|
+
if (!target) continue;
|
|
198
|
+
if (firstFetchSession.has(target)) {
|
|
199
|
+
out.duplicate_fetches++;
|
|
200
|
+
if (firstFetchSession.get(target) === sess) out.duplicate_fetches_same_session++;
|
|
201
|
+
else out.duplicate_fetches_cross_session++;
|
|
202
|
+
} else {
|
|
203
|
+
firstFetchSession.set(target, sess);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
186
209
|
// Duplicate detail-fetches = the Workstream-B opportunity. Count only REAL network fetches (a cache
|
|
187
210
|
// hit is already a saved duplicate, not a new one). First network fetch of a URI seeds the map; any
|
|
188
211
|
// later network fetch of the same URI is a duplicate, attributed same- vs cross-session.
|
package/driver/publish/index.mjs
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
// (not via runStage). Run identity (runId/codename) comes from the driver's ctx.run; do NOT regenerate it.
|
|
10
10
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync, chmodSync } from 'node:fs';
|
|
11
11
|
import { join, dirname } from 'node:path';
|
|
12
|
-
import { driverDir } from '../../shared/driver-dir.mjs'; //
|
|
12
|
+
import { driverDir, RUN_DIR_MODE } from '../../shared/driver-dir.mjs'; //
|
|
13
13
|
import { parseReport, parseAudit, parseSections, parseBlocks, stripInternal, parseCaseLawProfiles, parseCaseLawPreamble, joinCaseLawProfiles } from './parse.mjs';
|
|
14
14
|
import { renderHtml, parseActionBuckets, actYouConditions } from './render.mjs';
|
|
15
15
|
import { buildAudit } from './xlsx.mjs';
|
|
16
16
|
import { parseFindingsJson, parseFindingsJsonLenient, deriveDisplayVerdict, joinFindingToBlock, CLIENT_TIER_BY_COMPOSITE, projectCoverageJudgment } from '../findings-model.mjs';
|
|
17
|
-
import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; import { coverageFormStamp, readCoverageForm } from '../coverage-form-io.mjs'; import { coverageUnitLabel } from '../coverage-ledger.mjs'; // — and why an absence did not close
|
|
17
|
+
import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; import { coverageFormStamp, readCoverageForm } from '../coverage-form-io.mjs'; import { coverageUnitLabel } from '../coverage-ledger.mjs'; import { recallReceiptForOwnCompany } from '../known-conflicts.mjs'; // — and why an absence did not close; whose recall checks an audit lists
|
|
18
18
|
import { clearanceReportData } from './report-data.mjs';
|
|
19
19
|
import { searchDepthRecord, planTerritoriesOf } from './search-depth.mjs'; import { bandRecords } from '../named-band.mjs'; // how much was read to reach the answer, as counts and tokens
|
|
20
20
|
import { parseFrameworkManifest } from '../framework.mjs';
|
|
@@ -667,7 +667,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
667
667
|
})();
|
|
668
668
|
const asOf = new Date().toISOString(); // C2 — publish-time clock for the priority-window flag (render stays pure)
|
|
669
669
|
const poolRunDir = join(poolRoot, runId);
|
|
670
|
-
mkdirSync(poolRunDir, { recursive: true });
|
|
670
|
+
mkdirSync(poolRunDir, { recursive: true, mode: RUN_DIR_MODE }); // owner and group only; mkdir keeps the pool root's set-GID
|
|
671
671
|
// The run dir inherits the web-server group + the set-GID bit AUTOMATICALLY from the set-GID pool root
|
|
672
672
|
// (mode 2750, web-server group) — so files written inside take that group and the web server (Caddy in
|
|
673
673
|
// the reference deployment, mode 0640) can read them. Do NOT chmod this dir: the service account is not
|
|
@@ -852,7 +852,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
852
852
|
// which publishes without one: the throw left publishReport at `published → null` and the runner
|
|
853
853
|
// exited 1. Reading from the report's own directory is also the right answer for a republish.
|
|
854
854
|
const recallStore = readStore(runDir ?? dirname(reportMd), '_driver/register-recall.json');
|
|
855
|
-
const undispatchedProbes = (recallStore.value?.overflow ?? [])
|
|
855
|
+
const undispatchedProbes = (recallReceiptForOwnCompany(recallStore.value)?.overflow ?? []) // never another company's remembered conflict
|
|
856
856
|
.filter((o) => o && (o.term || o.qid))
|
|
857
857
|
.map((o) => ({
|
|
858
858
|
area: String(o.term ?? o.qid),
|
|
@@ -1071,12 +1071,23 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1071
1071
|
// the copy. The register-only LEVEL is retired, so no new run reaches this true; a run that already
|
|
1072
1072
|
// did still must, or its workbook would claim a sweep that never ran.
|
|
1073
1073
|
const registerOnly = isRegisterOnly(searchPolicy);
|
|
1074
|
+
// A RUN WHOSE COMPANY PICKED NO MARKETPLACES (the owner's ruling of 2026-09-23). Marketplaces were still
|
|
1075
|
+
// covered by the general web search, and by any stores the engine chose for the matter, so the lines that
|
|
1076
|
+
// describe the common-law layer say that instead of naming a marketplace search nobody picked. Read from
|
|
1077
|
+
// the frozen profile, which is the list the run actually searched; absent (a legacy run) reads as picked.
|
|
1078
|
+
let noMarketplacesPicked = false;
|
|
1079
|
+
try {
|
|
1080
|
+
const frozenProfile = runDir ? JSON.parse(readFileSync(driverDir(runDir, 'profile.json'), 'utf8')) : null;
|
|
1081
|
+
noMarketplacesPicked = Array.isArray(frozenProfile?.platforms) && frozenProfile.platforms.length === 0;
|
|
1082
|
+
} catch { /* no frozen profile — a legacy run keeps its wording */ }
|
|
1074
1083
|
|
|
1075
1084
|
// Jurisdiction line for the Summary — searched register scope + the common-law surface layer.
|
|
1076
1085
|
const regScope = (searchedJurisdictions && searchedJurisdictions.length) ? searchedJurisdictions.join('/') : 'Worldwide';
|
|
1077
1086
|
const jurisdiction = registerOnly
|
|
1078
1087
|
? `${regScope} (register only — no common-law / marketplace search)`
|
|
1079
|
-
:
|
|
1088
|
+
: noMarketplacesPicked
|
|
1089
|
+
? `${regScope} (register) + common-law (Western web / social, plus any stores chosen for this matter)`
|
|
1090
|
+
: `${regScope} (register) + common-law (Western web / marketplace / social)`;
|
|
1080
1091
|
|
|
1081
1092
|
// Common-law URL-join: a grid hit whose candidate URL resolves to a finding's own link PROVES that search
|
|
1082
1093
|
// term became a finding (rescues an anchor term whose finding wears a different name). Best-effort; absent
|
|
@@ -1224,6 +1235,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1224
1235
|
caseLawText: rdText(join(dirname(reportMd), 'case-law-findings.md')),
|
|
1225
1236
|
registerPlan: rdJson(driverDir(runBase, 'register-plan.json')),
|
|
1226
1237
|
laneDepthVerdicts,
|
|
1238
|
+
noMarketplacesPicked,
|
|
1227
1239
|
});
|
|
1228
1240
|
writeRO('search-depth.json', JSON.stringify(searchDepth, null, 2));
|
|
1229
1241
|
} catch { /* the depth record is additive — a publish never fails for want of it */ }
|