clearotron 0.2.4 → 0.3.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 +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +62 -38
- package/bin/brandowner.mjs +18 -169
- package/bin/clearotron.mjs +3 -1
- package/bin/connect.mjs +28 -19
- package/bin/disconnect.mjs +3 -3
- package/bin/example.mjs +7 -7
- package/bin/framework-preflight.mjs +49 -0
- package/bin/grant.mjs +151 -93
- package/bin/onboard.mjs +220 -138
- package/bin/start.mjs +146 -137
- package/bin/stop.mjs +2 -2
- package/bin/update.mjs +1 -1
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +2 -2
- package/docs/E2E.md +12 -2
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +14 -13
- package/docs/SECURITY.md +23 -24
- package/docs/architecture/04-configuration-reference.md +12 -5
- package/docs/architecture/05-config-governance.md +7 -7
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +5 -0
- package/docs/configuration.md +118 -0
- package/docs/decisions/0004-documentation-structure.md +2 -2
- package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
- package/driver/CHANGELOG.md +70 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/cancel.mjs +27 -0
- package/driver/case-law-sources.mjs +3 -3
- package/driver/company-bundle.mjs +261 -0
- package/driver/compare.mjs +1 -1
- package/driver/compose-read.mjs +2 -2
- package/driver/config-inventory.mjs +2 -2
- package/driver/contract-audit.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +1 -1
- package/driver/declination-call.mjs +1 -1
- package/driver/deliver-trigger.sh +3 -3
- package/driver/dev-portal.mjs +3 -1
- package/driver/digest-queue.mjs +1 -1
- package/driver/disposition-tool.mjs +1 -1
- package/driver/doc-constants.mjs +1 -1
- package/driver/drain-posture.mjs +2 -2
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +44 -9
- package/driver/effective-scope.mjs +30 -1
- package/driver/effort-model.mjs +6 -6
- package/driver/engine/CONTRACT.md +2 -2
- package/driver/engine/anthropic-agent.mjs +11 -11
- package/driver/engine/jx-turn.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +29 -4
- package/driver/engine/mcp/recording-server.mjs +73 -1
- package/driver/engine/openai-agent.mjs +1 -1
- package/driver/engine/probe.mjs +28 -4
- package/driver/enqueue-schema.mjs +23 -3
- package/driver/findings-model.mjs +2 -2
- package/driver/flag-snapshot.mjs +2 -2
- package/driver/floor-duty.mjs +2 -2
- package/driver/frame-diff-model.mjs +1 -1
- package/driver/framework-preflight.mjs +143 -0
- package/driver/gateway.mjs +9 -1
- package/driver/hit-list.mjs +1 -1
- package/driver/jx-lanes.mjs +1 -1
- package/driver/jx.mjs +1 -1
- package/driver/knockout-assess-record.mjs +1 -1
- package/driver/knockout-review-record.mjs +435 -0
- package/driver/order-probe.mjs +1 -1
- package/driver/outbox-backoff.mjs +2 -2
- package/driver/owner-use-check.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +105 -8
- package/driver/pipeline.mjs +81 -30
- package/driver/plain-register.mjs +77 -3
- package/driver/portal-access.mjs +141 -74
- package/driver/portal-config-view.mjs +59 -70
- package/driver/portal-report.mjs +4 -4
- package/driver/portal-service.mjs +348 -141
- package/driver/portal-upstream.mjs +105 -17
- package/driver/predelivery-lint.mjs +43 -18
- package/driver/product-rows.mjs +1 -1
- package/driver/products.mjs +1 -1
- package/driver/profile-page.html +30 -5
- package/driver/profile-service.mjs +197 -26
- package/driver/profiles.mjs +48 -1
- package/driver/publish/index.mjs +31 -13
- package/driver/publish/knockout.mjs +9 -5
- package/driver/publish/office-record-links.mjs +189 -0
- package/driver/publish/parse.mjs +3 -3
- package/driver/publish/publish-inputs.mjs +26 -0
- package/driver/publish/render-knockout.mjs +42 -42
- package/driver/publish/render.mjs +29 -4
- package/driver/publish/report-data.mjs +2 -2
- package/driver/publish/seed-pool.mjs +1 -1
- package/driver/publish/templates/report.css +8 -8
- package/driver/publish/xlsx.mjs +49 -7
- package/driver/queue-watch-verdict.mjs +2 -2
- package/driver/recipe-service.mjs +1 -1
- package/driver/record-carry.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/reference-strip-signatures.mjs +1 -1
- package/driver/register-availability.mjs +4 -3
- package/driver/register-count.mjs +3 -3
- package/driver/register-records.mjs +1 -1
- package/driver/repair-composers.mjs +1 -1
- package/driver/repairs.mjs +3 -3
- package/driver/replay-archive.mjs +1 -1
- package/driver/report-card-record.mjs +1 -1
- package/driver/result-noun-fields.mjs +5 -0
- package/driver/roster-verdict.mjs +48 -5
- package/driver/run-activity.mjs +1 -1
- package/driver/run-requirements.mjs +18 -5
- package/driver/runner.mjs +24 -15
- package/driver/search-policy.mjs +8 -8
- package/driver/senior-rights.mjs +1 -1
- package/driver/skills/prelim-search/delivery-contract.md +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
- package/driver/stages-knockout.mjs +72 -6
- package/driver/stages.mjs +10 -10
- package/driver/suite-census.json +238 -70
- package/driver/synthesis-record.mjs +2 -2
- package/driver/systemd/clearotron-client-mcp.service +3 -3
- package/driver/systemd/clearotron-deploy.service +2 -2
- package/driver/systemd/clearotron-mcp-face.service +1 -1
- package/driver/systemd/clearotron-portal.service +3 -3
- package/driver/systemd/clearotron-worker.service +5 -5
- package/driver/systemd/install-census.mjs +1 -1
- package/driver/systemd/render-units.mjs +9 -9
- package/driver/terminal-clamp.mjs +1 -1
- package/driver/trigger-cap.mjs +18 -2
- package/driver/unit-inventory.mjs +8 -8
- package/driver/usage-ledger.mjs +5 -3
- package/driver/verify-knockout.mjs +7 -7
- package/driver/verify.mjs +5 -5
- package/driver/whatif-memo-run.mjs +1 -1
- package/driver/whatif-queue.mjs +3 -3
- package/driver/whatif-worker.mjs +2 -2
- package/examples/README.md +1 -1
- package/examples/grants.example.json +25 -24
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/http-server.mjs +3 -3
- package/mcp-server/key-socket.mjs +1 -1
- package/mcp-server/lib/audit-view.mjs +3 -3
- package/mcp-server/lib/brief.mjs +3 -3
- package/mcp-server/lib/driver.mjs +1 -1
- package/mcp-server/lib/events.mjs +1 -1
- package/mcp-server/lib/http-handler.mjs +2 -2
- package/mcp-server/lib/instructions.mjs +2 -2
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/ops.mjs +6 -3
- package/mcp-server/lib/options.mjs +15 -4
- package/mcp-server/lib/plan.mjs +7 -6
- package/mcp-server/lib/runs.mjs +10 -0
- package/mcp-server/lib/whatif.mjs +5 -5
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/remote/client-mcp-apikey.service +1 -1
- package/mcp-server/remote/client-mcp.service +2 -2
- package/mcp-server/remote/trademark-artifacts-http.service +1 -1
- package/mcp-server/server.mjs +38 -24
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
- package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/lane-probe.mjs +9 -3
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
- package/scripts/citation-drift-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/drive-env-check.mjs +1 -1
- package/scripts/e2e.mjs +5 -5
- package/scripts/env-audit.mjs +13 -1
- package/scripts/headless-page.mjs +5 -5
- package/scripts/live-surface-check.mjs +26 -2
- package/scripts/mint-names-in-force.mjs +19 -5
- package/scripts/mint-reference-strip-backlog.mjs +1 -1
- package/scripts/mint-suite-census.mjs +37 -10
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/preinstall-node-check.mjs +1 -1
- package/scripts/release-await-cut.mjs +3 -3
- package/scripts/release-cut-decision.mjs +1 -1
- package/scripts/release-dist-tag.mjs +1 -1
- package/scripts/release-install-check.mjs +1 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/scripts/release-publish-guard.mjs +1 -1
- package/scripts/release-version-pr-checks.mjs +2 -2
- package/scripts/release-version.mjs +61 -5
- package/scripts/render-brand-banner.mjs +1 -1
- package/scripts/render-check.mjs +2 -2
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-frame-check.mjs +1 -1
- package/scripts/report-screenshot.mjs +2 -2
- package/scripts/retire-bare-refs.mjs +1 -1
- package/scripts/revisit-render-check.mjs +1 -1
- package/scripts/score.mjs +1 -1
- package/scripts/strip-titles-and-attributions.mjs +389 -0
- package/scripts/strip-tracker-citations.mjs +122 -5
- package/scripts/test-run.mjs +4 -4
- package/scripts/third-party-notices.mjs +1 -1
- package/scripts/verify-publishable.mjs +1 -1
- package/shared/access-audience.mjs +2 -2
- package/shared/anon-overlay.mjs +1 -1
- package/shared/brand.mjs +15 -1
- package/shared/bundle-freshness.mjs +1 -1
- package/shared/bundle-rebuild.mjs +1 -1
- package/shared/checkout-move.mjs +2 -2
- package/shared/client-door.mjs +8 -8
- package/shared/connect-clients.mjs +7 -7
- package/shared/connector-signin-probe.mjs +1 -1
- package/shared/env-aliases.mjs +1 -1
- package/shared/env-local.mjs +5 -5
- package/shared/grants-edit.mjs +76 -0
- package/shared/install-auth.mjs +1 -1
- package/shared/listen.mjs +3 -3
- package/shared/mcp-challenge.mjs +1 -1
- package/shared/names-in-force.mjs +2 -1
- package/shared/onboarding-store.mjs +19 -2
- package/shared/reference-guard-classes.mjs +44 -2
- package/shared/register-selection.mjs +1 -1
- package/shared/scope.mjs +223 -56
- package/shared/secret-file.mjs +1 -1
- package/shared/server-units.mjs +1 -1
- package/shared/staff-domain.mjs +45 -78
- package/shared/summary-blocks.mjs +2 -2
- package/shared/systemd-failure.mjs +3 -3
- package/shared/tracked-files.mjs +1 -1
- package/shared/trigger-lane.mjs +1 -1
- package/shared/tty-style.mjs +1 -1
- package/shared/usage-block.mjs +1 -1
- package/shared/vacuous-pass.mjs +1 -1
- package/shared/verb-shim.mjs +1 -1
|
@@ -32,6 +32,8 @@ import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, de
|
|
|
32
32
|
import { fileURLToPath } from "node:url";
|
|
33
33
|
import { dirname, join } from "node:path";
|
|
34
34
|
import { storeInRepo, storeOutsideRepoMessage, makeCommittableAudit, commitWithAuditRow, makeStoreCommit } from "../shared/store-in-repo.mjs"; //
|
|
35
|
+
import { resolveFramework, resolvePlatforms, buildProfile, assertRosterAccepts, companyKeyFrom } from "./company-bundle.mjs";
|
|
36
|
+
import { defaultTerritoryState } from "./effective-scope.mjs"; // one reading of which stored territories the engine can search
|
|
35
37
|
import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mjs"; // — one store for the surface and the runs
|
|
36
38
|
import {
|
|
37
39
|
loadProfiles as loadProfilesDefault, validateProfileEdit as validateProfileEditDefault,
|
|
@@ -39,7 +41,7 @@ import {
|
|
|
39
41
|
assertProfileKey, derivedFloor, derivedBatchSize, CONTEXT_PACK_FILE, DEFAULT_DELIVERY_TEMPLATE,
|
|
40
42
|
} from "./profiles.mjs";
|
|
41
43
|
|
|
42
|
-
import { DEFAULT_FRAMEWORK, DEFAULT_WORKED_EXAMPLES, loadFrameworkManifest } from "./framework.mjs";
|
|
44
|
+
import { DEFAULT_FRAMEWORK, DEFAULT_WORKED_EXAMPLES, loadFrameworkManifest, manifestPathFor } from "./framework.mjs";
|
|
43
45
|
import { config } from "./driver.config.mjs";
|
|
44
46
|
import { productRows } from "./product-rows.mjs"; // the offering, so the editor never hand-types a menu
|
|
45
47
|
import { dirname as pathDirname } from "node:path";
|
|
@@ -57,7 +59,34 @@ import { accessAudience, audienceLabel } from "../shared/access-audience.mjs";
|
|
|
57
59
|
// at the wrong tree looked, from every log, exactly like a customer who simply has no custom framework.
|
|
58
60
|
// The page said "could not be read" and nothing anywhere said why. Log it once, at the point of loss.
|
|
59
61
|
const DRIVER_DIR = pathDirname(toPath(import.meta.url));
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* SAY IT ONCE WHEN A FRAMEWORK COMES OUT OF THE SHIPPED TREE INSTEAD OF THE STORE.
|
|
65
|
+
*
|
|
66
|
+
* Skill resolution falls back from the config store to the repo, and for a generic methodology file that
|
|
67
|
+
* fallback is the migration working as designed. A FRAMEWORK is the exception, because the shipped tree
|
|
68
|
+
* carries decks under the same filenames customers use for their own — take a customer's deck out of the
|
|
69
|
+
* store and the repo's copy answers. It is readable, it is valid, and it is somebody else's rubric, and
|
|
70
|
+
* the matter is rated under it with nothing raised anywhere.
|
|
71
|
+
*
|
|
72
|
+
* WARNED ONCE PER PATH, DELIBERATELY. These run on every profile view. A line per view on a healthy
|
|
73
|
+
* install is a log flood, and a log flood is silenced, which would reproduce the silence this exists to
|
|
74
|
+
* break. The set is per-process and never cleared: the fact does not change while the process runs.
|
|
75
|
+
*/
|
|
76
|
+
const substitutionsSaid = new Set();
|
|
77
|
+
function sayIfSubstituted(rel) {
|
|
78
|
+
try {
|
|
79
|
+
const r = config.resolveSkillPathReport(rel);
|
|
80
|
+
if (r.layer !== "base" || substitutionsSaid.has(r.path)) return;
|
|
81
|
+
substitutionsSaid.add(r.path);
|
|
82
|
+
console.error(`[profile-service] framework served from the shipped tree, not the configured store: ${rel} `
|
|
83
|
+
+ `is not in the store, so ${r.path} answered. If a customer's own deck was removed from the store, `
|
|
84
|
+
+ `the shipped file of the same name is now rating their matters.`);
|
|
85
|
+
} catch { /* an unreadable overlay is reported by the resolution the caller is about to make */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
function manifestFor(fwPath) {
|
|
89
|
+
sayIfSubstituted(manifestPathFor(fwPath));
|
|
61
90
|
try { return loadFrameworkManifest((rel) => config.resolveSkillPath(rel), fwPath); }
|
|
62
91
|
catch (e) {
|
|
63
92
|
console.error(`[profile-service] framework manifest unreadable for ${fwPath}: ${String(e?.message ?? e)} `
|
|
@@ -78,23 +107,18 @@ const stripMd = (s) => String(s ?? "").replace(/\*+/g, "").trim();
|
|
|
78
107
|
// matrix-shaped decks: the deck's "Band meanings" table is the only table whose FIRST cell is
|
|
79
108
|
// exactly the band label (the matrix table suffixes its labels with the deck's internal indices, e.g.
|
|
80
109
|
// "**Very High** *(5)*") — take that row's cells as { band, meaning, response }.
|
|
81
|
-
function
|
|
110
|
+
function matrixBandRows(deck, manifest) {
|
|
82
111
|
const tableRows = deck.split("\n").filter((l) => /^\s*\|.*\|\s*$/.test(l));
|
|
83
|
-
|
|
84
|
-
for (const b of manifest.bands) {
|
|
112
|
+
return manifest.bands.map((b) => {
|
|
85
113
|
const want = b.label.trim().toLowerCase();
|
|
86
|
-
let hit = null;
|
|
87
114
|
for (const line of tableRows) {
|
|
88
115
|
const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map(stripMd);
|
|
89
|
-
if (cells.length >= 3 && cells[0].toLowerCase() === want && cells[1] && cells[2])
|
|
90
|
-
|
|
91
|
-
break;
|
|
92
|
-
}
|
|
116
|
+
if (cells.length >= 3 && cells[0].toLowerCase() === want && cells[1] && cells[2])
|
|
117
|
+
return { band: b.label, meaning: cells[1], response: cells[2] };
|
|
93
118
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
return out;
|
|
119
|
+
return { band: b.label, miss: "no table row starts with this band label and then states a meaning and a response — "
|
|
120
|
+
+ "a matrix-shaped deck defines its bands in a three-column table" };
|
|
121
|
+
});
|
|
98
122
|
}
|
|
99
123
|
|
|
100
124
|
// bands-shaped decks (the house default among them): each band lives under its own heading ("## VERY HIGH RISK") — find
|
|
@@ -120,7 +144,7 @@ function matrixBandMeanings(deck, manifest) {
|
|
|
120
144
|
// design is the protection it was actually for: a band whose section is missing, or whose section
|
|
121
145
|
// states no rungs at all, is a GARBLED deck and still returns null, because a half-shown framework
|
|
122
146
|
// misleads worse than an absent one. A merely renamed rung is not that case.
|
|
123
|
-
function
|
|
147
|
+
function bandsBandRows(deck, manifest) {
|
|
124
148
|
const re = /^#{1,6}[ \t]*([^\n]+)$/gm;
|
|
125
149
|
const heads = [];
|
|
126
150
|
for (let m; (m = re.exec(deck)); ) heads.push({ text: stripMd(m[1]), start: m.index, end: m.index + m[0].length });
|
|
@@ -129,7 +153,7 @@ function bandsBandMeanings(deck, manifest) {
|
|
|
129
153
|
for (const b of manifest.bands) {
|
|
130
154
|
const want = b.label.trim().toLowerCase();
|
|
131
155
|
const sec = sections.find((s) => s.head === want || (s.head.startsWith(want) && !/[a-z0-9]/i.test(s.head.charAt(want.length))));
|
|
132
|
-
if (!sec)
|
|
156
|
+
if (!sec) { out.push({ band: b.label, miss: "no heading whose text is this band label, or begins with it" }); continue; }
|
|
133
157
|
// Every top-level "- **Label.** text" bullet the band states, in the deck's order. The label is the
|
|
134
158
|
// deck's own word for the rung; nothing here decides which rungs exist or what they may be called.
|
|
135
159
|
const rungs = [];
|
|
@@ -138,7 +162,11 @@ function bandsBandMeanings(deck, manifest) {
|
|
|
138
162
|
const text = stripMd(m[2]).trim();
|
|
139
163
|
if (label && text) rungs.push({ label, text });
|
|
140
164
|
}
|
|
141
|
-
if (!rungs.length)
|
|
165
|
+
if (!rungs.length) {
|
|
166
|
+
out.push({ band: b.label, miss: "the section under this band's heading states no rungs — a bands-shaped deck "
|
|
167
|
+
+ "writes each rung as a top-level `- **Label.** text` bullet" });
|
|
168
|
+
continue; // a section stating no rungs is a garbled deck — see above
|
|
169
|
+
}
|
|
142
170
|
// `meaning` IS KEPT, and it is not vestigial: `driver/profile-page.html` renders these rows too and
|
|
143
171
|
// reads exactly this field, so dropping it would blank a second surface that nobody asked me to
|
|
144
172
|
// change. It carries the LAST rung — the consequences one in both the shipped deck and the completed
|
|
@@ -148,19 +176,79 @@ function bandsBandMeanings(deck, manifest) {
|
|
|
148
176
|
return out;
|
|
149
177
|
}
|
|
150
178
|
|
|
151
|
-
/**
|
|
152
|
-
*
|
|
153
|
-
|
|
179
|
+
/**
|
|
180
|
+
* A refusal, worded for the person in the browser.
|
|
181
|
+
*
|
|
182
|
+
* THE SAME FACTS, A DIFFERENT READER. The shared create path's own sentences are written for somebody who
|
|
183
|
+
* typed a command: they name the flag that was missing and the directory that was written to, which is
|
|
184
|
+
* exactly what that reader needs. The person on the New company page typed neither. A sentence about
|
|
185
|
+
* `--platforms` and a filesystem path does not tell them what to do; it tells them they are in the wrong
|
|
186
|
+
* product.
|
|
187
|
+
*
|
|
188
|
+
* So the code is worded here and the facts ride alongside, which is what lets the screen offer a link to
|
|
189
|
+
* the company that already holds a key rather than printing its slug. An unrecognised code falls back to
|
|
190
|
+
* the original message: wrong for this reader, but never blank — an unworded refusal must not become a
|
|
191
|
+
* silent one.
|
|
192
|
+
*/
|
|
193
|
+
export function browserRefusal(e) {
|
|
194
|
+
const d = e?.detail ?? {};
|
|
195
|
+
switch (e?.code) {
|
|
196
|
+
case "key_exists":
|
|
197
|
+
return { error: "refused", code: "key_exists", key: d.key,
|
|
198
|
+
message: `A company is already filed under "${d.key}". Open it to make changes, or choose a different name.` };
|
|
199
|
+
case "domain_claimed":
|
|
200
|
+
return { error: "refused", code: "domain_claimed", domain: d.domain, heldBy: d.heldBy,
|
|
201
|
+
message: `${d.domain} is already used by another company. An address can only belong to one, `
|
|
202
|
+
+ `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
|
+
case "framework_missing":
|
|
208
|
+
return { error: "refused", code: "framework_missing",
|
|
209
|
+
message: "The risk framework this company was pointed at is not on this installation, so nothing "
|
|
210
|
+
+ "was created. Rating a company under a framework nobody chose is worse than refusing." };
|
|
211
|
+
case "invalid_bundle":
|
|
212
|
+
// 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 ?? []).join(" ") || String(e.message) };
|
|
215
|
+
default:
|
|
216
|
+
return { error: "refused", message: String(e.message) };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Every band's row, hit or miss — deck text + validated manifest → one entry per manifest band, in the
|
|
222
|
+
* manifest's order, each either a rendered row or `{ band, miss }` saying what the deck did not do.
|
|
223
|
+
*
|
|
224
|
+
* ONE PREMISE, TWO READERS. `extractBandMeanings` below is the renderer's verdict and collapses to null
|
|
225
|
+
* on any miss, because a half-shown framework misleads worse than an absent one. The pre-flight needs the
|
|
226
|
+
* opposite of a verdict: an author staring at an empty box has to be told WHICH band and WHY. Writing
|
|
227
|
+
* that as a second walk of the same deck would put two matchers on one property, and the day they drift
|
|
228
|
+
* the pre-flight blesses a deck the renderer drops. So the walk happens once, here, and the null is a
|
|
229
|
+
* collapse of this result rather than a separate decision.
|
|
230
|
+
*
|
|
231
|
+
* Never throws: a deck is a customer's file and may be anything at all.
|
|
232
|
+
*/
|
|
233
|
+
export function bandMeaningRows(deckText, manifest) {
|
|
154
234
|
try {
|
|
155
235
|
if (!deckText || !manifest || !Array.isArray(manifest.bands) || !manifest.bands.length) return null;
|
|
156
236
|
return manifest.structure?.kind === "matrix"
|
|
157
|
-
?
|
|
158
|
-
:
|
|
237
|
+
? matrixBandRows(String(deckText), manifest)
|
|
238
|
+
: bandsBandRows(String(deckText), manifest);
|
|
159
239
|
} catch { return null; }
|
|
160
240
|
}
|
|
161
241
|
|
|
242
|
+
/** Pure extraction (exported for the tests): deck text + validated manifest → [{ band, meaning, response? }]
|
|
243
|
+
* in manifest band order, or null on ANY miss. Never throws. */
|
|
244
|
+
export function extractBandMeanings(deckText, manifest) {
|
|
245
|
+
const rows = bandMeaningRows(deckText, manifest);
|
|
246
|
+
return rows && !rows.some((r) => r.miss) ? rows : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
162
249
|
function bandMeaningsFor(fwPath, manifest) {
|
|
163
250
|
if (!manifest) return null;
|
|
251
|
+
sayIfSubstituted(fwPath);
|
|
164
252
|
try { return extractBandMeanings(readFileSync(config.resolveSkillPath(fwPath), "utf8"), manifest); }
|
|
165
253
|
catch (e) {
|
|
166
254
|
// Same reasoning as manifestFor: a silent null here blanks the "What the bands mean" box while the
|
|
@@ -194,7 +282,7 @@ function normalizeDefaultProduct(incoming, existing) {
|
|
|
194
282
|
}
|
|
195
283
|
return incoming;
|
|
196
284
|
}
|
|
197
|
-
// marketplaceDensity has NO CONTROL ON ANY SURFACE since the
|
|
285
|
+
// marketplaceDensity has NO CONTROL ON ANY SURFACE since the ruling of 2026-08-29 ("get rid of it
|
|
198
286
|
// completely. there is no such thing as staff only"), and it is NOT code-owned — so without this it would
|
|
199
287
|
// be stripped on the next save of any profile that has one.
|
|
200
288
|
//
|
|
@@ -210,7 +298,7 @@ function normalizeDefaultProduct(incoming, existing) {
|
|
|
210
298
|
// NO CONTROL ON ANY SURFACE, and profile-page.html reconstructs its payload from the inputs it has — so
|
|
211
299
|
// each one would be stripped on the next staff save of any profile carrying it.
|
|
212
300
|
//
|
|
213
|
-
// marketplaceDensity the control was removed by
|
|
301
|
+
// marketplaceDensity the control was removed by ruling; the value sizes
|
|
214
302
|
// the grid batch and losing it re-arms a measured truncation crash.
|
|
215
303
|
// demoData never had a control; losing it turns a demo account into
|
|
216
304
|
// one indistinguishable from a client, which is the exact failure the marker
|
|
@@ -283,7 +371,15 @@ export function makeProfileService({
|
|
|
283
371
|
key: profile.key,
|
|
284
372
|
profile: stripDerived(profile),
|
|
285
373
|
contextPack: readPack(profile.key),
|
|
286
|
-
derived: { minCellsPerVariant: derivedFloor(profile), batchSize: derivedBatchSize(profile)
|
|
374
|
+
derived: { minCellsPerVariant: derivedFloor(profile), batchSize: derivedBatchSize(profile),
|
|
375
|
+
// The stored default territories the engine cannot search, NAMED. Without this the profile
|
|
376
|
+
// screen shows the entries back exactly as typed and the engine quietly ignores them — which is
|
|
377
|
+
// what a person saw before: a setting that reads as in force and does nothing.
|
|
378
|
+
//
|
|
379
|
+
// It is computed here rather than read off a run, because it is a fact about the PROFILE. A
|
|
380
|
+
// reader who only hears about it on the runs where it happened to apply learns about a broken
|
|
381
|
+
// setting at random.
|
|
382
|
+
unrecognizedTerritories: defaultTerritoryState(profile).unrecognized },
|
|
287
383
|
framework: {
|
|
288
384
|
path: fwPath,
|
|
289
385
|
// CUSTOM MEANS "NOT THE GENERIC DEFAULT", which is what the word means to the lawyer reading the
|
|
@@ -318,6 +414,80 @@ export function makeProfileService({
|
|
|
318
414
|
const parts = path.replace(/\/+$/, "").split("/").filter(Boolean); // ["profiles", key?, action?]
|
|
319
415
|
if (parts[0] !== "profiles") return { status: 404, json: { error: "not_found" } };
|
|
320
416
|
|
|
417
|
+
// POST /profiles — CREATE, which is not the same act as save and must not route through it.
|
|
418
|
+
//
|
|
419
|
+
// `save` is an upsert and looks like it would do: it would not. `preserveCodeOwned` takes the
|
|
420
|
+
// on-disk value of every code-owned field and DELETES the field when there is none, and a company
|
|
421
|
+
// being created has nothing on disk — so a create through save writes no `frameworkPath`, silently,
|
|
422
|
+
// and the company is rated under the house default from then on. That is live today through the
|
|
423
|
+
// staff page and it is what this route exists not to repeat.
|
|
424
|
+
//
|
|
425
|
+
// Every question below is asked of `driver/company-bundle.mjs`, which the command line asks too.
|
|
426
|
+
// There is no second opinion here about what a valid company is.
|
|
427
|
+
if (parts.length === 1 && method === "POST") {
|
|
428
|
+
const name = isStr(body?.name) ? body.name.trim() : "";
|
|
429
|
+
if (!name) return { status: 400, json: { error: "needs_name", message: "A company needs a name." } };
|
|
430
|
+
|
|
431
|
+
// The key is derived from the name unless one was asked for, and it is REFUSED rather than
|
|
432
|
+
// repaired: a name of punctuation alone derives nothing, and inventing a key there would file a
|
|
433
|
+
// company under something nobody chose.
|
|
434
|
+
const wanted = isStr(body?.key) && body.key.trim() ? body.key.trim().toLowerCase() : companyKeyFrom(name);
|
|
435
|
+
if (!wanted) {
|
|
436
|
+
return { status: 400, json: { error: "needs_key",
|
|
437
|
+
message: `No key could be made from "${name}". Type one — lowercase letters, digits and hyphens.` } };
|
|
438
|
+
}
|
|
439
|
+
try { assertProfileKey(wanted); }
|
|
440
|
+
catch (e) { return { status: 400, json: { error: "bad_key", message: String(e.message) } }; }
|
|
441
|
+
|
|
442
|
+
const existing = resolveExisting();
|
|
443
|
+
let framework, platforms, profile;
|
|
444
|
+
try {
|
|
445
|
+
// ALWAYS SET, NEVER ABSENT — the company's own when one is supplied, the house default named
|
|
446
|
+
// otherwise. This is the whole reason a create does not go through save.
|
|
447
|
+
framework = resolveFramework(isStr(body?.frameworkPath) ? body.frameworkPath : null);
|
|
448
|
+
platforms = resolvePlatforms(Array.isArray(body?.platforms) ? body.platforms : null, existing);
|
|
449
|
+
profile = buildProfile({
|
|
450
|
+
key: wanted, name,
|
|
451
|
+
domains: Array.isArray(body?.matchDomains) ? body.matchDomains : [],
|
|
452
|
+
platforms: platforms.platforms,
|
|
453
|
+
// The whole resolution, not its path: buildProfile reads `.path` off it, and handing it the
|
|
454
|
+
// string writes `frameworkPath: undefined` — which is precisely the silent-absence defect this
|
|
455
|
+
// route exists to prevent, arriving through the route that prevents it.
|
|
456
|
+
framework,
|
|
457
|
+
industry: isStr(body?.industry) && body.industry.trim() ? body.industry.trim() : null,
|
|
458
|
+
// Carried, not dropped. A form that offers a field and a route that ignores it is the silent
|
|
459
|
+
// data loss this whole route exists to stop, arriving one layer further in. Arrays only —
|
|
460
|
+
// anything else is left absent rather than guessed at, and the validator then rules on it.
|
|
461
|
+
tradingNames: Array.isArray(body?.selfExclusionOwners) ? body.selfExclusionOwners : [],
|
|
462
|
+
classes: Array.isArray(body?.defaultClasses) ? body.defaultClasses : [],
|
|
463
|
+
territories: Array.isArray(body?.defaultJurisdictions) ? body.defaultJurisdictions : [],
|
|
464
|
+
});
|
|
465
|
+
// The proposed roster, validated whole. A colliding domain does not fail this company — it
|
|
466
|
+
// stops the deployment resolving ANY of them at the next start, so it is caught before a write
|
|
467
|
+
// rather than discovered by the next process to read profiles.
|
|
468
|
+
assertRosterAccepts({ store: profileDir, key: wanted, profile, loadProfiles });
|
|
469
|
+
} catch (e) {
|
|
470
|
+
if (e?.name === "Refusal") return { status: 400, json: browserRefusal(e) };
|
|
471
|
+
throw e;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const { files } = writeProfile({ profileDir, key: wanted, profile, contextPack: "" });
|
|
475
|
+
// WRITTEN AND RECORDED ARE TWO EVENTS. The write is live the instant it renames; the commit can
|
|
476
|
+
// fail on its own. Reporting them as one is how somebody is told nothing happened about a company
|
|
477
|
+
// that is already governing runs.
|
|
478
|
+
const message = `chore(clearotron): create company ${wanted} (via portal, by ${by})`;
|
|
479
|
+
const { commit, commitError } = commitWithAuditRow({ audit, gitCommit, files, message, by,
|
|
480
|
+
row: { event: "profile-create", key: wanted, by, fields: Object.keys(profile) } });
|
|
481
|
+
return { status: 201, json: {
|
|
482
|
+
key: wanted, name, written: true, created: true, commit,
|
|
483
|
+
// The receipt reads off THESE, so it can say which framework and how many marketplaces, and
|
|
484
|
+
// whether each was chosen or defaulted, rather than restating what was typed.
|
|
485
|
+
framework: { path: framework.path, defaulted: framework.source === "default" },
|
|
486
|
+
marketplaces: { count: platforms.platforms.length, defaulted: platforms.source !== "supplied" },
|
|
487
|
+
...(commitError ? { commitError: `created and LIVE, but the git commit failed (${commitError}) — the audit line records the gap` } : {}),
|
|
488
|
+
} };
|
|
489
|
+
}
|
|
490
|
+
|
|
321
491
|
// GET /profiles — roster
|
|
322
492
|
if (parts.length === 1) {
|
|
323
493
|
if (method !== "GET") return { status: 405, json: { error: "method_not_allowed" } };
|
|
@@ -333,6 +503,7 @@ export function makeProfileService({
|
|
|
333
503
|
} };
|
|
334
504
|
}
|
|
335
505
|
|
|
506
|
+
|
|
336
507
|
const key = parts[1];
|
|
337
508
|
const action = parts[2];
|
|
338
509
|
|
|
@@ -409,7 +580,7 @@ export function makeProfileService({
|
|
|
409
580
|
if (overlayBody.defaultProduct === undefined && priorOverlay?.defaultProduct !== undefined)
|
|
410
581
|
overlayBody.defaultProduct = priorOverlay.defaultProduct;
|
|
411
582
|
// marketplaceDensity: same rule, and now permanent rather than "yet" — the control was removed from
|
|
412
|
-
// both surfaces by
|
|
583
|
+
// both surfaces by ruling, so an overlay's value can only ever come from the file it is in.
|
|
413
584
|
preserveUncontrolled(overlayBody, priorOverlay);
|
|
414
585
|
// ARCHIVE STATE IS STICKY AGAINST OMISSION — recipe-service.mjs's discipline verbatim: un-archiving
|
|
415
586
|
// takes an EXPLICIT archived:false, never a body that simply lacks the key. This is not theoretical:
|
|
@@ -627,7 +798,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
627
798
|
//
|
|
628
799
|
// `makeAccessVerifier` has ALWAYS accepted issuer/jwksUrl/emailClaim — this service simply never
|
|
629
800
|
// passed them, which is how one product shipped a provider-agnostic API face and three single-vendor
|
|
630
|
-
// services beside it.
|
|
801
|
+
// services beside it. Ruling 2026-08-23: "we can't launch with an identity vendor, they bring
|
|
631
802
|
// their own… they pick their own."
|
|
632
803
|
const OIDC_ISSUER = process.env.PROFILE_OIDC_ISSUER || "";
|
|
633
804
|
const JWKS_URL = process.env.PROFILE_JWKS_URL || "";
|
package/driver/profiles.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
|
20
20
|
import { join, dirname } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { ORDERABLE_PRODUCTS } from "./search-policy.mjs";
|
|
23
|
+
import { recognizedTerritories } from "./territory-tiers.mjs"; // which stored territories the engine can actually search
|
|
23
24
|
import { envFrom } from "../shared/env-aliases.mjs"; // — a refusal names the name in force
|
|
24
25
|
|
|
25
26
|
// CLEAROTRON_CUSTOMERS_DIR selects the customer config STORE; the bundled driver/profiles is the
|
|
@@ -216,6 +217,31 @@ export function withRunPlatforms(profile, jobPlatforms) {
|
|
|
216
217
|
return { profile: { ...profile, platforms: [...(profile?.platforms ?? []), ...added] }, added };
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
// The three facts that tell one company from another at a glance, for the surfaces a person picks on.
|
|
221
|
+
//
|
|
222
|
+
// ONE SHAPE, TWO ROUTES. The staff roster and a client's own /me both answer this, and they answered
|
|
223
|
+
// different questions before: the roster carried key and name, /me carried names alone. A picker that
|
|
224
|
+
// renders a facts line from one and a bare name from the other shows the same company two ways
|
|
225
|
+
// depending on who signed in — the exact defect ownerNames.mjs was written to end for the NAME.
|
|
226
|
+
//
|
|
227
|
+
// THE COUNT, NEVER THE LIST. `platformCount` is what a reader sees ("6 marketplaces"); the platform
|
|
228
|
+
// list is which marketplaces this account has us sweep, and no surface here asks for it. Sending the
|
|
229
|
+
// array so the browser can call `.length` would put that on the wire for every granted identity.
|
|
230
|
+
//
|
|
231
|
+
// Absent industry reads as null and the caller omits the segment: "Generic default" carries no
|
|
232
|
+
// industry and no territories, and a line reading "· 3 marketplaces ·" with empty ends is worse than
|
|
233
|
+
// a shorter line.
|
|
234
|
+
export function companyFactsOf(profile) {
|
|
235
|
+
const industry = typeof profile?.industry === "string" && profile.industry.trim() ? profile.industry.trim() : null;
|
|
236
|
+
return {
|
|
237
|
+
industry,
|
|
238
|
+
platformCount: Array.isArray(profile?.platforms) ? profile.platforms.length : 0,
|
|
239
|
+
territories: Array.isArray(profile?.defaultJurisdictions)
|
|
240
|
+
? profile.defaultJurisdictions.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim())
|
|
241
|
+
: [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
219
245
|
// F7 — the closed set of profile-file keys (deny-unknown-key). Every key here has a live consumer
|
|
220
246
|
// recorded in FIELD_CONSUMERS; an unknown key is a dead knob or a typo and hard-fails at load (the
|
|
221
247
|
// loader silently tolerated unknowns before). `minCellsPerVariant`/`batchSize` are intentionally
|
|
@@ -620,6 +646,27 @@ export function validateProfileEdit(key, profileObj, contextPack = "", { sparse
|
|
|
620
646
|
if (contextPack && String(contextPack).trim()) {
|
|
621
647
|
try { assertContextPackShape(String(contextPack), "context pack"); } catch (e) { errors.push(String(e.message)); }
|
|
622
648
|
}
|
|
649
|
+
// A DEFAULT TERRITORY THE ENGINE CANNOT SEARCH IS REFUSED WHERE IT IS WRITTEN.
|
|
650
|
+
//
|
|
651
|
+
// HERE AND NOT IN validateProfileShape, and the difference is a roster-wide outage. That function runs
|
|
652
|
+
// on the LOAD path, so a refusal there would stop every profile already holding such an entry from
|
|
653
|
+
// loading — and when the loader throws, it does not fail that one bundle, it fails the deployment's
|
|
654
|
+
// whole roster and every customer's clearance with it. This wrapper is the WRITE path: the two doors
|
|
655
|
+
// that create, the two that save, and the repository lint. Nothing here can refuse a profile that is
|
|
656
|
+
// already on disk.
|
|
657
|
+
//
|
|
658
|
+
// It does not narrow what a REQUEST may name. That tolerance is deliberate and is a decision about what
|
|
659
|
+
// a client receives; this is the stored default, which is the opposite case — it is set once, by
|
|
660
|
+
// somebody who then stops watching, and its failure is silent by construction.
|
|
661
|
+
if (!sparse || profileObj?.defaultJurisdictions !== undefined) {
|
|
662
|
+
const { dropped } = recognizedTerritories(profileObj?.defaultJurisdictions ?? []);
|
|
663
|
+
if (dropped.length) {
|
|
664
|
+
errors.push(`profiles/${key}.json: ${dropped.map((d) => JSON.stringify(d)).join(", ")} `
|
|
665
|
+
+ `${dropped.length === 1 ? "is not a territory" : "are not territories"} the engine can search, `
|
|
666
|
+
+ `so storing ${dropped.length === 1 ? "it" : "them"} would be a default that silently does `
|
|
667
|
+
+ `nothing. Use a country name, or a two-letter code such as US, GB or EU.`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
623
670
|
return { ok: errors.length === 0, errors };
|
|
624
671
|
}
|
|
625
672
|
|
|
@@ -742,7 +789,7 @@ export function loadProfiles({ dir, force = false, includeTestFixtures, includeD
|
|
|
742
789
|
: String(process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ?? "").trim() === "1";
|
|
743
790
|
if (!asked) for (const [k, p] of [...profiles]) if (p?.testFixture === true) profiles.delete(k);
|
|
744
791
|
|
|
745
|
-
// THE DEMO ACCOUNT IS NOT PART OF A FRESH INSTALL EITHER —
|
|
792
|
+
// THE DEMO ACCOUNT IS NOT PART OF A FRESH INSTALL EITHER — ruling, 2026-09-08: a clean install
|
|
746
793
|
// resolves `generic` and nothing else, and the demo brings its own account when somebody runs it.
|
|
747
794
|
// Nobody should have to clean demo material out of an environment they just created.
|
|
748
795
|
//
|
package/driver/publish/index.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { parseReport, parseAudit, parseSections, parseBlocks, stripInternal, par
|
|
|
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 } from './publish-inputs.mjs';
|
|
17
|
+
import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; // — and why an absence did not close
|
|
18
18
|
import { clearanceReportData } from './report-data.mjs';
|
|
19
19
|
import { parseFrameworkManifest } from '../framework.mjs';
|
|
20
20
|
import { rollupTokens } from '../tokens.mjs';
|
|
@@ -132,7 +132,7 @@ export function evaluateClientGate({ coverage = [], lintFailingIds = [], escalat
|
|
|
132
132
|
const closing = requiredAbsent(inputsAbsent);
|
|
133
133
|
if (closing.length)
|
|
134
134
|
push('publish-input-absent', `a required input to this report was not there (${closing.join(', ')}) — the report was built without it, which is not the same as a search that found nothing`);
|
|
135
|
-
return { released: reasons.length === 0, reasons, reasonCodes, inputsAbsent: [...(Array.isArray(inputsAbsent) ? inputsAbsent : [])] };
|
|
135
|
+
return { released: reasons.length === 0, reasons, reasonCodes, inputsAbsent: [...(Array.isArray(inputsAbsent) ? inputsAbsent : [])], notClosing: nonClosingAbsences(inputsAbsent) }; // `notClosing` EXPLAINS a recorded absence that did not hold the release (publish-inputs.mjs nonClosingAbsences) — it decides nothing
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
// T3 (H3) — the machine-QC INPUT assembly. Mirrors publishReport's own reads exactly (the
|
|
@@ -201,7 +201,7 @@ const INDEX_CSS = `
|
|
|
201
201
|
.b{display:inline-block;color:#fff;font-weight:700;font-size:12px;padding:2px 9px;border-radius:999px}
|
|
202
202
|
.b-mh,.b-l3{background:var(--h-amber)}.b-l4{background:var(--crimson)}.b-l2{background:var(--h-green)}.b-l1{background:var(--h-grey)}
|
|
203
203
|
.stmt{display:block;margin-top:3px;font-size:11.5px;color:var(--muted,#6b5d50);max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
204
|
-
.hold{display:inline-block;background:var(--crimson);color:#fff;font-weight:700;font-size:11px;padding:1px 7px;border-radius:999px;margin-left:6px}
|
|
204
|
+
.hold{display:inline-block;background:var(--crimson);color:#fff;font-weight:700;font-size:11px;padding:1px 7px;border-radius:999px;margin-left:6px} .disc{display:inline-block;border:1px solid var(--line);color:var(--muted);font-size:11px;padding:1px 7px;border-radius:999px;margin-left:6px}
|
|
205
205
|
.hpill{display:inline-block;color:#fff;font-weight:700;font-size:11.5px;padding:3px 11px;border-radius:999px;white-space:nowrap}
|
|
206
206
|
.filterbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px}
|
|
207
207
|
.filterbar .flbl{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--crimson-mid);font-weight:700}
|
|
@@ -247,7 +247,7 @@ function indexRows(runs, { reportFile, linkPrefix = '', showAudit = true, client
|
|
|
247
247
|
<td>${matterCell}</td>
|
|
248
248
|
<td>${anonMark(r.title, { key: ck, run: r.runId })}</td>
|
|
249
249
|
<td>${anonClient(r.client, ck)}</td>
|
|
250
|
-
<td><span class="b b-${esc(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${
|
|
250
|
+
<td><span class="b b-${esc(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${!client && !qcFailed && r.clientGate?.inputsAbsent?.length ? ` <span class="disc" title="${escAttr(`published without ${r.clientGate.inputsAbsent.join(', ')} — each declared optional, so the release stands; the report was built without it`)}">◦ built without an input</span>` : ''}${
|
|
251
251
|
// spec 64 — the stance clause of THE one risk statement beside the (labelled) band pill, so the
|
|
252
252
|
// index can never show a bare severity word that reads as the whole answer. The tier word leads
|
|
253
253
|
// the statement; the pill already shows it, so the cell carries the clause after the first " — ".
|
|
@@ -859,7 +859,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
859
859
|
const esPath = driverDir(runDir ?? dirname(reportMd), 'enforcer-signals.json');
|
|
860
860
|
if (existsSync(esPath)) { const es = JSON.parse(readFileSync(esPath, 'utf8')); if (Array.isArray(es)) enforcerSignals = es; }
|
|
861
861
|
} catch { /* absent — no telemetry lines */ }
|
|
862
|
-
bindFindingsToRecords(findings, recordsByUri);
|
|
862
|
+
const officeLinks = officeLinksFor(findings, recordsByUri, fetchReceipts); bindFindingsToRecords(findings, recordsByUri);
|
|
863
863
|
// 404-card caveat (2026-07-22): the V4-2 closure pass persisted every cited record its targeted
|
|
864
864
|
// fetch definitively could not retrieve (predelivery-lint.json artifactSet.recordFetchFailures);
|
|
865
865
|
// the evidence join stamps `_recordFetchFailure` from it so the card render carries the
|
|
@@ -1030,7 +1030,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1030
1030
|
// the same rule (the workbook's own BANNED gate had already started firing on the raw detail —
|
|
1031
1031
|
// advisory, so CI stayed green). reviewReceipts.lint keeps its raw detail for the internal
|
|
1032
1032
|
// readers above (fetchState reads registry-record-coverage's URIs out of it).
|
|
1033
|
-
counts = await buildAudit({ findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
|
|
1033
|
+
counts = await buildAudit({ findings, coverage, contextNotes, coverageJudgment, markAssessment, corrections: correctionsDoc, fetchState, verdict: verdictInfo, jurisdiction, commonLawJoinedTerms, registerOnly, clientGate, lintFailures: deliveryFlagLines(reviewReceipts.lint), productName, registerPublishesRecordPages: runOrigins == null ? null : runOrigins.length > 0, recordLinks: officeLinks?.byUri ?? null }, auditParsed, join(poolRunDir, auditFile), fm.title, fm);
|
|
1034
1034
|
grpRead(join(poolRunDir, auditFile), 0o640);
|
|
1035
1035
|
if (counts?.gateViolations?.length) console.warn(`[audit-workbook] advisory: ${counts.gateViolations.join(' | ')}`);
|
|
1036
1036
|
} catch (e) {
|
|
@@ -1052,7 +1052,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1052
1052
|
// worse demo than no toggle.
|
|
1053
1053
|
const reportNav = siteNav(poolRoot, 'report', null, '../', { anon: false });
|
|
1054
1054
|
// `demoData` is resolved above the report.md write — one answer, every surface.
|
|
1055
|
-
writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, providerLabel, seniorRights, findingsSchemaVersion }));
|
|
1055
|
+
writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion }));
|
|
1056
1056
|
// ONE report (spec 2026-07-30 §5): report.client.html is no longer written. The knockout lane's own
|
|
1057
1057
|
// collapse note is the precedent: "two renderings of one run is how the wrong link gets sent". The
|
|
1058
1058
|
// client host serves the same report.html through the portal's readReport() (cleaning built in) — its
|
|
@@ -1102,9 +1102,11 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1102
1102
|
// carry neither field and consumers null-guard, exactly as they do for markName — a delivered run does
|
|
1103
1103
|
// not gain a project retroactively, it gains one on republish or not at all.
|
|
1104
1104
|
let project = null;
|
|
1105
|
+
let organisation = null;
|
|
1105
1106
|
try {
|
|
1106
1107
|
const frozen = runDir ? JSON.parse(readFileSync(driverDir(runDir, 'profile.json'), 'utf8')) : null;
|
|
1107
1108
|
if (frozen?.projectKey) project = { key: String(frozen.projectKey), name: String(frozen.projectName ?? frozen.projectKey) };
|
|
1109
|
+
if (typeof frozen?.organisation === 'string' && frozen.organisation) organisation = frozen.organisation;
|
|
1108
1110
|
} catch { /* no frozen profile — pre-WS-B run, or a republish with no workspace */ }
|
|
1109
1111
|
let tokenRollup;
|
|
1110
1112
|
try { tokenRollup = runDir ? rollupTokens(runDir) : undefined; } catch { tokenRollup = undefined; }
|
|
@@ -1142,10 +1144,14 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1142
1144
|
projectKey: project?.key ?? undefined,
|
|
1143
1145
|
projectName: project?.name ?? undefined,
|
|
1144
1146
|
customerKey: customerKey || 'generic',
|
|
1147
|
+
// WHICH ORGANISATION'S GENERIC this run was filed under, read from the frozen sidecar like the
|
|
1148
|
+
// project. Absent on a company's run and on one filed before organisations existed, so such a meta
|
|
1149
|
+
// stays byte-identical to a pre-stamp one.
|
|
1150
|
+
organisation: organisation ?? undefined,
|
|
1145
1151
|
issuedAt,
|
|
1146
1152
|
// WHICH BUILD produced this. null off a git checkout — a provenance stamp never fails a publish.
|
|
1147
1153
|
engineCommit: engineCommit(),
|
|
1148
|
-
kind: 'clearance',
|
|
1154
|
+
kind: 'clearance', recordLinks: officeLinks?.tally ?? undefined, // per office: linked, or cited by number and why; only where the register has no record pages
|
|
1149
1155
|
searchLevel: searchPolicy?.level ?? undefined,
|
|
1150
1156
|
// Display-only face of the level ("Depth 4"), frozen alongside it so the list can show which reads
|
|
1151
1157
|
// have run on a name without the browser having to know the level registry.
|
|
@@ -1180,7 +1186,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1180
1186
|
// re-rendered without its meta changing shape. When it IS present it is the durable record that
|
|
1181
1187
|
// this report was assembled without those stores, which the run previously kept nowhere at all.
|
|
1182
1188
|
clientGate: { released: clientGate.released, reasons: clientGate.reasons,
|
|
1183
|
-
inputsAbsent: clientGate.inputsAbsent?.length ? clientGate.inputsAbsent : undefined },
|
|
1189
|
+
inputsAbsent: clientGate.inputsAbsent?.length ? clientGate.inputsAbsent : undefined, notClosing: clientGate.notClosing?.length ? clientGate.notClosing : undefined },
|
|
1184
1190
|
// PR-9 — present ⇒ report-data.json is beside the report and the portal can render natively (the
|
|
1185
1191
|
// same stamp the knockout lane writes; the consumer branch had readers before it had a writer).
|
|
1186
1192
|
// undefined on a producer miss, so the meta never advertises a file that is not there.
|
|
@@ -1189,7 +1195,7 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
|
|
|
1189
1195
|
|
|
1190
1196
|
const total = skipRegen ? null : regenIndex(poolRoot);
|
|
1191
1197
|
if (!skipRegen) await regenSurfaces(poolRoot);
|
|
1192
|
-
// THE PORTAL ROUTE, not the pool's directory layout
|
|
1198
|
+
// THE PORTAL ROUTE, not the pool's directory layout. This was
|
|
1193
1199
|
// `<origin>/<runId>/report.html` — where the documents sit on disk, which is not an application route.
|
|
1194
1200
|
// Composed rather than spelled here so the report link is built the same way the audit link always was.
|
|
1195
1201
|
const url = reportRouteFor(poolUrl, runId);
|
|
@@ -1244,7 +1250,7 @@ export function accessNoteHtml(font, domain = config.accessDomain) {
|
|
|
1244
1250
|
* the edge's own legacy-report regexp character for character, so this function admits exactly the URLs the
|
|
1245
1251
|
* deployment admits and no others.
|
|
1246
1252
|
*/
|
|
1247
|
-
// ── TWO SHAPES, AND THE SECOND ONE IS HISTORY
|
|
1253
|
+
// ── TWO SHAPES, AND THE SECOND ONE IS HISTORY ────────────────────────────────────────────────────────
|
|
1248
1254
|
//
|
|
1249
1255
|
// The block quoted above describes an edge rewrite that IS NOT IN FORCE ON PRODUCTION. Settled by the
|
|
1250
1256
|
// account owner against the live deployment, signed in as a delivered run's own owner: the emailed link
|
|
@@ -1279,7 +1285,7 @@ export function auditRouteFor(origin, runId) {
|
|
|
1279
1285
|
}
|
|
1280
1286
|
|
|
1281
1287
|
/**
|
|
1282
|
-
* THE REPORT'S ROUTE, from an origin and a run id
|
|
1288
|
+
* THE REPORT'S ROUTE, from an origin and a run id.
|
|
1283
1289
|
*
|
|
1284
1290
|
* The delivered link used to be the pool's directory layout pasted behind the public origin —
|
|
1285
1291
|
* `<origin>/<runId>/report.html` — which is where the documents sit on disk and is not an application
|
|
@@ -1387,7 +1393,7 @@ export function composeEmailBody(reportMdPath, url, auditFile, productName = nul
|
|
|
1387
1393
|
// section "already renders every open floor in plain language" — it was never built, and the pointer
|
|
1388
1394
|
// had been promising it since.
|
|
1389
1395
|
//
|
|
1390
|
-
//
|
|
1396
|
+
// RULING 2026-08-19 (relayed): drop the claim; no Coverage section is being designed. So the
|
|
1391
1397
|
// sentence states the fact and stops. It is deliberately not replaced with a different pointer — the
|
|
1392
1398
|
// failure mode here was a pointer written before its target, and one true sentence beats two where
|
|
1393
1399
|
// the second is a promise. When a surface exists that renders open floors in client language, this is
|
|
@@ -1612,3 +1618,15 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
|
|
|
1612
1618
|
|
|
1613
1619
|
return `<div style="${FONT}">${reviewHeadline}${footerNote}</div>`;
|
|
1614
1620
|
}
|
|
1621
|
+
|
|
1622
|
+
// ── The office's own page for each fetched record, where the run's register publishes none of its own ──
|
|
1623
|
+
// Addressed from the record's numbers (office-record-links.mjs), never from the model or the handle, and
|
|
1624
|
+
// null on every other register, so those runs publish exactly as before. The tally goes to meta.json and
|
|
1625
|
+
// the log, so a register whose numbers never fit shows up as a count rather than as silence. Kept down
|
|
1626
|
+
// here, below every line the rest of the tree cites by number.
|
|
1627
|
+
import { recordLinksFor } from './office-record-links.mjs';
|
|
1628
|
+
function officeLinksFor(findings, recordsByUri, fetchReceipts) {
|
|
1629
|
+
const links = recordLinksFor(findings, recordsByUri, fetchReceipts?.[0]?.provider);
|
|
1630
|
+
if (links) console.log(`[record-links] ${links.summary}`);
|
|
1631
|
+
return links;
|
|
1632
|
+
}
|
|
@@ -70,7 +70,7 @@ export const knockoutStatement = (framework, marks) =>
|
|
|
70
70
|
// renders through publish/render-knockout.mjs in the product's own design language, off the same shared
|
|
71
71
|
// stylesheet and brand tokens as the clearance report.
|
|
72
72
|
//
|
|
73
|
-
// THE REVIEWER'S NOTES ARE ON THE REPORT SINCE 2026-09-07 (
|
|
73
|
+
// THE REVIEWER'S NOTES ARE ON THE REPORT SINCE 2026-09-07 (ruling). This
|
|
74
74
|
// paragraph used to end "internal working material (the purple staff notes, the model's registerEstimate)
|
|
75
75
|
// is not IN the report; it lives in the audit workbook". That is now true of `registerEstimate` only: the
|
|
76
76
|
// notes render on the page, labelled, and the workbook keeps its copy. See render-knockout.mjs's header
|
|
@@ -364,14 +364,14 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
364
364
|
let registerRecords = null;
|
|
365
365
|
try { registerRecords = JSON.parse(readFileSync(driverDir(runDir, 'register-records.json'), 'utf8')); }
|
|
366
366
|
catch { registerRecords = null; }
|
|
367
|
-
// The owner lookups this run made
|
|
367
|
+
// The owner lookups this run made, read the same tolerant way as the records above:
|
|
368
368
|
// an archived run that predates the lane has no file, and its cards then render exactly as they were
|
|
369
369
|
// delivered. The source line the report prints comes from HERE, not from anything the seat typed.
|
|
370
370
|
let ownerChecks = [];
|
|
371
371
|
try { ownerChecks = JSON.parse(readFileSync(driverDir(runDir, 'owner-checks.json'), 'utf8')).checks ?? []; }
|
|
372
372
|
catch { ownerChecks = []; }
|
|
373
373
|
// The request the run was given, read the same tolerant way as the sidecars above and for the same
|
|
374
|
-
// reason (
|
|
374
|
+
// reason (A.1). It is what "About this request" states; a run archived before the
|
|
375
375
|
// sidecar existed has none, and its page renders exactly as it was delivered.
|
|
376
376
|
let instructedScope = null;
|
|
377
377
|
try { instructedScope = JSON.parse(readFileSync(driverDir(runDir, 'instructed-scope.json'), 'utf8')); }
|
|
@@ -492,7 +492,7 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
492
492
|
// once as string surgery on the report URL, once as this function, and both spelled a pool path.
|
|
493
493
|
//
|
|
494
494
|
// A SINGLE MARK USED TO KEEP THE LEGACY SHAPE, on the reasoning that `<runId>/report.html` is what the
|
|
495
|
-
// rewrite exists for and that it resolves. THAT IS FALSE ON PRODUCTION
|
|
495
|
+
// rewrite exists for and that it resolves. THAT IS FALSE ON PRODUCTION and the
|
|
496
496
|
// paragraph is kept, corrected, because its warning is still the right instinct and only its premise
|
|
497
497
|
// was wrong.
|
|
498
498
|
//
|
|
@@ -526,7 +526,7 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
526
526
|
// paragraph names every mark in the batch, and a reader who ordered one name must not be handed a
|
|
527
527
|
// summary about other clients' marks. What it left behind was a page opening with nothing.
|
|
528
528
|
//
|
|
529
|
-
// The assess stage writes a model-authored paragraph per mark now (
|
|
529
|
+
// The assess stage writes a model-authored paragraph per mark now (ruling, full length), so
|
|
530
530
|
// the blank has a replacement rather than being merely correct. Substituting it HERE, into the
|
|
531
531
|
// per-mark COPY, is what makes one line serve both surfaces: the rendered `<div class="sub">` and
|
|
532
532
|
// `report-data-<slug>.json`'s `.summary` both read `batch.executiveSummary`, so neither the
|
|
@@ -619,6 +619,10 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
619
619
|
markName: batchMarkName(markNames) ?? undefined,
|
|
620
620
|
engineCommit: engineCommit(),
|
|
621
621
|
client: null, customerKey: customerKey || 'generic',
|
|
622
|
+
// WHICH ORGANISATION'S GENERIC this batch was filed under, read from the frozen sidecar exactly as
|
|
623
|
+
// the clearance publisher reads it. Absent on a company's batch and on one filed before
|
|
624
|
+
// organisations existed.
|
|
625
|
+
organisation: (() => { try { const f = JSON.parse(readFileSync(driverDir(runDir, 'profile.json'), 'utf8')); return typeof f?.organisation === 'string' && f.organisation ? f.organisation : undefined; } catch { return undefined; } })(),
|
|
622
626
|
issuedAt: (() => { try { const prev = JSON.parse(readFileSync(join(poolRunDir, 'meta.json'), 'utf8')); return prev?.issuedAt ?? new Date().toISOString(); } catch { return new Date().toISOString(); } })(),
|
|
623
627
|
kind: 'knockout-batch',
|
|
624
628
|
marks: (findings.marks ?? []).map((m) => ({ name: m.name, band: m.rating })),
|