clearotron 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.env.example +28 -0
  2. package/.nvmrc +1 -1
  3. package/INSTALL.md +13 -11
  4. package/README.md +11 -16
  5. package/THIRD-PARTY-NOTICES.md +1 -1
  6. package/bin/clearotron.mjs +22 -0
  7. package/bin/example.mjs +4 -0
  8. package/bin/onboard.mjs +195 -19
  9. package/bin/start.mjs +154 -8
  10. package/build-info.json +2 -2
  11. package/demo/full-country-search/run/_driver/framework.json +1 -1
  12. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  13. package/demo/knockout-search/run/_driver/framework.json +1 -1
  14. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  15. package/docs/E2E.md +5 -5
  16. package/docs/PORTAL.md +2 -2
  17. package/docs/README.md +5 -4
  18. package/docs/SECURITY.md +17 -0
  19. package/docs/architecture/04-configuration-reference.md +2 -0
  20. package/docs/architecture/05-config-governance.md +14 -0
  21. package/docs/architecture/05-customer-profiles.md +2 -2
  22. package/docs/configuration.md +4 -3
  23. package/driver/CHANGELOG.md +56 -0
  24. package/driver/common-law-receipts.mjs +1 -1
  25. package/driver/config-staleness.mjs +1 -1
  26. package/driver/dev-portal.mjs +2 -2
  27. package/driver/driver.config.mjs +1 -1
  28. package/driver/engine/cli-version.mjs +103 -0
  29. package/driver/enqueue-schema.mjs +8 -8
  30. package/driver/feedback-store.mjs +1 -1
  31. package/driver/findings-model.mjs +52 -2
  32. package/driver/flag-snapshot.mjs +20 -0
  33. package/driver/framework.mjs +1 -1
  34. package/driver/gateway.mjs +18 -1
  35. package/driver/package.json +3 -3
  36. package/driver/party-facts.mjs +2 -2
  37. package/driver/phase0.mjs +1 -1
  38. package/driver/pipeline.mjs +5 -5
  39. package/driver/plain-register.mjs +120 -0
  40. package/driver/portal-access.mjs +1 -1
  41. package/driver/portal-config-view.mjs +40 -1
  42. package/driver/portal-families.mjs +1 -1
  43. package/driver/portal-report.mjs +1 -1
  44. package/driver/portal-service.mjs +26 -7
  45. package/driver/portal-upstream.mjs +2 -2
  46. package/driver/predelivery-lint.mjs +121 -1
  47. package/driver/profile-page.html +10 -2
  48. package/driver/profile-service.mjs +4 -4
  49. package/driver/profiles/README.md +3 -3
  50. package/driver/profiles.mjs +144 -12
  51. package/driver/publish/index.mjs +2 -2
  52. package/driver/publish/knockout.mjs +7 -1
  53. package/driver/publish/render-knockout.mjs +453 -118
  54. package/driver/publish/render.mjs +2 -2
  55. package/driver/reference-strip-signatures.mjs +20 -0
  56. package/driver/search-policy.mjs +2 -2
  57. package/driver/skills/blind-frame/SKILL.md +1 -1
  58. package/driver/skills/knockout-assess/SKILL.md +84 -0
  59. package/driver/skills/matter-frame/SKILL.md +6 -6
  60. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  61. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  62. package/driver/skills/prelim-search/SKILL.md +1 -1
  63. package/driver/skills/prelim-search/report-prose.md +82 -0
  64. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  65. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  66. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  67. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  68. package/driver/skills/prelim-variants/SKILL.md +3 -3
  69. package/driver/stages.mjs +4 -4
  70. package/driver/suite-census.json +134 -38
  71. package/driver/unit-inventory.mjs +184 -27
  72. package/driver/verify.mjs +1 -1
  73. package/driver/whatif-memo-run.mjs +26 -4
  74. package/driver/whatif-memo.mjs +19 -1
  75. package/examples/README.md +2 -2
  76. package/examples/grants.example.json +6 -7
  77. package/mcp-server/CHANGELOG.md +2 -0
  78. package/mcp-server/lib/scrub.mjs +1 -1
  79. package/mcp-server/lib/whatif.mjs +2 -2
  80. package/mcp-server/package.json +1 -1
  81. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  82. package/mcp-server/server.mjs +1 -1
  83. package/package.json +28 -10
  84. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  85. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-CEYvXdqH.js} +226 -49
  86. package/portal-ui/dist/index.html +2 -2
  87. package/portal-ui/package.json +1 -1
  88. package/providers/_shared/term-shape.mjs +1 -1
  89. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  90. package/providers/oauth-mcp-bridge/package.json +1 -1
  91. package/providers/uspto-local/src/index-store.js +2 -2
  92. package/scripts/citation-line-check.mjs +121 -2
  93. package/scripts/env-classify.mjs +67 -3
  94. package/scripts/live-surface-check.mjs +38 -1
  95. package/scripts/nightly-notice.mjs +0 -2
  96. package/scripts/preinstall-node-check.mjs +24 -0
  97. package/scripts/strip-tracker-citations.mjs +29 -6
  98. package/scripts/test-run.mjs +16 -0
  99. package/shared/anon-overlay.mjs +3 -3
  100. package/shared/customer-store.mjs +1 -1
  101. package/shared/invocation.mjs +28 -0
  102. package/shared/names-in-force.mjs +2 -0
  103. package/shared/node-floor.mjs +97 -0
  104. package/shared/staff-domain.mjs +154 -0
  105. package/shared/store-in-repo.mjs +23 -2
  106. package/skills/clearotron-ops/SKILL.md +1 -1
  107. package/driver/recipes/README.md +0 -49
  108. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  109. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  110. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  111. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  112. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  113. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  114. package/scripts/ai-page-render-check.mjs +0 -532
  115. package/scripts/clearances-render-check.mjs +0 -480
  116. package/scripts/composer-render-check.mjs +0 -1027
  117. package/scripts/home-render-check.mjs +0 -526
  118. package/scripts/portal-lifecycle-check.mjs +0 -639
