clearotron 0.3.1-beta.2 → 0.3.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/grant.mjs +21 -22
  2. package/build-info.json +2 -2
  3. package/driver/CHANGELOG.md +33 -0
  4. package/driver/citation-census.json +3 -3
  5. package/driver/common-law-receipts.mjs +11 -1
  6. package/driver/connotation-search.mjs +56 -5
  7. package/driver/contract-e3-backlog.mjs +31 -31
  8. package/driver/contract-vocabulary.mjs +59 -58
  9. package/driver/engine/mcp/gather-config.mjs +1 -1
  10. package/driver/gateway.mjs +29 -2
  11. package/driver/matter-frame-record.mjs +54 -1
  12. package/driver/package.json +1 -1
  13. package/driver/portal-config-view.mjs +26 -1
  14. package/driver/portal-report.mjs +16 -2
  15. package/driver/portal-service.mjs +327 -8
  16. package/driver/publish/render.mjs +0 -45
  17. package/driver/publish/templates/report.css +1 -30
  18. package/driver/skills/knockout-assess/SKILL.md +35 -6
  19. package/driver/skills/knockout-frame/SKILL.md +2 -1
  20. package/driver/skills/prelim-search/risk-framework-triage.md +3 -3
  21. package/driver/stages.mjs +19 -5
  22. package/driver/suite-census.json +112 -16
  23. package/driver/verify-knockout.mjs +58 -0
  24. package/driver/verify.mjs +83 -7
  25. package/mcp-server/CHANGELOG.md +11 -0
  26. package/mcp-server/lib/audit.mjs +96 -3
  27. package/mcp-server/lib/http-handler.mjs +76 -3
  28. package/mcp-server/lib/runs.mjs +42 -2
  29. package/mcp-server/package.json +1 -1
  30. package/mcp-server/packs/client/CONNECT.md +10 -6
  31. package/mcp-server/server.mjs +37 -3
  32. package/package.json +1 -1
  33. package/portal-ui/dist/assets/{index-D8ITW-aD.js → index-ChIQsMYp.js} +1375 -817
  34. package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-DBIs21e4.css} +37 -0
  35. package/portal-ui/dist/index.html +2 -2
  36. package/portal-ui/package.json +1 -1
  37. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  38. package/providers/oauth-mcp-bridge/package.json +1 -1
  39. package/scripts/ask-ai-render-check.mjs +359 -0
  40. package/scripts/citation-line-check.mjs +53 -2
  41. package/scripts/deprecate-below.mjs +146 -0
  42. package/scripts/test-run.mjs +24 -1
  43. package/shared/connect-clients.mjs +52 -16
  44. package/shared/driver-dir.mjs +1 -1
  45. package/shared/grants-edit.mjs +99 -2
@@ -89,7 +89,7 @@ export function opsTokenFor({ bootToken, roster, mint }) {
89
89
  // has held the signing secret on both start paths for as long as both have existed. The comment has been
90
90
  // corrected in place rather than left to be trusted.
91
91
  import { mintToken, loadGrants, resolvePerson, addressesInGrants } from "../shared/scope.mjs";
92
- import { withPerson, withCompany } from "../shared/grants-edit.mjs";
92
+ import { withPerson, withoutPerson, personPoints, withCompany } from "../shared/grants-edit.mjs";
93
93
  import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, decided once
94
94
  import { fileURLToPath } from "node:url";
95
95
 
@@ -1082,6 +1082,59 @@ function outcomeRow({ event = "request-refused", method, path, email = null, sta
1082
1082
  return row;
1083
1083
  }
1084
1084
 
1085
+ /**
1086
+ * Revoking the connector keys a removed person holds — composed here, exported so it can be driven.
1087
+ *
1088
+ * It is the portal's half of an act `clearotron disconnect` also performs, through the same module, so
1089
+ * one author decides what revoking means. What differs is WHERE each runs, and the difference decides
1090
+ * what each may do: `disconnect` runs on the box beside the door, and this runs in a web request in a
1091
+ * different service with a different environment.
1092
+ */
1093
+ export function makeConnectorKeyRevoker({ env = process.env, home = null } = {}) {
1094
+ return async ({ email, grants: g }) => {
1095
+ const { recordedKeysFor, disablePlan, applyDisablePlan, denylistPathFor } = await import("../shared/client-door.mjs");
1096
+ const { homedir } = await import("node:os");
1097
+ const { existsSync, mkdirSync, writeFileSync, appendFileSync } = await import("node:fs");
1098
+ const { join, dirname } = await import("node:path");
1099
+ const base = home ?? homedir();
1100
+ const recorded = recordedKeysFor(g, email);
1101
+ const plan = disablePlan({ env, unitDir: join(base, ".config", "systemd", "user"), exists: existsSync,
1102
+ identity: email, recorded, denylistPath: denylistPathFor(env, base) });
1103
+ if (!plan.possible) return { grants: g, revoked: 0, jtis: [], lateArm: false, says: plan.says };
1104
+ if (plan.lateArm) return { grants: g, revoked: 0, jtis: plan.jtis, lateArm: true, says: plan.says ?? [] };
1105
+ // THE RECORD IS NEVER STRUCK FROM HERE, and the ledger step is dropped rather than no-opped.
1106
+ //
1107
+ // `disablePlan` decides `lateArm` from the environment it is handed, and the environment this
1108
+ // process has is the PORTAL's. The connector is a different unit with its own `EnvironmentFile`, so
1109
+ // a box where the portal names a revocation list and the door was started without one reads as
1110
+ // `lateArm: false` here — and the plan would then write the list AND strike the record, for a key
1111
+ // that still works. A record removed while its key works is the only trace of that key, gone: the
1112
+ // failure `client-door.mjs` states its ordering rule to prevent.
1113
+ //
1114
+ // So this process does the half it can vouch for. Writing the list is safe in both configurations —
1115
+ // it is the right act where the door reads it and a file nobody opens where it does not. Leaving the
1116
+ // record is safe in both too: `connectKeyReport` already judges a record valid, expired or revoked,
1117
+ // so a record of a revoked key is an accurate one, and `clearotron doctor` is where an operator sees
1118
+ // which. `clearotron disconnect` runs on the box, beside the door, and still strikes.
1119
+ //
1120
+ // The seam THROWS rather than doing nothing, so that a future change putting the ledger step back
1121
+ // fails here instead of quietly striking again.
1122
+ const revokeOnly = { ...plan, steps: plan.steps.filter((step) => step.id === "revoke") };
1123
+ applyDisablePlan(revokeOnly, {
1124
+ appendDenylist: (path, jtis) => {
1125
+ mkdirSync(dirname(path), { recursive: true });
1126
+ if (!existsSync(path)) writeFileSync(path, "# Revoked key ids, one jti per line. Read on every key check.\n", { mode: 0o600 });
1127
+ appendFileSync(path, jtis.map((j) => `${j}\n`).join(""));
1128
+ },
1129
+ strikeRecords: () => {
1130
+ throw new Error("the portal must not strike a key record: it cannot see the connector's environment, "
1131
+ + "so it cannot know whether the revocation list it just wrote is the one that door loaded");
1132
+ },
1133
+ });
1134
+ return { grants: g, revoked: plan.jtis.length, jtis: plan.jtis, lateArm: false, recordKept: true };
1135
+ };
1136
+ }
1137
+
1085
1138
  export function makePortalService({
1086
1139
  poolRoot, workspaceRoot, recipesDir = undefined, secret,
1087
1140
  grants = null,
@@ -1094,6 +1147,14 @@ export function makePortalService({
1094
1147
  // CLEAROTRON_ACCESS_FILE. Null means this service cannot write the file, and the routes that would
1095
1148
  // need to say so rather than pretend.
1096
1149
  writeGrants = null,
1150
+ // Revoking the connector keys a removed person holds. INJECTED for the reason `writeGrants` is: this
1151
+ // constructor stays pure over its inputs, an arm can watch the revocation without a denylist file on
1152
+ // disk, and boot is where the paths live. It takes the grants object the removal produced and returns
1153
+ // it with the struck records gone, so one write lands both facts.
1154
+ //
1155
+ // Null means this service cannot revoke, and the answer SAYS so — a removal that quietly left a live
1156
+ // key would be the exact failure the page's own sentence promises against.
1157
+ revokeConnectorKeys = null,
1097
1158
  // The queue directories the RUNNER drains — the same list it hands checkRunCaps. The allowance counter
1098
1159
  // and the quota pre-check read their ledger beside these, so they count what the wall counts (:
1099
1160
  // they used to reconstruct a workspace-relative path that resolved to nothing once the queue moved out
@@ -2316,6 +2377,52 @@ export function makePortalService({
2316
2377
  // cache, and a failure that answers `null` rather than throwing — a page that cannot read its door says
2317
2378
  // so, which is the honest half of this change.
2318
2379
  let doorKindCache = { at: 0, url: null, kind: null };
2380
+ // ── WHETHER A READER HAS AN ASSISTANT ON THIS INSTALLATION ────────────────────────────────────────
2381
+ //
2382
+ // The only evidence either process holds is the connector's own access log: enrolment says a person MAY
2383
+ // connect, never that they did. `readConnections` is the connector's reader, imported rather than
2384
+ // rebuilt here — the path this log lives at is the connector's fact, and a portal deriving it from its
2385
+ // own environment is how a live key's record nearly got struck on a split install.
2386
+ //
2387
+ // TWO INSTALLATION SHAPES, AND THE SPLIT IS THE ONE THE ROUTE ALREADY MAKES. A hosted install has a
2388
+ // published client door and many signed-in people, so the question is answered per person: is this
2389
+ // email in the log. A local install has no client door, one reader, and a connector that cannot know
2390
+ // who it is serving — so the local route's own record answers for the only person who could be asking.
2391
+ // Reading a local record as an answer on a HOSTED install would mark every reader connected the moment
2392
+ // one member of staff ran the server by hand, which is why it is gated on there being no client door.
2393
+ //
2394
+ // Returns null, never false, when there was nothing to read. See the route for why that matters.
2395
+ //
2396
+ // CACHED FOR A MINUTE, the same as the door probe above and for the same reason: this is a page load,
2397
+ // not a check. It runs on every report a reader opens, and reading the tail of a log each time to
2398
+ // answer a question whose answer changes once, ever, would be paid on the client-facing screen.
2399
+ // KEYED ON THE FILES IT READ, not on time alone. The log's location is configuration and does not move
2400
+ // under a running service — but a cache that ignored it would answer about the wrong file for a minute
2401
+ // after it did, and it is what makes this cache testable at the route rather than only at the helper.
2402
+ let connectionsCache = { at: 0, key: null, seen: null };
2403
+ const CONNECTIONS_TTL_MS = 60_000;
2404
+
2405
+ async function readerHasConnectedAi(principal) {
2406
+ const email = String(principal?.email ?? "").trim().toLowerCase();
2407
+ let seen = null;
2408
+ // Imported here rather than at the top, the way every other reach into mcp-server/lib from this file
2409
+ // is: the driver does not depend on the connector, and the import-cycle walk is what keeps it so.
2410
+ let audit = null;
2411
+ try { audit = await import("../mcp-server/lib/audit.mjs"); }
2412
+ catch { return null; } // best-effort: never fail a page load
2413
+ const key = audit.auditPaths().join("\u0000");
2414
+ if (connectionsCache.seen && connectionsCache.key === key && Date.now() - connectionsCache.at < CONNECTIONS_TTL_MS) {
2415
+ seen = connectionsCache.seen;
2416
+ } else {
2417
+ try { seen = audit.readConnections(); } catch { return null; }
2418
+ connectionsCache = { at: Date.now(), key, seen };
2419
+ }
2420
+ if (!seen.available) return null;
2421
+ if (email && seen.emails.has(email)) return true;
2422
+ if (!process.env.CLEAROTRON_CLIENT_MCP_URL && seen.local) return true;
2423
+ return false;
2424
+ }
2425
+
2319
2426
  const DOOR_KIND_TTL_MS = 60_000;
2320
2427
  async function connectorDoorKind(url) {
2321
2428
  if (!url) return null;
@@ -2402,6 +2509,18 @@ async function connectorDoorKind(url) {
2402
2509
  email: principal.email ?? null, // the identity to sign in with — what they already use
2403
2510
  enabled: !!url,
2404
2511
  stdio, // the local route, or null for a client
2512
+ // ── HAS THIS READER ALREADY CONNECTED AN ASSISTANT? ──────────────────────────────────────
2513
+ //
2514
+ // Folded into this route rather than given its own, because the Ask-AI control on a report
2515
+ // already loads it and a second request per report open buys nothing.
2516
+ //
2517
+ // `null` IS A THIRD STATE AND THE SCREEN HAS TO DRAW IT. True, false and "no log to read" are
2518
+ // different facts: an installation whose access log has not been written yet, or cannot be
2519
+ // read, has not told us this reader never connected — it has told us nothing. The control
2520
+ // treats null the way it treats false, because offering the menu to somebody with no
2521
+ // assistant reproduces the defect this is fixing, and because the panel carries its own way
2522
+ // past ("Already connected? Ask anyway"). What it must not do is claim the measurement.
2523
+ aiConnected: await readerHasConnectedAi(principal),
2405
2524
  // Every client, already resolved: served or not, with what it needs or why it cannot be.
2406
2525
  //
2407
2526
  // ── `steps` COMES BACK, on the owner's 2026-09-03 ruling ─────────
@@ -3060,7 +3179,16 @@ async function connectorDoorKind(url) {
3060
3179
  const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
3061
3180
  if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
3062
3181
  } catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
3063
- return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn }) };
3182
+ // WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL, read the same way `disablePlan` reads it: the
3183
+ // door loaded a revocation list at start, or it did not and never will for the keys already
3184
+ // out. The variable's PRESENCE is the whole question — its value is a path, and this route
3185
+ // must not say where.
3186
+ // WHETHER A REVOCATION LIST IS NAMED FOR THIS PROCESS AT ALL — which is not the same question
3187
+ // as whether the connector loaded one, and the page's words are careful about the difference.
3188
+ // The connector is a separate unit with its own environment; this answers only for here.
3189
+ const keysRevocable = Boolean(revokeConnectorKeys)
3190
+ && String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim() !== "";
3191
+ return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn, keysRevocable }) };
3064
3192
  }
3065
3193
  // /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
3066
3194
  //
@@ -3070,23 +3198,40 @@ async function connectorDoorKind(url) {
3070
3198
  // as they were, and the answer says which happened. Nobody sets their own switches, and nobody
3071
3199
  // gives Run without holding it.
3072
3200
  if (parts[2] === "people" && parts.length === 3 && method === "POST") {
3073
- if (localSignIn) return { status: 409, json: { error: "local_sign_in" } };
3201
+ // CODE AS ITS OWN FIELD. `error` carries a sentence on most routes here and a token on a few, so
3202
+ // a client cannot tell the two apart by looking at it — and the one that reached the page as page
3203
+ // copy was a token. A code the client reads as a FIELD is decidable; prose never is.
3204
+ if (localSignIn) return { status: 409, json: { error: "local_sign_in", code: "local_sign_in" } };
3074
3205
  if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
3075
3206
  const email = String(body?.email ?? "").trim().toLowerCase();
3076
3207
  if (!email || email.indexOf("@") <= 0 || email.indexOf("@") !== email.lastIndexOf("@"))
3077
3208
  return { status: 400, json: { error: "Enter one email address." } };
3209
+ // ACCESS TO EVERYTHING IS NOT A POINT, and the handler used to treat it as one. The form draws
3210
+ // "Everything on this Clearotron" only to somebody who holds it, and sent it as
3211
+ // `{kind:"everything"}` alongside the organisation and company points; nothing here matched
3212
+ // that kind, so it fell to the 404 below and the page rendered the refusal it keeps for a
3213
+ // point outside the adder's reach — "You can only give access to what you have access to
3214
+ // yourself" — to the one person on the install for whom that is false. The control had never
3215
+ // worked. It lives under `people` as a switch, not in any organisation, so it is read as one.
3078
3216
  const points = [];
3217
+ let wantsEverything = false;
3079
3218
  for (const a of Array.isArray(body?.access) ? body.access : []) {
3080
3219
  const key = typeof a?.key === "string" ? a.key : "";
3081
- if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) points.push({ tenant: key });
3220
+ if (a?.kind === "everything" && seesEverything(principal)) wantsEverything = true;
3221
+ else if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) points.push({ tenant: key });
3082
3222
  else if (a?.kind === "company" && principal.accountOrgs?.[key]) points.push({ tenant: principal.accountOrgs[key], account: key });
3083
3223
  else return { status: 404, json: { error: "not_found" } };
3084
3224
  }
3085
- if (!points.length) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
3086
- const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true };
3225
+ if (!points.length && !wantsEverything) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
3226
+ const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true, everything: wantsEverything };
3087
3227
  if (want.run && !mayRun(principal)) return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
3088
3228
  const existing = resolvePerson(email, grantsHere);
3089
3229
  const setSwitches = email !== principal.email && (!existing || reachCovers(principal, existing));
3230
+ // THE SWITCHES ARE WHERE ACCESS TO EVERYTHING IS WRITTEN, so a grant of it that cannot write
3231
+ // them writes nothing at all — and `withPerson` would return unchanged grants and this route a
3232
+ // 201 naming a person who gained nothing. Said instead of returned.
3233
+ if (wantsEverything && !setSwitches)
3234
+ return { status: 400, json: { error: "Access to everything is part of what a person may do, and that cannot be set from here for this address. Nothing was saved." } };
3090
3235
  let next;
3091
3236
  try { next = withPerson(grantsHere, { email, points, switches: want, setSwitches }); }
3092
3237
  catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
@@ -3101,6 +3246,138 @@ async function connectorDoorKind(url) {
3101
3246
  const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email === email) ?? null;
3102
3247
  return { status: 201, json: { person, switchesApplied: setSwitches } };
3103
3248
  }