@@ -123,16 +123,22 @@ export const UNIT_INVENTORY = Object.freeze([
123
123
  // exists to make impossible — and a mechanism that installs itself is the easiest kind to ship
124
124
  // undeclared.
125
125
  unit: "clearotron-deploy", runsOn: ["test"],
126
- measured: "2026-09-07, test box: the timer and its service are installed under ~/.config/systemd/user/ and the service has runsee /home/testuser/deploy/autodeploy.log. The timer's ARMED state is not part of this claim; a lane may hold it, and a hold is recorded in the deploy log rather than inferred from ActiveState.",
126
+ measured: "2026-09-08, the test deployment: the timer and its service are installed for that deployment's own user, the timer is enabled and ACTIVE, and it last fired within the hour `systemctl --user list-timers`. The service's own state says nothing about this: it is `inactive` between runs and `inactive` when the timer has been stopped. The timer's ActiveState is what says anything will start it again.",
127
127
  tracked: ["clearotron-deploy.service", "clearotron-deploy.timer"],
128
- note: "the install's own updater. Ships tracked, runs nowhere yet.",
129
- orphanReason: "SHIPPED AND PLACED ON NO BOX YET, which is a THIRD kind of orphan and not either of "
130
- + "the other two: courtlistener-mcp runs on production under another name, feedback-mint was "
131
- + "deliberately switched off, and this one has simply never been installed. `runsOn` gains "
132
- + "\"test\" the day a test deploy places it and \"prod\" when the owner asks for it. Until then it "
133
- + "stays out of CHECKED_UNITS, because asking systemd about a unit nobody installed produces a "
134
- + "\"not compared\" row that reads like a fault and is not one. It goes on with "
135
- + "`systemctl --user enable --now clearotron-deploy.timer` once bin/onboard.mjs has written both files.",
128
+ note: "the install's own updater. Placed on the test deployment, where it pulls hourly; production takes it when the owner asks.",
129
+ // IT WAS AN ORPHAN, AND THE CONDITION IT NAMED HAS BEEN MET. This entry carried an `orphanReason`
130
+ // saying it had been shipped and installed nowhere, and that `runsOn` would gain "test" the day a
131
+ // deploy placed it. It has: the timer is installed, enabled and firing, and `runsOn` says so. The prose
132
+ // stayed behind the field, which is the ordinary way a declaration and its explanation come apart
133
+ // and it matters more here than usual, because this entry is now what puts a real timer into the
134
+ // timer list. An entry describing itself as installed nowhere, whose timer the check asks systemd
135
+ // about by name, is a contradiction a reader has to resolve before trusting either half.
136
+ //
137
+ // AND THE POSTURE IS NOW STATED, because it is a change in what a red means rather than in code: a
138
+ // STOPPED deploy timer FAILS. The service reads `inactive` between runs and `inactive` when nothing
139
+ // will ever run it again, so the timer's own state is the only thing separating a deployment that
140
+ // tracks the main branch from one quietly frozen on whatever it last built. If the timer is ever
141
+ // held deliberately, a check that says so for as long as the hold lasts is the behaviour to want.
136
142
  },
137
143
  {
138
144
  // ── `tracked` WAS A CLAIM ABOUT A FILE THAT HAS NEVER EXISTED (tracker issue 175) ──────────────
@@ -142,10 +148,23 @@ export const UNIT_INVENTORY = Object.freeze([
142
148
  // `live-surface-check` FAILED on a healthy box — the worst kind of red, because it teaches a reader
143
149
  // to scroll past the check that would catch a unit genuinely gone missing.
144
150
  //
145
- // THE ROW STAYS, AND DELETING IT WOULD HAVE BEEN THE WRONG FIX. The unit is LIVE ON PRODUCTION, as
146
- // the note below has said all along. Removing the entry would make this inventory stop knowing about
147
- // something that is running — the same failure it exists to prevent, pointed the other way. What was
148
- // false is the `tracked` claim, not the unit.
151
+ // THE ROW STAYS, AND DELETING IT WOULD HAVE BEEN THE WRONG FIX. What was false then was the
152
+ // `tracked` claim, not the row. Removing the entry would make this inventory stop knowing about a
153
+ // unit it ships nothing for — the same failure it exists to prevent, pointed the other way.
154
+ //
155
+ // ── AND THE OTHER HALF OF THAT PARAGRAPH HAS NOW BEEN MEASURED FALSE TOO ───────────────────────
156
+ //
157
+ // It read "The unit is LIVE ON PRODUCTION, as the note below has said all along", and it had been
158
+ // said all along without ever being enumerated. Production was read on 2026-09-08 — every account
159
+ // on the machine, unit file names and enable state — and no unit of this name exists under any of
160
+ // them. An earlier reading the same day reached the same answer from the other direction while
161
+ // closing a different question.
162
+ //
163
+ // So `runsOn` is empty and the note says what was measured. This is a change to what this
164
+ // repository CLAIMS about production, made because a reading contradicted the claim, and not a
165
+ // statement that production should not run it — that is a deployment question and it is the
166
+ // owner's. If the answer is yes, `runsOn` gains "prod" in the change that deploys it, against a
167
+ // fresh enumeration, which is this file's own rule rather than an exception to it.
149
168
  //
150
169
  // Declared the way its siblings are: `tracked: null` with the reason, which this file's own header
151
170
  // calls the sanctioned way to say the repo does not carry one.
@@ -156,16 +175,31 @@ export const UNIT_INVENTORY = Object.freeze([
156
175
  // is a change which ADDS a file, measured against the deployed copy, and it is not this one. Two
157
176
  // prose references in driver/systemd/render-units.mjs also describe this file as though it were in
158
177
  // the tree; they are stale today either way.
159
- unit: "profile-service", runsOn: ["prod"], tracked: null,
178
+ unit: "profile-service", runsOn: [],
179
+ measured: "2026-09-08: production read by account — the deployment account carries six clearotron-* "
180
+ + "unit files and the doctrine-sync timer, the legacy account carries five units belonging to the "
181
+ + "other product plus unloadable residue, and no unit of THIS name is under either. Names and enable "
182
+ + "state only; no unit contents were read.",
183
+ tracked: null,
160
184
  untrackedReason: "the repository has never carried this file — `git log --all` on the path is empty. "
161
- + "It deploys from the production box's own copy, in the same CF Access template family as "
162
- + "trademark-portal and trademark-ops-mcp. Whether a placeholder should now ship is open: see above.",
185
+ + "It was described as deploying from a copy held on the deployment itself, in the same "
186
+ + "identity-provider template family as two other pre-rename names; the 2026-09-08 reading finds no "
187
+ + "such copy under any account, so that description is history rather than current state. Whether a "
188
+ + "placeholder should now ship is open: see above.",
163
189
  // NO LONGER RESOLVED AT INSTALL. It carried `@CLEAROTRON_CHECKOUT_DIR@` because
164
190
  // it loaded no EnvironmentFile and so had no `${VAR}` systemd could expand. The owner's one-config-
165
191
  // per-server-box ruling gives it `EnvironmentFile=%h/.env` like every other service, which makes the
166
192
  // checkout path an ordinary systemd expansion and leaves no placeholder to render.
167
- note: "LIVE ON PRODUCTION. an earlier record lists it as never run; that is true of the test box only. "
168
- + "Was a TEMPLATE unit carrying CF Access values inline; generic since tracker issue 1925.",
193
+ note: "NOT RUNNING ANYWHERE, measured 2026-09-08. This row said LIVE ON PRODUCTION for as long as it "
194
+ + "existed and no enumeration ever supported it. Was a TEMPLATE unit carrying identity-provider "
195
+ + "values inline; generic since tracker issue 1925.",
196
+ orphanReason: "CLAIMED A DEPLOYMENT IT WAS NEVER MEASURED ON, which is a FOURTH kind of orphan and "
197
+ + "the only one that was ever a wrong claim rather than a waiting decision: the other three run "
198
+ + "under another name, were deliberately switched off, or have simply never been installed. This "
199
+ + "one was declared live and read as absent. The row is kept because the deployment question is "
200
+ + "open and belongs to the owner — whether production should run this service at all — and an "
201
+ + "inventory that deleted the row would lose the only place that question is written down. It "
202
+ + "gains a deployment the day an enumeration shows it, never the day someone intends it.",
169
203
  },
170
204
  {
171
205
  // RUNS ON NO BOX YET, AND THAT IS THE HONEST DECLARATION. `runsOn: ["prod"]` is a claim about a
@@ -249,8 +283,11 @@ export const UNIT_INVENTORY = Object.freeze([
249
283
  + "alternatives, and running both puts a second claimant on one queue.",
250
284
  },
251
285
  {
252
- unit: "trademark-portal", runsOn: ["prod"],
253
- measured: "2026-09-07, test box: LoadState=not-found. No unit of this name exists there. The `test` claim was inherited from before the rename to clearotron-* and was never remeasured; the `prod` claim is left alone because production was NOT measured here.", tracked: null,
286
+ unit: "trademark-portal", runsOn: [],
287
+ orphanReason: "THE PRE-RENAME PORTAL. The deployment account runs `clearotron-portal`; this name is "
288
+ + "what that service used to be called, and the 2026-09-08 reading finds no unit of this name under "
289
+ + "any account. It ran on production until the rename and the entry was never re-measured after it.",
290
+ measured: "2026-09-08: production read by account — the deployment account carries six clearotron-* unit files and the doctrine-sync timer, the legacy account carries five units of the other product plus unloadable residue, and no unit of THIS name is under either. Names and enable state only. This line previously carried a 2026-09-07 reading of the test deployment — LoadState=not-found there, with the production claim left alone because production had not been measured. It has been now, and this is that reading.", tracked: null,
254
291
  untrackedReason: "the deployed copy carries real Cloudflare Access team/AUD/domain values inline. "
255
292
  + "A tracked file would be the placeholder TEMPLATE, merged by hand after a "
256
293
  + "diff — writing one carelessly replaces working auth with placeholders that look configured.",
@@ -261,13 +298,19 @@ export const UNIT_INVENTORY = Object.freeze([
261
298
  supersededName: "portal-service.service (deleted, owner-ruled 2026-08-14)",
262
299
  },
263
300
  {
264
- unit: "trademark-ops-mcp", runsOn: ["prod"],
265
- measured: "2026-09-07, test box: LoadState=not-found. No unit of this name exists there. The `test` claim was inherited from before the rename to clearotron-* and was never remeasured; the `prod` claim is left alone because production was NOT measured here.", tracked: null,
301
+ unit: "trademark-ops-mcp", runsOn: [],
302
+ orphanReason: "THE PRE-RENAME OPERATOR DOOR. The deployment account runs `clearotron-mcp-face`; the "
303
+ + "2026-09-08 reading finds no unit of this name under any account. Same rename as the portal it "
304
+ + "serves, and the same claim carried across it unmeasured.",
305
+ measured: "2026-09-08: production read by account — the deployment account carries six clearotron-* unit files and the doctrine-sync timer, the legacy account carries five units of the other product plus unloadable residue, and no unit of THIS name is under either. Names and enable state only. This line previously carried a 2026-09-07 reading of the test deployment — LoadState=not-found there, with the production claim left alone because production had not been measured. It has been now, and this is that reading.", tracked: null,
266
306
  untrackedReason: "same CF Access inline-values shape as the portal it serves.",
267
307
  },
268
308
  {
269
- unit: "client-mcp", runsOn: ["prod"],
270
- measured: "2026-09-07, test box: LoadState=not-found. No unit of this name exists there. The `test` claim was inherited from before the rename to clearotron-* and was never remeasured; the `prod` claim is left alone because production was NOT measured here.", tracked: ["client-mcp.service"],
309
+ unit: "client-mcp", runsOn: [],
310
+ orphanReason: "THE PRE-RENAME CLIENT DOOR. `clearotron-client-mcp` runs on both the production and "
311
+ + "the test deployments; the 2026-09-08 reading finds no unit of THIS name under any account. The "
312
+ + "tracked template stays because the repository still ships it — see the note.",
313
+ measured: "2026-09-08: production read by account — the deployment account carries six clearotron-* unit files and the doctrine-sync timer, the legacy account carries five units of the other product plus unloadable residue, and no unit of THIS name is under either. Names and enable state only. This line previously carried a 2026-09-07 reading of the test deployment — LoadState=not-found there, with the production claim left alone because production had not been measured. It has been now, and this is that reading.", tracked: ["client-mcp.service"],
271
314
  note: "TRACKED, in mcp-server/remote/ — this entry said it had no file, and the file was there. It is "
272
315
  + "a banner-marked TEMPLATE, so the live copy differing from it is the arrangement, not drift. "
273
316
  + "#1147 ADDED THE TEST BOX (2026-08-18): the client surface ran nowhere but production, so the test "
@@ -278,18 +321,79 @@ export const UNIT_INVENTORY = Object.freeze([
278
321
  + "not the default.",
279
322
  },
280
323
  {
281
- unit: "client-mcp-apikey", runsOn: ["prod"], tracked: ["client-mcp-apikey.service"],
324
+ unit: "client-mcp-apikey", runsOn: ["test"],
325
+ measured: "2026-09-08, read by account: enabled and present on the TEST deployment, beside the "
326
+ + "renamed `clearotron-client-mcp`. Absent from production under any account. The entry declared "
327
+ + "production and the reading puts it on the other deployment — the claim was not stale, it named "
328
+ + "the wrong machine. Why it runs there at all beside the renamed door is a separate question.",
329
+ tracked: ["client-mcp-apikey.service"],
282
330
  note: "TRACKED, in mcp-server/remote/. Same correction as client-mcp: the old reason claimed the live "
283
331
  + "key made a tracked file impossible, and the tracked TEMPLATE — which holds no key — already existed.",
284
332
  },
285
333
  {
286
- unit: "trademark-artifacts-http", runsOn: ["prod"], tracked: ["trademark-artifacts-http.service"],
334
+ unit: "trademark-artifacts-http", runsOn: [],
335
+ orphanReason: "A PRE-RENAME NAME OF THE RETIRED DEPLOYMENT, and the one of them whose tracked file "
336
+ + "is GENERIC rather than a template — so the file is ready to be compared against a live copy the "
337
+ + "day something runs it, and nothing does. Read as absent under every account on 2026-09-08.",
338
+ measured: "2026-09-08: production read by account — no unit of this name is under the deployment account or the legacy one. Names and enable state only; no unit contents were read.", tracked: ["trademark-artifacts-http.service"],
287
339
  note: "TRACKED, in mcp-server/remote/. GENERIC, not a template: it carries no banner and no "
288
340
  + "placeholder, and defers its CF Access values to the EnvironmentFile — so its live copy is "
289
341
  + "expected to MATCH the tracked file, and a difference is real drift.",
290
342
  },
291
343
  {
292
- unit: "client-access", runsOn: ["prod"], tracked: null,
344
+ unit: "clearotron-mcp-local", runsOn: ["prod"], tracked: null,
345
+ untrackedReason: "the fifth hand-made door on production, on 18793, kept as it is by ruling while "
346
+ + "the product fix lands. Known and hand-made is not the same as declared: it was running on "
347
+ + "production and named by no inventory, no unit file and no line of code in this repo, so the "
348
+ + "health check could not see it and nothing would have said if it stopped. Declared here to end "
349
+ + "that, not to bless the arrangement.",
350
+ },
351
+ {
352
+ unit: "clearotron-doctrine-sync", runsOn: ["prod"], tracked: null,
353
+ // WHAT SYSTEMD KNOWS THIS UNIT AS, which `tracked` cannot say here. For a tracked unit the file
354
+ // list carries the suffixes — `clearotron-deploy` names its `.service` AND its `.timer` — so a
355
+ // reader deriving systemd queries from the entry gets both. An UNTRACKED unit ships no files, so
356
+ // `tracked: null` leaves nothing to derive from and the bare name silently means `.service`.
357
+ // Stated separately so the two questions stay apart: `tracked` is what this repo SHIPS,
358
+ // `systemdUnits` is what the box RUNS.
359
+ systemdUnits: ["clearotron-doctrine-sync.service", "clearotron-doctrine-sync.timer"],
360
+ // ── THE ONE WHOSE SILENCE REACHES A CLIENT ────────────────────────────────────────────────────
361
+ //
362
+ // This is the timer half of production's doctrine refresh. The stages read doctrine to decide what
363
+ // they do, so if it stops, production runs clearances against whatever doctrine last landed and NO
364
+ // SURFACE ANYWHERE SAYS SO — the client receives a report that looks exactly like a current one.
365
+ //
366
+ // That is a client-facing failure with no detector, which is what the drift check exists for, and
367
+ // the drift check could not see this unit because the unit was in no list.
368
+ //
369
+ // BEING IN THIS LIST IS NOT YET DETECTION, AND SAYING SO IS THE POINT OF THIS PARAGRAPH. Every name
370
+ // here is bare, and `systemctl show <bare>` answers for the `.service`. For a timer-driven unit that
371
+ // service is `inactive` BETWEEN RUNS and `inactive` when its timer has been stopped — the same
372
+ // answer to both questions. So the health check can now say this unit exists and cannot say whether
373
+ // anything still starts it, which is the failure the entry was written for.
374
+ //
375
+ // The signal lives on `clearotron-doctrine-sync.timer`, whose ActiveState separates `active`
376
+ // (waiting) from `inactive` (stopped). What is missing is not the NAME — `systemdUnits` below
377
+ // carries it — but a consumer: the check derives its queries from the bare unit rather than from
378
+ // that field, so the timer is written down and never asked about.
379
+ //
380
+ // WHEN THE CHECK DERIVES ITS QUERIES FROM `systemdUnits` RATHER THAN FROM THE BARE NAME, THIS
381
+ // PARAGRAPH IS OBSOLETE — and the field below is what it will read. Stated as a condition rather
382
+ // than as a pointer to where it is being done, because a condition is self-verifying: a reader
383
+ // confirms or refutes it by looking at the check today, and the day the derivation lands this reads
384
+ // as history instead of as an open item.
385
+ untrackedReason: "the doctrine refresh on production, timer-driven, running from no file in "
386
+ + "driver/systemd/ and named nowhere in this repo. It CAN be tracked — it carries no "
387
+ + "identity-edge value — and nobody wrote it down. Until it is, its absence is at least no "
388
+ + "longer silent.",
389
+ },
390
+ {
391
+ unit: "client-access", runsOn: [],
392
+ orphanReason: "NOT THIS REPOSITORY'S SERVICE, and no longer running under this name: the legacy "
393
+ + "account holds it only as `client-access.service.retired-20260904`, a suffix systemd never reads. "
394
+ + "Declared here so a unit sharing a machine with ours is not invisible, and now declared as gone.",
395
+ measured: "2026-09-08: present on the legacy account ONLY as a retired-suffixed filename, which "
396
+ + "systemd does not load. No loadable unit of this name under any account.", tracked: null,
293
397
  untrackedReason: "it is not this repo's service. Its ExecStart runs a script from a different "
294
398
  + "product's checkout on the same box, not under this clone — so there is no code here to "
295
399
  + "template, and the old reason (CF Access values inline) would send the next reader hunting for a "
@@ -453,6 +557,59 @@ export const CHECKED_UNITS = Object.freeze(
453
557
  UNIT_INVENTORY.filter((u) => u.runsOn.length > 0).map((u) => u.unit),
454
558
  );
455
559
 
560
+ /**
561
+ * The TIMER units this deployment claims to have, by name, as systemd spells them.
562
+ *
563
+ * ── WHY A SECOND LIST AND NOT MORE NAMES IN THE ONE ABOVE ───────────────────────────────────────────
564
+ *
565
+ * `CHECKED_UNITS` holds BARE names, and that is load-bearing rather than an accident: the verdict
566
+ * appends the suffixes itself when matching what a box is running, so one entry answers for a unit's
567
+ * service, timer and path alike. The health check then asks `systemctl show <bare>`, and systemd
568
+ * resolves a bare name to the `.service`.
569
+ *
570
+ * For a timer-driven unit that is the wrong question, and it is wrong in the direction that hides the
571
+ * failure. The service of a timer-driven unit reads `ActiveState=inactive` **between runs** — it is
572
+ * meant to — and it reads `inactive` when its timer has been STOPPED. Identical strings, opposite
573
+ * meanings, and the check could report the unit exists while being unable to say whether anything
574
+ * still starts it. That is the whole of what a timer entry is for.
575
+ *
576
+ * The signal lives on the `.timer`, whose `ActiveState` separates `active` (armed and waiting) from
577
+ * `inactive` (stopped, and nothing will fire the service again).
578
+ *
579
+ * ── WHERE THE NAMES COME FROM ───────────────────────────────────────────────────────────────────────
580
+ *
581
+ * From `tracked`, which already lists the files an entry accounts for, so a shipped timer is covered
582
+ * with nothing new to declare and cannot be forgotten. An UNTRACKED unit has no file list to read, so
583
+ * it says so with `systemdUnits:` — the systemd names to ask about, which is a different fact from the files
584
+ * the repository ships and is why it is not folded into `tracked`. Claiming a tracked file that does
585
+ * not exist is a fault; naming a unit this deployment runs is a claim about a box.
586
+ */
587
+ export const CHECKED_TIMERS = Object.freeze(
588
+ UNIT_INVENTORY.filter((u) => u.runsOn.length > 0)
589
+ .flatMap((u) => (u.systemdUnits ?? u.tracked ?? []).filter((f) => f.endsWith(".timer")))
590
+ .sort(),
591
+ );
592
+
593
+ /**
594
+ * Judge the timers. PURE — the caller reads systemd.
595
+ *
596
+ * `inactive` is the finding here, which inverts the reflex a reader brings from services. A stopped
597
+ * timer breaks nothing now and nothing fails: the service simply never fires again, and every surface
598
+ * that reports on the service keeps saying `inactive`, which is what it says when the timer is working.
599
+ */
600
+ export function timerVerdict(timers, { probeFailed = null } = {}) {
601
+ if (probeFailed) return { state: "unknown", stopped: [], absent: [], message: `could not ask systemd about the timers: ${probeFailed}. That is this check failing to look, not a report about them.` };
602
+ const rows = timers ?? [];
603
+ if (!rows.length) return { state: "pass", stopped: [], absent: [], message: "no timer is declared for this box" };
604
+ const absent = rows.filter((t) => t.load === "not-found").map((t) => t.unit).sort();
605
+ const stopped = rows.filter((t) => t.load !== "not-found" && t.active !== "active").map((t) => t.unit).sort();
606
+ const parts = [`${rows.length} declared timer(s)`];
607
+ if (stopped.length) parts.push(`STOPPED: ${stopped.join(", ")} — the service each one drives will not fire again, and its own state stays 'inactive' either way`);
608
+ if (absent.length) parts.push(`${absent.length} declared timer(s) do not exist on this box`);
609
+ if (!stopped.length && !absent.length) parts.push("all armed");
610
+ return { state: stopped.length ? "fail" : "pass", stopped, absent, message: parts.join("; ") };
611
+ }
612
+
456
613
  /** Every tracked file the inventory accounts for, flattened. */
457
614
  export const ACCOUNTED_FILES = Object.freeze(
458
615
  UNIT_INVENTORY.flatMap((u) => u.tracked ?? []),
package/driver/verify.mjs CHANGED
@@ -61,7 +61,7 @@ function readRunProfile(p) {
61
61
 
62
62
  // ── doc 50: the run-scoped FROZEN framework manifest ────────────────────────────────────────────────
63
63
  // _driver/framework.json is the framework-in-force's manifest, frozen beside profile.json at
64
- // attachProfile time — the band vocabulary every v4 gate joins against (zephyr "Medium" vs house
64
+ // attachProfile time — the band vocabulary every v4 gate joins against (a customer ladder's "Medium" vs house
65
65
  // "Moderate" stays consistent within a run and across resume). Same walk-up + fail-closed convention as
66
66
  // readRunProfile: absent ⇒ null (archived/legacy runs); unreadable ⇒ invalid (driver-written, so a
67
67
  // corrupt one is a bug that must surface, never a silent downgrade to the wrong vocabulary).
@@ -157,7 +157,7 @@ export async function seatReason({ runDir, runId, assumption, ratedUnder, findin
157
157
  const replyPath = join(runDir, MEMO_DIR, `reply-${sha(`${runId}${assumption}`).slice(0, 10)}.json`);
158
158
  mkdirSync(join(runDir, MEMO_DIR), { recursive: true });
159
159
  const r = await dispatch("whatif-memo", {
160
- message: composeMemoMessage({ assumption, findings, ratedUnder, skill }),
160
+ message: composeMemoMessage({ assumption, findings, ratedUnder, skill, replyPath }),
161
161
  model,
162
162
  // A BOUNDED READING, and the ceiling says so. This re-reads evidence already gathered; a memo that
163
163
  // needed the clearance's own ceiling would not be the cheap thing the plan promised the reader.
@@ -230,7 +230,12 @@ export function validateMemoReply(raw) {
230
230
  * The findings are handed over as the run's own JSON rather than summarised: a memo that reasoned over
231
231
  * our paraphrase of the evidence would be answering about the paraphrase.
232
232
  */
233
- export function composeMemoMessage({ assumption, findings, ratedUnder = null, skill = "" } = {}) {
233
+ // `ratedUnder` DEFAULTS HERE AND IS REFUSED BY `composeMemo`, and that asymmetry is only safe while the
234
+ // artifact is the gate. One resolution is threaded to both, and nothing reaches disk without the memo
235
+ // composing — so a forgotten field is caught there. It stops being safe the moment a dispatch path calls
236
+ // this composer alone: the seat would be instructed "house default" with no artifact refusal behind it,
237
+ // because no artifact would be composed. A caller that dispatches without composing owes its own check.
238
+ export function composeMemoMessage({ assumption, findings, ratedUnder = null, skill = "", replyPath = null } = {}) {
234
239
  return [
235
240
  skill.trim(),
236
241
  "",
@@ -250,8 +255,21 @@ export function composeMemoMessage({ assumption, findings, ratedUnder = null, sk
250
255
  JSON.stringify(findings ?? null, null, 2),
251
256
  "```",
252
257
  "",
253
- "Write the JSON object the skill dictates, and nothing else.",
254
- ].join("\n");
258
+ // NAME THE FILE, IN THE FIRST MESSAGE. The skill says what to write and the harness was told where
259
+ // to look, and until this line nothing told the SEAT where to put it. A seat given a write grant and
260
+ // no destination writes somewhere reasonable of its own choosing — an archived run carries one such
261
+ // reply at its run root — and the harness then looks where it asked, does not find it, and fails the
262
+ // attempt on `missing_file`. The warm patch names the path, which is the whole reason the retry
263
+ // succeeded: the second attempt was the first one that said where.
264
+ //
265
+ // The cost of not saying it was never the failure. It was that every memo billed two dispatches for
266
+ // one document and left a retried stage on the parent run's ledger — an INVESTIGATE line on a report
267
+ // that had already been delivered clean.
268
+ replyPath
269
+ ? `Write it to this ABSOLUTE path (create parent dirs if needed): ${replyPath}`
270
+ : "Write the JSON object the skill dictates, and nothing else.",
271
+ replyPath ? "Write the JSON object the skill dictates, and nothing else." : null,
272
+ ].filter((l) => l !== null).join("\n");
255
273
  }
256
274
 
257
275
  /**
@@ -319,6 +337,10 @@ export async function askArchivedRun({ runId, question, requestedBy = null } = {
319
337
  body: String(reading?.body ?? ""),
320
338
  limits: Array.isArray(reading?.limits) ? reading.limits : [],
321
339
  mark: run.markName ?? null,
340
+ // The authority the seat was instructed to assess under, carried to the artifact rather than ending
341
+ // at the return value. `parentRatedUnder` answers null for a run that froze no profile, which is a
342
+ // fact the memo states rather than omits.
343
+ ratedUnder,
322
344
  });
323
345
  if (!composed.ok)
324
346
  return { ok: false, fail: MEMO_FAILS.COMPOSE_REFUSED, detail: composed.reason, missing: composed.missing };
@@ -43,10 +43,25 @@ export const REQUIRED = Object.freeze(["assumption", "parentRunId", "parentRepor
43
43
  * needs new evidence" — it is a claim, and `limitsStated` records which claim was made rather than
44
44
  * leaving a reader to infer it from silence.
45
45
  */
46
- export function composeMemo({ assumption, parentRunId, parentReport, date, body, limits = [], mark = null } = {}) {
46
+ export function composeMemo({ assumption, parentRunId, parentReport, date, body, limits = [], mark = null, ratedUnder } = {}) {
47
47
  const missing = REQUIRED.filter((k) => !String({ assumption, parentRunId, parentReport, date, body }[k] ?? "").trim());
48
48
  if (missing.length) return { ok: false, missing, reason: `a memo cannot be composed without: ${missing.join(", ")}` };
49
49
 
50
+ // THE RATING AUTHORITY IS REQUIRED BY PRESENCE, NOT BY TRUTHINESS, because `null` is a real answer.
51
+ // A memo is reasoned under an authority — the seat is instructed to assess under a named framework, or
52
+ // told the run froze none and to stay with the house default — and until this line the only thing that
53
+ // reached disk was the reasoning. A lawyer may act on this document; it has to say what it was rated
54
+ // under.
55
+ //
56
+ // Three states, and the third is the one worth separating. A named key is one fact. The house default
57
+ // is a DIFFERENT fact, not an absence, and it is recorded rather than left blank. A caller that never
58
+ // resolved an authority at all is neither, and refusing it is the point: a memo whose artifact is
59
+ // silent about its authority is exactly the defect this closes, so it must not be composable.
60
+ if (ratedUnder === undefined)
61
+ return { ok: false, missing: ["ratedUnder"],
62
+ reason: "a memo cannot be composed without its rating authority — pass the resolved key, or null "
63
+ + "for a run that froze no customer profile. Absent is not the same fact as either." };
64
+
50
65
  const limitLines = limits
51
66
  .map((l) => ({ cannot: String(l?.cannot ?? "").trim(), smallestSearch: String(l?.smallestSearch ?? "").trim() }))
52
67
  .filter((l) => l.cannot);
@@ -64,6 +79,9 @@ export function composeMemo({ assumption, parentRunId, parentReport, date, body,
64
79
  "",
65
80
  `**Date:** ${date}`,
66
81
  `**Derived from:** ${parentReport} (run ${parentRunId})`,
82
+ `**Rated under:** ${ratedUnder === null
83
+ ? "the house default — this report's run froze no customer profile"
84
+ : String(ratedUnder).trim()}`,
67
85
  "",
68
86
  "## The assumption you asked me to apply",
69
87
  "",
@@ -26,8 +26,8 @@ surfaces as a failing test rather than as a wrong report.
26
26
  (`loadGrants` in `shared/scope.mjs`) and asserted to grant what it looks like it grants — `../INSTALL.md`
27
27
  §8 tells an installer to copy this file, and a row that silently resolves to nothing means an empty
28
28
  world on their first sign-in. **Read it as a shape, not as a starting roster.** Most of the accounts it
29
- names `aurora`, `zephyr`, `petcary` are fixtures the test suite reads and are not part of the
30
- published package; the one a fresh install actually has is `demo-brand-owner`. Replace the names with
29
+ names are invented and resolve to nothing on a fresh install; the one account a fresh install
30
+ actually has is `demo-brand-owner`. Replace the names with
31
31
  your own accounts and keep the shapes: a tenant with every account, a tenant with one, a tenant whose
32
32
  users see different subsets.
33
33
 
@@ -5,27 +5,26 @@
5
5
  "users": {
6
6
  "principal@firm.example": "*",
7
7
  "associate@firm.example": [
8
- "aurora"
8
+ "demo-brand-owner"
9
9
  ]
10
10
  }
11
11
  },
12
- "aurora-direct": {
12
+ "brand-owner-direct": {
13
13
  "accounts": [
14
- "aurora"
14
+ "demo-brand-owner"
15
15
  ],
16
16
  "users": {
17
- "*@aurora.example": "*"
17
+ "*@brand-owner.example": "*"
18
18
  }
19
19
  },
20
20
  "evaluation": {
21
21
  "accounts": [
22
- "zephyr",
23
- "petcary"
22
+ "demo-brand-owner"
24
23
  ],
25
24
  "users": {
26
25
  "reviewer@partner.example": "*",
27
26
  "observer@partner.example": [
28
- "zephyr"
27
+ "demo-brand-owner"
29
28
  ]
30
29
  }
31
30
  },
@@ -1,5 +1,7 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.2.3
4
+
3
5
  ## 0.2.2
4
6
 
5
7
  ## 0.3.0-beta.0
@@ -54,7 +54,7 @@ export const CLIENT_FRONT_MATTER = new Set([
54
54
  // `Generic default (generic) · Generic default framework · profile d37721cda899` → drop the profile segment.
55
55
  // The framework title is client-facing (report footer: "Rated under <title>"); the profile hash is the
56
56
  // internal identity of the config that rated the matter and is not. A custom framework carries its source
57
- // FILENAME too (`custom framework: Aurora Interactive ACP risk framework (risk-framework-aurora.md)`) — the
57
+ // FILENAME too (`custom framework: Foxglade Interactive ACP risk framework (risk-framework-foxglade.md)`) — the
58
58
  // human title stays, the file is config identity and goes.
59
59
  function ratedUnderForClient(v) {
60
60
  return String(v ?? "").split("·").map((s) => s.trim())
@@ -278,8 +278,8 @@ export async function whatIfRun({ confirmationToken } = {}, deps = {}) {
278
278
  // The reconstruction above carries six fields and resolveProfile keys on none of them. It reads
279
279
  // `job.profileKey` first, then falls back to `job.forwarderDomain`; the job has `forwarder` but not
280
280
  // `forwarderDomain`, and no profileKey at all. So BOTH resolution routes were dead here and every
281
- // what-if silently resolved to the house `generic` profile. Measured on a petcary run:
282
- // {"event":"profile-mismatch","sidecar":"petcary","resolved":"generic"} — while the client-facing
281
+ // what-if silently resolved to the house `generic` profile. Measured on a test-account run:
282
+ // {"event":"profile-mismatch","sidecar":"burrowell","resolved":"generic"} — while the client-facing
283
283
  // result reported ok:true and "Sandboxed re-run complete" and said nothing.
284
284
  //
285
285
  // WHY THAT IS WORSE THAN A WRONG LABEL. A what-if changes ONE thing and reads the difference. This
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -29,8 +29,8 @@ original message. It does not guess.
29
29
 
30
30
  ## Late applicant binding
31
31
 
32
- Requester (mid-run): *"the applicant will be Zephyr Beverages Ltd"* → `feed_context { runId,
33
- customer: "Zephyr Beverages Ltd" }` → the outbox later carries a `late-bind-ack` to relay.
32
+ Requester (mid-run): *"the applicant will be Coastline Drinks Ltd"* → `feed_context { runId,
33
+ customer: "Coastline Drinks Ltd" }` → the outbox later carries a `late-bind-ack` to relay.
34
34
 
35
35
  ## Courier wake
36
36
 
@@ -232,7 +232,7 @@ const tools = {
232
232
  projects: (byCustomer.get(p.key) ?? []).sort((a, b) => a.key.localeCompare(b.key)) }))
233
233
  .sort((a, b) => a.key.localeCompare(b.key));
234
234
  return {
235
- _note: "Customer roster for intake resolution. Resolve by JUDGMENT — an explicit name, a misspelling (\"Zefyr\"→zephyr), or an implicit reference (\"our functional-beverage client\") all map to a key. Set the job's profileKey to the chosen customer key; OMIT it for a new/unknown customer (⇒ the neutral generic profile). If the request names a specific PROJECT/engagement under that customer (listed in `projects[]`), also set projectKey to that project's key; OMIT projectKey when no project is meant (⇒ the customer profile). CLARIFY if you cannot tell either. Never pick a profile from the sender's email domain.",
235
+ _note: "Customer roster for intake resolution. Resolve by JUDGMENT — an explicit name, a misspelling of one of the keys below, or an implicit reference (\"our functional-beverage client\") all map to a key. Set the job's profileKey to the chosen customer key; OMIT it for a new/unknown customer (⇒ the neutral generic profile). If the request names a specific PROJECT/engagement under that customer (listed in `projects[]`), also set projectKey to that project's key; OMIT projectKey when no project is meant (⇒ the customer profile). CLARIFY if you cannot tell either. Never pick a profile from the sender's email domain.",
236
236
  clients,
237
237
  genericFallback: "generic",
238
238
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.2.2",
4
+ "version": "0.2.3",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "description": "Clearotron — trademark-clearance engine: deterministic orchestration driver + MCP interrogation server. Provider-agnostic, model-agnostic, runs headless (no gateway).",
15
15
  "engines": {
16
- "node": ">=22.19.0"
16
+ "node": ">=22.13.0"
17
17
  },
18
18
  "workspaces": [
19
19
  "driver",
@@ -22,6 +22,7 @@
22
22
  "portal-ui"
23
23
  ],
24
24
  "scripts": {
25
+ "preinstall": "node scripts/preinstall-node-check.mjs",
25
26
  "postinstall": "node -e \"const{existsSync}=require('node:fs');const{execFileSync}=require('node:child_process');if(existsSync('scripts/install-hooks.mjs'))execFileSync(process.execPath,['scripts/install-hooks.mjs'],{stdio:'inherit'})\"",
26
27
  "prepack": "node scripts/write-build-info.mjs",
27
28
  "prepublishOnly": "node scripts/release-completeness-check.mjs",
@@ -72,7 +73,7 @@
72
73
  "buffers": "file:vendor/buffers",
73
74
  "exceljs": "^4.4.0",
74
75
  "jose": "^6.2.3",
75
- "undici": "^8.10.1"
76
+ "undici": "^7.29.1"
76
77
  },
77
78
  "bin": {
78
79
  "clearotron": "bin/clearotron.mjs"
@@ -105,13 +106,30 @@
105
106
  "portal-ui/dist/",
106
107
  "portal-ui/package.json",
107
108
  ".env.example",
108
- "!driver/profiles/aurora.json",
109
- "!driver/profiles/zephyr.json",
110
- "!driver/profiles/petcary.json",
111
- "!driver/profiles/petcary.context.md",
112
- "!driver/profiles/projects/aurora/",
113
- "!driver/recipes/aurora/",
114
- "!driver/recipes/zephyr/",
109
+ "!driver/profiles/*.json",
110
+ "driver/profiles/generic.json",
111
+ "driver/profiles/demo-brand-owner.json",
112
+ "!driver/profiles/*.context.md",
113
+ "!driver/profiles/projects/*/",
114
+ "driver/profiles/projects/demo-brand-owner/",
115
+ "!driver/recipes/*/",
116
+ "!driver/skills/prelim-search/risk-framework-*",
117
+ "driver/skills/prelim-search/risk-framework.md",
118
+ "driver/skills/prelim-search/risk-framework.manifest.json",
119
+ "driver/skills/prelim-search/risk-framework-demo.md",
120
+ "driver/skills/prelim-search/risk-framework-demo.manifest.json",
121
+ "driver/skills/prelim-search/risk-framework-triage.md",
122
+ "driver/skills/prelim-search/risk-framework-triage.manifest.json",
123
+ "!driver/skills/prelim-search/worked-examples-*",
124
+ "driver/skills/prelim-search/worked-examples.md",
125
+ "driver/skills/prelim-search/worked-examples-demo.md",
126
+ "!scripts/home-render-check.mjs",
127
+ "!scripts/ai-page-render-check.mjs",
128
+ "!scripts/composer-render-check.mjs",
129
+ "!scripts/clearances-render-check.mjs",
130
+ "!scripts/portal-lifecycle-check.mjs",
131
+ "!scripts/test-account-names.mjs",
132
+ "!scripts/no-test-account-reaches-the-package.mjs",
115
133
  "!**/test/",
116
134
  "!**/bench/",
117
135
  "!**/*.test.mjs",