3249
+
3250
+ // /portal/admin/people/change — change what somebody may do and see.
3251
+ //
3252
+ // THE DIFF IS COMPUTED HERE, FROM THE FILE, and never taken from the request. The page sends the
3253
+ // state it wants; this reads what the file holds right now, narrows that to the part of the
3254
+ // person the caller can see, and works out what to add and what to take away between the two. A
3255
+ // page that had been open while somebody else was edited would otherwise write its own stale
3256
+ // copy back over them — and the half it would overwrite is the half outside its own view, which
3257
+ // nobody looking at either screen could see happen.
3258
+ //
3259
+ // NOBODY CHANGES THEMSELVES. The Add form already refuses to set the adder's own switches; this
3260
+ // refuses the whole act, because a manager who can take their own Manage away can lock the
3261
+ // install's last manager out of it with one press, and the way back is a text editor on the box.
3262
+ if (parts[2] === "people" && parts[3] === "change" && parts.length === 4 && method === "POST") {
3263
+ // CODE AS ITS OWN FIELD. `error` carries a sentence on most routes here and a token on a few, so
3264
+ // a client cannot tell the two apart by looking at it — and the one that reached the page as page
3265
+ // copy was a token. A code the client reads as a FIELD is decidable; prose never is.
3266
+ if (localSignIn) return { status: 409, json: { error: "local_sign_in", code: "local_sign_in" } };
3267
+ if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
3268
+ const email = String(body?.email ?? "").trim().toLowerCase();
3269
+ if (email === principal.email) return { status: 400, json: { error: "You cannot change your own access. Somebody else who manages this install can." } };
3270
+ const existing = resolvePerson(email, grantsHere);
3271
+ const held = personPoints(grantsHere, email);
3272
+ if (!existing && !held.listed && !held.points.length) return { status: 404, json: { error: "not_found" } };
3273
+
3274
+ // What the caller can see of this person, and what they asked for — both in the same shape, so
3275
+ // the difference between them is the change.
3276
+ const inReach = (pt) => pt.account == null
3277
+ ? (principal.genericOrgs ?? []).includes(pt.tenant)
3278
+ : principal.accountOrgs?.[pt.account] === pt.tenant;
3279
+ const mine = held.points.filter(inReach);
3280
+ const want = [];
3281
+ for (const a of Array.isArray(body?.access) ? body.access : []) {
3282
+ const key = typeof a?.key === "string" ? a.key : "";
3283
+ if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) want.push({ tenant: key, account: null });
3284
+ else if (a?.kind === "company" && principal.accountOrgs?.[key]) want.push({ tenant: principal.accountOrgs[key], account: key });
3285
+ else return { status: 404, json: { error: "not_found" } };
3286
+ }
3287
+ const id = (pt) => `${pt.tenant}/${pt.account ?? "*"}`;
3288
+ const wanted = new Set(want.map(id));
3289
+ const kept = new Set(mine.map(id));
3290
+ const drop = mine.filter((pt) => !wanted.has(id(pt)));
3291
+ const add = want.filter((pt) => !kept.has(id(pt)));
3292
+ // TAKING THE WHOLE OF SOMEBODY'S VISIBLE ACCESS AWAY IS REMOVAL, and it has its own route, its
3293
+ // own confirmation and its own revocation. Reaching it by unticking every row would do half of
3294
+ // that act under the word "save".
3295
+ if (!want.length) return { status: 400, json: { error: "Leave them at least one organisation or company, or remove them instead." } };
3296
+
3297
+ const switches = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true,
3298
+ everything: held.switches.everything };
3299
+ if (switches.run && !held.switches.run && !mayRun(principal))
3300
+ return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
3301
+ // Switches belong to the person and not to a point, so they are set only when the whole of this
3302
+ // person sits inside the caller's reach. Otherwise the points move and the switches stay, and
3303
+ // the answer says which happened — the same contract adding already has.
3304
+ const setSwitches = reachCovers(principal, existing);
3305
+ let next;
3306
+ try {
3307
+ next = drop.length ? withoutPerson(grantsHere, { email, points: drop }) : grantsHere;
3308
+ next = withPerson(next, { email, points: add, switches, setSwitches });
3309
+ } catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
3310
+ try { await writeGrants(next); }
3311
+ catch (e) {
3312
+ audit({ event: "person-change", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
3313
+ return { status: 500, json: { error: "The guest list could not be written, so nothing was changed." } };
3314
+ }
3315
+ audit({ event: "person-change", by: principal.email, person: email, added: add.length, removed: drop.length,
3316
+ switchesApplied: setSwitches, ok: true, status: 200 });
3317
+ const { loadProfiles } = await import("./profiles.mjs");
3318
+ const companies = Object.fromEntries([...loadProfiles({ force: true }).values()].map((p) => [p.key, p.name ?? p.key]));
3319
+ const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email.toLowerCase() === email) ?? null;
3320
+ return { status: 200, json: { person, switchesApplied: setSwitches, added: add.length, removed: drop.length } };
3321
+ }
3322
+
3323
+ // /portal/admin/people/remove — take their access away.
3324
+ //
3325
+ // HOW FAR IT REACHES IS THE SERVER'S ANSWER, NOT THE REQUEST'S. A caller who can see the whole of
3326
+ // this person removes them from the install; a caller who can see one organisation's worth of
3327
+ // them takes away that organisation and nothing else. The page draws a different button for each
3328
+ // — it reads the same `covered` the view computes — but the request carries no scope to get
3329
+ // wrong, so a stale page cannot ask for more than the person pressing it can see.
3330
+ if (parts[2] === "people" && parts[3] === "remove" && parts.length === 4 && method === "POST") {
3331
+ // CODE AS ITS OWN FIELD. `error` carries a sentence on most routes here and a token on a few, so
3332
+ // a client cannot tell the two apart by looking at it — and the one that reached the page as page
3333
+ // copy was a token. A code the client reads as a FIELD is decidable; prose never is.
3334
+ if (localSignIn) return { status: 409, json: { error: "local_sign_in", code: "local_sign_in" } };
3335
+ if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
3336
+ const email = String(body?.email ?? "").trim().toLowerCase();
3337
+ if (email === principal.email) return { status: 400, json: { error: "You cannot remove your own access. Somebody else who manages this install can." } };
3338
+ const existing = resolvePerson(email, grantsHere);
3339
+ const held = personPoints(grantsHere, email);
3340
+ if (!existing && !held.listed && !held.points.length) return { status: 404, json: { error: "not_found" } };
3341
+ const whole = reachCovers(principal, existing);
3342
+ const inReach = (pt) => pt.account == null
3343
+ ? (principal.genericOrgs ?? []).includes(pt.tenant)
3344
+ : principal.accountOrgs?.[pt.account] === pt.tenant;
3345
+ const mine = held.points.filter(inReach);
3346
+ if (!whole && !mine.length) return { status: 404, json: { error: "not_found" } };
3347
+
3348
+ let next;
3349
+ try { next = withoutPerson(grantsHere, whole ? { email, all: true } : { email, points: mine }); }
3350
+ catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
3351
+
3352
+ // THE KEYS, AND ONLY ON A WHOLE REMOVAL. A narrowing leaves the person on the install with
3353
+ // access somewhere else, and their connector key is how they reach that.
3354
+ let keys = { checked: false };
3355
+ if (whole) {
3356
+ if (!revokeConnectorKeys) keys = { checked: false, note: "this installation cannot revoke issued keys from here" };
3357
+ else {
3358
+ try {
3359
+ const { grants: written, ...said } = await revokeConnectorKeys({ email, grants: next });
3360
+ next = written ?? next;
3361
+ keys = { checked: true, ...said };
3362
+ }
3363
+ // A REVOCATION THAT FAILED MUST NOT LOOK LIKE ONE THAT HAPPENED, and it must not take the
3364
+ // removal with it either: the access record is the gate every surface reads, and leaving
3365
+ // it in place because a key file could not be appended would be the larger failure.
3366
+ catch (e) { keys = { checked: true, failed: true, note: String(e?.message ?? e).slice(0, 200) }; }
3367
+ }
3368
+ }
3369
+
3370
+ try { await writeGrants(next); }
3371
+ catch (e) {
3372
+ audit({ event: "person-remove", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
3373
+ return { status: 500, json: { error: "The guest list could not be written, so nothing was changed." } };
3374
+ }
3375
+ audit({ event: "person-remove", by: principal.email, person: email, scope: whole ? "install" : "organisations",
3376
+ organisations: whole ? null : [...new Set(mine.map((pt) => pt.tenant))].length,
3377
+ keysRevoked: keys.revoked ?? 0, keysUnrevokable: keys.lateArm ? (keys.jtis?.length ?? 0) : 0, ok: true, status: 200 });
3378
+ return { status: 200, json: { removed: whole ? "install" : "organisations",
3379
+ organisations: whole ? [] : [...new Set(mine.map((pt) => pt.tenant))], keys } };
3380
+ }
3104
3381
  // /portal/admin/observed — who has actually USED this instance lately, from the audit log.
3105
3382
  //
3106
3383
  // ALWAYS 200, even when the log is missing or unreadable, with an `available` boolean — the
@@ -3761,9 +4038,39 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
3761
4038
  // `extra` exists so a response can carry a CSP. Before this the content-type was hardcoded and there
3762
4039
  // was no way to attach one — which is why the portal shipped without a policy rather than with a
3763
4040
  // permissive one.
4041
+ // ── EVERY JSON RESPONSE SAYS HOW IT MAY BE CACHED, because leaving it unsaid is the defect ────────
4042
+ //
4043
+ // This writer used to send `content-type` and `content-length` and nothing else — no `Cache-Control`,
4044
+ // no `Vary`, no validator — so what a browser did with a signed-in response carrying another
4045
+ // company's material was the browser's decision and not ours. With no `Last-Modified` to work from a
4046
+ // heuristic cache may well store nothing; the defect is not a demonstrated leak, it is that the answer
4047
+ // was never stated for a response behind a session.
4048
+ //
4049
+ // THE ARGUMENT WAS ALREADY MADE HERE, FOR ONE ROUTE. `/portal/api/connect-key` has always set
4050
+ // `no-store` because "a credential sitting in a proxy or a disk cache is the 'outlives the moment'
4051
+ // failure ... arriving by a route the page cannot see". An access list is not a credential and has the
4052
+ // same property, so the rule belongs to the class rather than to the one response that carried a
4053
+ // token.
4054
+ //
4055
+ // `Vary: Accept` IS NOT ABOUT PRIVACY. `/portal/admin/*` is one address served two ways: the app
4056
+ // fetches JSON there, and a browser navigation gets the app document instead (portal-static.mjs
4057
+ // decides on `Accept`, above this router). Nothing told a cache those were different responses, so a
4058
+ // stored JSON body could answer a later navigation and render raw data in a window — with this server
4059
+ // never asked, which is why the negotiation above could not save it.
4060
+ //
4061
+ // A ROUTE'S OWN HEADERS STILL WIN. `extra` is spread last, so connect-key keeps its stricter set
4062
+ // (`no-cache, must-revalidate, private` and the HTTP/1.0 `pragma`) rather than being flattened to this
4063
+ // default. The arm below pins that, because a default that quietly relaxed a stricter route would be
4064
+ // this change making things worse while reading as an improvement.
3764
4065
  const send = (res, status, obj, extra = {}) => {
3765
4066
  const b = JSON.stringify(obj);
3766
- res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(b), ...extra });
4067
+ res.writeHead(status, {
4068
+ "content-type": "application/json",
4069
+ "content-length": Buffer.byteLength(b),
4070
+ "cache-control": "no-store",
4071
+ "vary": "accept",
4072
+ ...extra,
4073
+ });
3767
4074
  res.end(b);
3768
4075
  };
3769
4076
  return async function handler(req, res) {
@@ -4302,6 +4609,18 @@ const PORT = PORT_CHOICE.port;
4302
4609
  atomicWrite(envFrom(process.env, "CLEAROTRON_ACCESS_FILE"), `${JSON.stringify(g, null, 2)}\n`);
4303
4610
  };
4304
4611
 
4612
+ // Revoking a removed person's connector keys, from the same module `clearotron disconnect` revokes
4613
+ // through — one author for what revoking means, and one place the ordering rule lives: the id reaches
4614
+ // the denylist before its record is struck, because a record removed first leaves a live key with no
4615
+ // trace it ever existed.
4616
+ //
4617
+ // IT NEVER NAMES THE LIST IN `.env`. `applyDisablePlan` will do that when a plan is `lateArm` — the
4618
+ // door was started without a revocation list, so the running process never loaded one — and that step
4619
+ // belongs to an operator at a terminal, not to a web request rewriting the install's environment. So
4620
+ // a lateArm plan is NOT applied: nothing is written, and the answer says the keys stay live until they
4621
+ // expire. Striking the records instead would delete the only record of a working key.
4622
+ const revokeConnectorKeys = makeConnectorKeyRevoker({ env: process.env });
4623
+
4305
4624
  const { config } = await import("./driver.config.mjs");
4306
4625
  const { appendFileSync: append } = await import("node:fs");
4307
4626
  const auditPath = process.env.PORTAL_AUDIT || join(HERE, "..", "portal-audit.log");
@@ -4805,7 +5124,7 @@ const PORT = PORT_CHOICE.port;
4805
5124
  const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
4806
5125
  // Re-read per request (a getter that rescans), so a workspace created after boot is counted.
4807
5126
  queueDirs: () => config.queueDirs,
4808
- secret, grants, localSignIn: LOCAL_MODE, writeGrants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
5127
+ secret, grants, localSignIn: LOCAL_MODE, writeGrants, revokeConnectorKeys, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4809
5128
  // — the ONLY place the environment is read for this. `bin/start.mjs` is the
4810
5129
  // only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
4811
5130
  // environment through, so a stray `.env` can neither put a live install into demo mode nor take a
@@ -32,7 +32,6 @@ import { registrationSystem } from '../jurisdiction-systems.mjs';
32
32
  import { READ_LEAD_RE } from '../report-card-record.mjs'; // D3 — the dedupe gate and the card's acceptance are ONE predicate
33
33
  import { REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, FAVICON_LINK, logoLockup, BRAND, confPosture } from '../../shared/brand.mjs';
34
34
  import { NAV_CSS } from '../../shared/site-nav.mjs';
35
- import { mintToken } from '../../shared/scope.mjs'; // mint a scoped read-only USER token for "Ask your AI" (dep-free; no-op fallback when no secret)
36
35
  import { isEntrypoint } from "../../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
37
36
 
38
37
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -1745,32 +1744,6 @@ function method(_text) {
1745
1744
  // lawyer signs. The slim internal toolbar that survived it existed ONLY to host the Flag/Etch
1746
1745
  // controls and rendered nothing without them, so took it with them.
1747
1746
 
1748
- // "Ask your AI" launcher (§2.7) — its own collapsible banner under the verdict (a native <details>, default
1749
- // collapsed, no-print). COPY-FIRST, bring-your-own-AI. Same gating as before: rendered only when a mcpUrl is
1750
- // present (internal = shared read-only MCP; client export = OMITTED unless a scoped mcpUrl is given).
1751
- function askAi(fm, { mcpUrl, runId }) {
1752
- const mark = String(fm.title || fm.matter || 'this mark').trim();
1753
- const prompt = runId ? `Brief me on trademark clearance run ${runId}.` : `Brief me on the ${mark} trademark clearance.`;
1754
- const pa = escAttr(prompt);
1755
- const steps = (label, items) => `<details class="askai-steps"><summary>${label}</summary><ol>${items.map(i => `<li>${i}</li>`).join('')}</ol></details>`;
1756
- return `<details class="askband no-print">
1757
- <summary>
1758
- <div class="askband-ic" aria-hidden="true">💬</div>
1759
- <div class="askband-main">
1760
- <div class="askband-title">Ask your AI about this run <span class="askband-exp" aria-hidden="true">▾</span></div>
1761
- <div class="askband-sub">Connect once and interrogate the findings in the Claude or ChatGPT you already use — read-only.</div>
1762
- </div>
1763
- </summary>
1764
- <div class="askband-body">
1765
- <button type="button" class="util primary askai-copy" data-copy="${pa}">📋 Copy question</button>
1766
- <p class="askai-hint">Paste into the Claude or ChatGPT you already use — read-only. New chat: <a href="https://claude.ai/new" target="_blank" rel="noopener">Claude →</a> · <a href="https://chatgpt.com/" target="_blank" rel="noopener">ChatGPT →</a></p>
1767
- <div class="askai-field"><code class="askai-url">${esc(mcpUrl)}</code><button type="button" class="util askai-copy" data-copy="${escAttr(mcpUrl)}">Copy</button></div>
1768
- ${steps('Set up Claude <span class="askai-checked">· ✓ Checked 4 September 2026</span>', ['Settings → Connectors → Add custom connector', 'Paste the address above — it already carries your key', 'Set Authentication to None', 'Add. If it warns that authentication is required, that is its own guess — None is correct here'])}
1769
- ${steps('Set up ChatGPT', ['Settings → Connectors → Advanced → Developer mode', 'Add MCP server, paste the address above', `Sign in when the browser opens (${BRAND.name} email)`])}
1770
- <p class="askai-hint askai-note">These steps name no button we have not opened ourselves. Your app may word them differently.</p>
1771
- </div>
1772
- </details>`;
1773
- }
1774
1747
 
1775
1748
  // Brand :root tokens are sourced from shared/brand.mjs (REPORT_ROOT) and prepended to the
1776
1749
  // report stylesheet, so report.css carries only rules — the palette lives in ONE place across surfaces.
@@ -1808,7 +1781,6 @@ window.addEventListener('hashchange',_cardHashGo);
1808
1781
  window.addEventListener('load',_cardHashGo);
1809
1782
  window.addEventListener('beforeprint',function(){document.querySelectorAll('details').forEach(function(d){d.dataset.o=d.open?'1':'';d.open=true;});});
1810
1783
  window.addEventListener('afterprint',function(){document.querySelectorAll('details').forEach(function(d){d.open=d.dataset.o==='1';});_hidden.forEach(function(c){c.classList.remove('print-hidden');});_hidden=[];});
1811
- document.addEventListener('click',function(e){var cp=e.target.closest('.askai-copy');if(cp){navigator.clipboard&&navigator.clipboard.writeText(cp.getAttribute('data-copy'));}});
1812
1784
  document.addEventListener('click',function(e){var t=e.target.closest('.tb-exp-toggle'),pop=document.querySelector('.tb-exp-pop');if(t){if(pop){pop.hidden=!pop.hidden;t.setAttribute('aria-expanded',String(!pop.hidden));}return;}if(pop&&!pop.hidden&&!e.target.closest('.tb-exp-pop')){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}});
1813
1785
  document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=document.querySelector('.tb-exp-pop');if(pop&&!pop.hidden){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}}});`;
1814
1786
 
@@ -2154,21 +2126,6 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2154
2126
 
2155
2127
  // Excel/audit download — lives inside the topbar Export popover (portal-report strips the link at serve time for non-staff).
2156
2128
  const excelBtn2 = !opts.auditFile ? '' : `<a class="util" href="${esc(opts.auditFile)}" download>⬇ Download full audit (Excel)</a>`;
2157
- // "Ask your AI" target. Explicit opts.mcpUrl wins. Otherwise the staff surface (CLEAROTRON_MCP_URL), with
2158
- // a run-scoped read-only token embedded when a secret is configured. ONE report: the document always
2159
- // carries the staff connector; portal-report.mjs strips the whole block (and redacts any surviving MCP
2160
- // host) for non-staff readers at serve time — the client's connector path is the portal's own
2161
- // /portal/api/mcp-access, which needs no baked credential. FAIL CLOSED on a missing env: NO placeholder
2162
- // host — a forgotten env used to render a mcp.example.com connector that looked configured and resolved
2163
- // nowhere.
2164
- let mcpUrl = opts.mcpUrl || '';
2165
- if (!mcpUrl) {
2166
- const base = process.env.CLEAROTRON_MCP_URL || '';
2167
- let tok = '';
2168
- if (base && opts.runId) { try { tok = mintToken({ scope: 'user', runId: opts.runId }); } catch { tok = ''; } }
2169
- mcpUrl = base ? (tok ? `${base}?token=${tok}` : base) : '';
2170
- }
2171
- const askAiHtml = mcpUrl ? askAi(fm, { mcpUrl, runId: opts.runId }) : '';
2172
2129
 
2173
2130
  const cardFor = f => matchCard(f, cards);
2174
2131
  const recordsByUri = opts.recordsByUri || new Map(); // Instance #6 — the run's _records/ set (publishReport loads it)
@@ -2372,8 +2329,6 @@ ${opts.nav || ''}
2372
2329
  findings.some((f) => (f?.owner?.registrations ?? []).some((r) => r?.uri
2373
2330
  && (recordsByUri.size > 0 || !(r.status || r.filed || r.expiry || (r.classes && r.classes.length))))))}
2374
2331
 
2375
- ${askAiHtml}
2376
-
2377
2332
  <footer>
2378
2333
  <span>${productName ? `${esc(productName)}. ` : ''}${FRAMEWORK
2379
2334
  // TWO SENTENCES, and that count is the ruled shape rather than a consequence of trimming.
@@ -214,22 +214,6 @@
214
214
  details.method[open]>summary::after{content:"▴"}
215
215
  .method .mbody{padding:0 22px 20px;font-size:13px;color:var(--slate);line-height:1.62}
216
216
 
217
- /* Ask-your-AI launcher */
218
- .askai{position:relative;display:inline-block}
219
- .askai-pop{position:absolute;z-index:30;top:calc(100% + 8px);left:0;width:340px;max-width:88vw;background:var(--card);
220
- border:1px solid var(--line2);border-radius:14px;box-shadow:var(--shadow);padding:14px}
221
- .askai-hint{font-size:11.5px;color:var(--slate);margin:9px 0 8px;line-height:1.45}
222
- .askai-field{display:flex;gap:6px;align-items:center;background:var(--bg2);border-radius:9px;padding:6px 8px;margin-bottom:8px}
223
- .askai-url{font-family:var(--mono);font-size:11px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
224
- .askai-steps>summary{cursor:pointer;font-size:11.5px;font-weight:700;color:var(--crimson);padding:4px 0;list-style:none}
225
- .askai-steps>summary::-webkit-details-marker{display:none}
226
- .askai-steps ol{margin:4px 0 8px;padding-left:18px;font-size:11.5px;color:var(--slate)}
227
- /* The dated stamp on a set-up recipe we have actually driven. Muted on purpose: the recipe's own
228
- summary is crimson and bold, and a stamp that shouted as loudly would read as part of the step. */
229
- .askai-steps>summary .askai-checked{font-weight:600;color:var(--slate)}
230
- /* The note under BOTH recipes. `.askband-body .askai-hint` zeroes the top margin for the hint that
231
- opens the body; this one closes it and needs the gap back. */
232
- details.askband .askband-body .askai-note{margin-top:10px}
233
217
 
234
218
  footer{margin-top:54px;padding-top:20px;border-top:2px solid var(--ink);font-size:12px;color:var(--faint);line-height:1.6;display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
235
219
  footer .wm{font-size:13px}
@@ -282,19 +266,6 @@
282
266
  .rv-tag{font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#a06aa3}
283
267
  .rv-spacer{flex:1;min-width:16px}
284
268
 
285
- /* Ask-AI banner (collapsible) */
286
- details.askband{display:block;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--crimson);border-radius:14px;padding:16px 18px;margin:20px 0 0;box-shadow:var(--shadow)}
287
- details.askband>summary{cursor:pointer;list-style:none;display:flex;gap:14px;align-items:flex-start}
288
- details.askband>summary::-webkit-details-marker{display:none}
289
- .askband-ic{flex:none;width:38px;height:38px;border-radius:11px;background:var(--high-soft);color:var(--high-tx);display:flex;align-items:center;justify-content:center;font-size:18px}
290
- .askband-main{flex:1;min-width:0}
291
- .askband-title{font-weight:900;font-size:16px;letter-spacing:-.01em;margin:0 0 3px}
292
- .askband-sub{font-size:13px;color:var(--slate);line-height:1.5;margin:0}
293
- .askband-exp{display:inline-block;transition:.15s;color:var(--faint);font-size:12px;margin-left:4px}
294
- details.askband[open] .askband-exp{transform:rotate(180deg)}
295
- details.askband .askband-body{margin-top:12px;padding-left:52px;max-width:560px}
296
- details.askband .askband-body .askai-hint{margin-top:0}
297
- @media(max-width:560px){details.askband .askband-body{padding-left:0}}
298
269
 
299
270
  /* cross-region rights-holder rows (.rrow) + secondary region groups (.rgroup) */
300
271
  .keypanel{overflow-y:auto}
@@ -334,7 +305,7 @@
334
305
  @media (min-width:760px){.rn-why{flex:1 1 46%;min-width:280px}}
335
306
 
336
307
  @media print{
337
- .topbar,.no-print,.review,.askband,.int-note{display:none!important}
308
+ .topbar,.no-print,.review,.int-note{display:none!important}
338
309
  body{background:#fff}
339
310
  .panel,.card,.land,.firstUp,.method{box-shadow:none}
340
311
  /* Only PURE TOGGLES hide their summary line in print — the drill toggles ("The read" / "Full detail
@@ -16,7 +16,8 @@ there is no other value: a band that needs sharpening is the band above it, stat
16
16
  ## Per mark — the mandatory sequence
17
17
 
18
18
  1. **Context framing FIRST** (from the plan; correct it only if plainly wrong): coined vs common
19
- phrase vs cultural echo — the rating hangs off this.
19
+ phrase vs cultural echo. It frames what the name READS as, and the evidence bullets draw on it.
20
+ The band does not: no framing raises or lowers a rating (calibration rule 3, retired).
20
21
  2. **Parody / evocation check**: does the name echo a famous mark or property even without identical
21
22
  ownership ("Free Range 1s" echoes "Air Force 1s")? Flag it even when nobody owns the echoed form.
22
23
  3. **Band per the framework ladder**, applying the calibration rules below.
@@ -58,11 +59,21 @@ there is no other value: a band that needs sharpening is the band above it, stat
58
59
  read." It was the only instruction in this seat's context licensing that move, and the readiest
59
60
  explanation for a Disney hit rating Very High and an EA hit rating High against a lawyer's Medium and
60
61
  Manageable. The rest of the rule stands and is doctrine.)*
61
- 3. **Never rate the lowest band for common English phrases — DO rate it for coined/fanciful terms.**
62
- Common word or phrase → second-lowest band minimum even on a clean sweep, plus the purple
63
- register-pending note. Coined/invented word with a clean sweep → the lowest band is correct.
64
- The test: could a reasonable person use this word in everyday speech without reference to the
65
- proposed mark? No → coined. Yes → common phrase → floor applies.
62
+ 3. **RETIRED — how ordinary the words are is a note for the reviewing lawyer, never a band.**
63
+ This forbade the lowest band for a common English phrase and put a floor a band above it. On a
64
+ scale that ends in Low it kept an everyday name off "no issues found"; on a client scale with no
65
+ Low band the floor landed one band higher still, and three names went out rated above every
66
+ conflict card on their own pages — twelve cards, all at the bottom band, under three names a band
67
+ above them, with nothing on the page to explain it. The reviewing lawyer rated the cards right and
68
+ the names wrong.
69
+ **Removed for every client, not only where it misfired** (owner, 2026-09-15). Keeping it on scales
70
+ that have a Low band was considered and rejected; so was explaining the gap on the report, because
71
+ a report that explains a wrong rating is still wrong.
72
+ Say what the words are in the reading — coined, compound, everyday phrase, cultural echo — and let
73
+ it inform the evidence bullets. It does not set, floor or lift a band. A risk that belongs to the
74
+ NAME rather than to one user is written as its own `findings[]` record and rated there; Famous
75
+ Brand and Cultural Reference are already card types for exactly that.
76
+ *The number is kept and not reused*, for the reason rule 4 gives below.
66
77
  4. **RETIRED — the caveat is conditional now, and it is not this file's to state.**
67
78
  This rule ordered the pending-register caveat on *every* summary, unconditionally. When the register
68
79
  ran and surfaced live filings, that sentence tells a client its ratings are common-law only while the
@@ -77,6 +88,24 @@ there is no other value: a band that needs sharpening is the band above it, stat
77
88
  at its "(low)" qualifier.
78
89
  7. **Client's prior use mitigates, doesn't eliminate.** Rate the full external landscape first; note
79
90
  the mitigation separately in a purple bullet.
91
+ 8. **A NAME IS NEVER RATED ABOVE THE WORST CARD ON ITS PAGE.** The page is one judgment: the name's
92
+ band, its basis line, its Why and Why-not boxes and its opening read all have to agree with each
93
+ other and with the cards under them. A reader who sees a band on the name and a milder band on
94
+ every card beneath it has been given two answers and no way to choose.
95
+ **Cards means every rated item on this name's page** — each `findings[]` record, and each
96
+ `registerReads[]` row that carries a band. A row with no band is not a card and does not count.
97
+ **A name with no rated cards takes the bottom band of the client's own ladder.** Nothing was found
98
+ against it; that is what the bottom band is for. It is not a reason to reach up a band.
99
+ **Rating BELOW the worst card is allowed and needs a sentence**, because it is a real judgment:
100
+ the worst card's holder may touch only the edge of the request. Say which card and why it does not
101
+ carry the name.
102
+ **Anything that should lift a name is a card of its own** — a famous-mark echo, a cultural
103
+ reading, a risk that belongs to the NAME rather than to one user. Write it as a `findings[]`
104
+ record and rate it there, where a reader can see what it is and disagree with it. A band lifted
105
+ without a card is a conclusion with its evidence left out.
106
+ The driver checks this and REFUSES, naming the card it read: it never rewrites your band. A
107
+ corrected band you reasoned is the product; a band a script lowered under prose that still argues
108
+ for the old one is worse than the defect it replaced.
80
109
 
81
110
  ## Degraded marks (research unavailable or null — never inflate)
82
111