Repository navigation
feat(core)!: retire PluginSecurityScanner — plugin security scanning is not a platform capability - #15930
Conversation
…14919) ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 (director summon #14, decision batch #42). The class was a shell that reported success: four of its five private scanners returned an empty issue list unconditionally, and the fifth matched against an in-memory vulnerability database whose only writer had zero callers -- so every scan() ever performed answered status: 'passed' with a perfect score, for a malicious plugin as readily as a benign one. A security control that cannot fail is worse than none, because callers rely on it. - delete packages/core/examples/phase2-integration.ts (the only constructor) - delete src/security/security-scanner.ts; drop its export block from src/security/index.ts, leaving a tombstone naming the retirement - rewrite PHASE2_IMPLEMENTATION.md section 6 to state plainly that plugin security scanning is NOT a platform capability, and drop the two capability claims elsewhere in the same document that outlived their subject - delete the FOLLOW-UPS.md row, repair the paragraph that existed only to compound it, and correct the neighbouring row whose evidence the deletion falsified - pin the retirement as an export-list assertion on both barrels Repair was refused by name: a real vulnerability scanner is a feature with a design surface, not a defect fix. There is no replacement export. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 2 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3bd95c5c5e6b2d4f98cf5da03fa76e27737191a3 && git checkout 3bd95c5c5e6b2d4f98cf5da03fa76e27737191a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f1e91595f706b7eefb73457754f4eb05fa13e362 67e559697ada8e10715b6dfa73cadf71446164de && git checkout -B drift-repro f1e91595f706b7eefb73457754f4eb05fa13e362 && git merge --no-ff 67e559697ada8e10715b6dfa73cadf71446164de
node scripts/docs-audit/affected-docs.mjs --json f1e91595f706b7eefb73457754f4eb05fa13e362
|
…0087 ledger (#14919) check-adr-0087-registration refused the previous disposition, correctly: the changeset carries a real consumer prescription (delete the import and every call), so `not-required (no-migration-prescription)` was a self-contradiction. Every other not-required category is false too -- @objectstack/core publishes, so `unpublished` is out; `already-registered` has no entry to name; `type-surface-only` needs an any/unknown-to-concrete narrowing this is not; and `runtime-interface-only` explicitly inherits the same prescription refusal (#8299). The only truthful disposition left is `registered`. That is also the repo's settled convention for this exact shape -- a published TS symbol with no spec schema, no stored source and no tombstone, where the ledger is the only channel that reaches an upgrader. contracts.IDataDriver.findStream and actor-user-roles-to-positions are both registered on those grounds. D3 semantic, not a D2 conversion: the class has no spec schema, so there is no authorable key to tombstone and no stored sys_metadata row to rewrite -- a scanner was constructed per call and every result lived in a per-instance Map discarded with the object, so applyConversionsToStoredItem has no seam that would ever see one. This is what the ruling's "no metadata migration" excludes, and it is excluded. - add entries/semantic/18.plugin-security-scanner-retired.ts (one file, per the entries README kit -- no hand edit inside registry.ts's generated markers) - regenerate registry.ts via gen:migration-registry (157 semantic entries) - flip the core changeset's marker to `registered plugin-security-scanner-retired`, keeping the BREAKING banner, the no-replacement statement and the NOT MEASURED paragraph untouched - add the @objectstack/spec patch changeset, mirroring the #6138 backfill Measured and recorded in that changeset: the regeneration lap the entries README warns about did not materialise. check:generated reports all 15 artifacts up to date, and running gen:spec-changes and gen:upgrade-guide explicitly moved neither file -- a major-18 semantic entry is not yet projected into either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ionale into the body check-adr-0087-registration parses everything after `registered` as a comma/space-separated id list, so the trailing `why` prose that the `not-required (...)` forms accept was read as 131 nonexistent migration ids. The asymmetry is real and AGENTS.md spells it: `registered SOME-MIGRATION-ID` carries no `why`, the three `not-required` forms do. The rationale is unchanged, only relocated into the changeset body where a reader gets it anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
|
Contract review (clause ②) PASS at head Landing follows Implemented-by: Generated by Claude Code |
|
PM note on this PR's clause-② status — recorded so a cleared label is not later read as a review that happened. Measured just now, not inferred:
The one thing that should not be misread. This card was declared So the carrier now reads clear, but it reads clear because it was cleared — not because a review passed. If this PR lands from here, it lands on maintainer authority. That is the maintainer's call to make; this comment exists only so it is made knowingly, and so nobody reading the labels in a month concludes a review is on file. This seat has not armed this PR and will not arm it, consistent with the park recorded on the card. No action is requested. Two things still on offer, if wanted:
Generated by Claude Code |
|
Correction to the PM note above (5553093398) — a clause-② review at The open scope question the note repeats was ruled in that PASS (row 4): the Generated by Claude Code |
…ger serves `check:issue-citations` went red at 0a1bac8 with eight dangling sites across five files: issue 14919 at six of them and issue 8715 at two. Measured cause, from the gate's own `--probe-cause`: deleted, all eight — not transferred, not mistyped. Probed with a lit control beside each, since both dead numbers sit next to live ones: 8714 404 / 8715 404 / 8716 200, and 14918 404 / 14919 404 / 14920 200. Scattered pairs, not a contiguous band, which is what deletion-by-author looks like. Both were live references when the prose was written. ⛔ No number is guessed and none is swapped for a plausible neighbour. Each site keeps its number in prose and now says it no longer resolves, then names a record that DOES — verified by probe, not inferred: - issue 14919 -> PR #15930, `feat(core)!: retire PluginSecurityScanner`, merged 2026-09-05, whose body opens with a closing line naming that very issue number. Probe: 200. - issue 8715 -> #11825, the half of the pair this tree cites together that still resolves, and the same whole-def disposition shape. Probe: 200. The `#` sigil is what the gate judges (`CITATION_RE`); a bare number in prose is not a citation, so the number survives verbatim and the reference stops dangling. `migrations/registry.ts` is GENERATED and was NOT hand-edited: its three copies come from the two entry files, re-emitted by `gen:migration-registry`. The module docblock feeds a reference page, so `check:generated --fix` regenerated `content/docs/references/kernel/plugin-security-advanced.mdx` through `gen:docs`. ⛔ Nothing else moves: no schema, no key, no registry entry, no changeset level, no authorable row. Comments and the prose they generate, only. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…nsumers after the `PluginSecurityScanner` retirement (objectstack-ai#15932) (objectstack-ai#19610) Fixes objectstack-ai#15932 Clause-②: yes ADR-0049 enforce-or-remove. Executes the ruling on objectstack-ai#15932 (director seat, decision batch objectstack-ai#65, 2026-09-07, maintainer verbatim 「同意」). This is the second half of the `PluginSecurityScanner` retirement, whose live record is **PR objectstack-ai#15930**.⚠️ **Citation corrected:** the number that landing was filed under — 14919, written bare on purpose — **no longer resolves on the board** (404 at 2026-09-21T18:3xZ; lit control: the neighbouring 14920 returns 200), so the digits are kept greppable while the sigil is dropped, which is the same treatment this repo gave the dead `[objectstack-ai#14423]` **docblock citation** that PR objectstack-ai#19609 repaired in `packages/metadata/src/metadata-manager.ts`.⚠️ **Carrier corrected:** an earlier draft of this sentence called that precedent a `Blocked-by: objectstack-ai#14423` line. There is no such line — `Blocked-by:.*14423` returns **0** across `origin/main` (lit control: real `Blocked-by:` lines do exist, in five files and more), and objectstack-ai#19609's own diff removes `* [objectstack-ai#14423] The keyed plural read …` from a docblock. The precedent itself is real and reads verbatim *"the card it was filed under — issue 14423, written here without a leading hash because it no longer resolves"*; only its carrier was misnamed. ⛔ No replacement number is guessed: objectstack-ai#15930 is the PR whose patch did the work, ⛔ not a re-issued card. That change retired `PluginSecurityScanner`, and its type-only import was the scan-result family's only importer of any kind, so the schemas it fed went from one type-only importer to **zero consumers of any kind** while staying fully published. ## What is retired | member | route | | --- | --- | | `KernelSecurityScanResult` (def + 3 exports) | whole-def removal, `RETIRED_DEFS_BY_MAJOR[18]` | | `KernelSecurityVulnerability` (def + 3 exports) | whole-def removal, `RETIRED_DEFS_BY_MAJOR[18]` | | `PluginSecurityManifest.scanResults` | `retiredKey()` tombstone, `RETIRED_KEYS_BY_MAJOR[18]` | | `PluginSecurityManifest.vulnerabilities` | `retiredKey()` tombstone, `RETIRED_KEYS_BY_MAJOR[18]` — see **Scope** below | | `PluginQualityMetrics.securityScan` | `retiredKey()` tombstone, `RETIRED_KEYS_BY_MAJOR[18]` | Two routes because the two questions have different answers. Nothing parses the two **defs**, so there is no author a prescription could reach and a tombstone would be noise. The three **keys** sit on shapes that are not `.strict()`, where a bare deletion is a silent strip (ADR-0104) — so each becomes a `retiredKey()` tombstone, audible in both channels: `tsc` (input type `never`) and the parse, which raises the prescription itself. **No D2 conversion.** A plugin security manifest and a plugin registry entry are package artifacts a publisher ships — never stack collection members, never stored `sys_metadata` rows — so the conversion chain has no seam that would see one. That is the disposition the sibling `kernel-plugin-security-durations-unit-in-key` entry already records for this same manifest. The D3 semantic entry `plugin-security-scan-result-surface-retired` carries the judgement. ## Premise, re-measured first-hand on `origin/main` @ `236cec19a5` | reading | value | | --- | --- | | `KernelSecurityScanResult` / `KernelSecurityVulnerability` in `packages/**/*.ts` outside the declaring module | **0** | | LIT CONTROL — `PluginSecurityManifest` inside `plugin-security-advanced.zod.ts` | **5** ⇒ the file is greppable, so the zero is a reading | | `packages/core/src/security/security-scanner.ts` | **absent** ⇒ the `PluginSecurityScanner` retirement (PR objectstack-ai#15930) landed | | `PluginQualityMetrics.securityScan` in `packages/**/*.ts` | `plugin-registry.test.ts` only — the spec's own self-test | | objectui at the pinned sha `87af769e` — does it import any of this? | **0** hits; LIT CONTROL: `@objectstack/spec` is imported there ⇒ no sibling fix and no pin bump are owed | **The authorable-row count is 27, not the 22 the card carried.** Measured with the playbook's instrument on `authorable-surface/kernel.json`: 8 rows for `KernelSecurityScanResult`, 17 for `KernelSecurityVulnerability`, plus `PluginSecurityManifest:scanResults` and `PluginQualityMetrics:securityScan` — 28 counting the forced-consequence `PluginSecurityManifest:vulnerabilities`. The disagreement is reported, not reconciled: the 22 is superseded, and the FOLLOW-UPS row now says so. ## Scope — one key outside the four names, reported rather than absorbed `PluginSecurityManifest.vulnerabilities` is **not** one of the four names the ruling listed. It is a forced consequence: it was an array of `KernelSecurityVulnerability` and the last authorable referent of a def the ruling retires by name, so it cannot outlive that def, and keeping the def alive only to carry it would be keeping the retired family alive under a second name. It is not a neighbour retired by proximity — the fence's stated concern — and it is named here, in the registry entry, in the changeset and in the hand-back. ⛔ **The outstanding carve-out is ONE enum member wide, not three — and it is NOT recorded as checked.**⚠️ **Coordinates corrected.** The ruling made three carve-outs conditional on a producer grep of `objectstack-ai/cloud`. Two of the three no longer exist in this tree, so only one is still outstanding: | carve-out the ruling named | state at head `3e0a06d0b5` | |:--|:--| | marketplace `'scanning'` status, `marketplace.zod.ts` | **LIVE**, one hit in any `.zod.ts`, at `marketplace.zod.ts:348` — the sole outstanding carve-out | | `marketplace-admin.zod.ts` | **file absent from the tree**; that family's disposition was decided on objectstack-ai#16526 | | incident `'malware'` type, `incident-response.zod.ts` | **file absent from the tree** — the incident family was retired whole by objectstack-ai#15513, ruled **2026-09-05**, two days BEFORE the ruling that made `'malware'` conditional; `malware` returns **0** in any `.zod.ts` | Instrument controls, so the two zeros are readings rather than a dead grep: `marketplace*.zod.ts` on the same `find` returns `marketplace.zod.ts`, and `malware` on the same grep returns 7 non-`.zod.ts` files (ADRs, design docs, records). An earlier draft of this body named all three files in the present tense; that was wrong and is retracted here. `objectstack-ai/cloud` is not reachable from this session, so the producer question for `'scanning'` is genuinely **NOT MEASURED**. ⛔ The absence of these names from this diff is not evidence about them. ## Breaking, for a population that is not measured `@objectstack/spec` is published, so removing six exports and three authorable keys is breaking for consumers no download, dependent or source telemetry was consulted for — exactly as that retirement's own changeset (PR objectstack-ai#15930) says of its own three exports. That was an input to the ruling, not a reason to soften the removal. No deprecation window (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). The release note is written centrally; `content/docs/releases/` is untouched.⚠️ **Runtime behaviour is deliberately unchanged.** Nothing ever read any of these keys, so deleting one removes no check that was running. A consumer that gated on `securityScan.passed === true` was gating on nothing. ##⚠️ Changeset level — the ruling says `major`, a live gate refuses it The ruling and the dispatch both say **`major`**. `scripts/check-changeset-no-major.mjs` hard-refuses a `major` bump for the duration of the launch window (every publishable package is in one Changesets `fixed` group, so one `major` promotes ~70 packages), and the retirement playbook was corrected to say so in objectstack-ai#19446, which is on `main`. A `major` changeset here is a guaranteed-red PR that cannot land. This PR therefore ships **`minor` + a BREAKING banner carrying the FROM → TO mapping and the one-line fix** — the carrier the window designates for breaking-ness — plus the ADR-0087 disposition marker. `check-changeset-no-major.mjs` and `check-adr-0087-registration.mjs` are both green on it. **This is flagged, not silently chosen:** if the seat wants the literal `major`, that is a decision about the launch-window guard, not about this diff. ## Verification | check | result | | --- | --- | | `pnpm --filter @objectstack/spec build` | **pass** (after the two deletion gates fired and were answered, below) | | `pnpm --filter @objectstack/spec test` | **pass** — 510 files, 14897 passed, 1 todo | | `pnpm --filter @objectstack/spec typecheck` | see the hand-back | | `pnpm --filter @objectstack/spec check:generated` | **pass** — 15 artefacts; 5 were stale and were regenerated by `--fix`, never hand-edited | | `check-adr-0087-registration.mjs` | **pass** — 1 declared-breaking changeset, disposition `registered plugin-security-scan-result-surface-retired` | | `check-changeset-no-major.mjs` | **pass** — no `major` bump introduced | **Two gates fired on the way, and both were answered rather than routed around.** The json-schema manifest deletion gate refused the two vanished defs until their keys left `json-schema.manifest/kernel.json` *and* each was declared in `RETIRED_DEFS_BY_MAJOR`; the authorable-surface deletion gate then refused the 25 orphaned key rows until they left `authorable-surface/kernel.json` in the same commit. That sequence is the removal's own evidence and is why the ratchets moved. ⛔ `authorable-surface.base.json` was not touched. **Reverse verification — the refusal pin can fail.** The `scanResults` tombstone was ablated to `z.array(z.unknown()).optional()` with `scripts/ablation-replace.mjs`, which proved the mutation on disk (anchor 1 → 0, blob `0f3af37f5068` → `969efcdaa46a`) before running anything. Result: exactly one test failed — the `scanResults` refusal pin — and the other four passed. The restore leg verified blob == HEAD and `git diff HEAD` empty. ## Acceptance notes Noted, not filed — observed while executing, outside this card's scope, and no in-flight PR or person is known to be heading for these files: - `packages/spec/src/kernel/plugin-security.zod.ts` declares a **parallel, unprefixed** scan-result family — `SecurityVulnerabilitySchema` and `SecurityScanResultSchema`, near-duplicates of the pair retired here, with their own self-test in `plugin-security.test.ts`. It is outside the four names and is deliberately untouched; the pin test asserts both are still exported, so the fence is machine-checked rather than described. Whether it is live is a separate census this card did not take. - `plugin-security-advanced.test.ts`, the declaring module's own self-test, contained **zero** references to the scan-result family. The premise called `plugin-security.test.ts` the family's self-test; it is in fact the *other* family's. The retired family had no self-test at all — a reading slightly stronger than the card's. PR body maintained by the `domain:spec` execution seat, session `session_01UDXER3sdqfeVYpEWZs5mZx`; the diff is the dev's. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…on entries states each lesson in words, not tracker numbers (stage 2) (objectstack-ai#20324) Part of objectstack-ai#20233 Clause-②: no **Stage 2 of a staged card.** The card stays open for later stages; this PR carries no closing keyword. Text only: no entry id, `from` / `to`, conversion or matching logic moves, and the chain rewrites exactly what it rewrote before. ## What this does `os migrate meta` prints every ADR-0087 semantic entry it crosses as one block: `⚠ [protocol N] SURFACE → REPLACEMENT`, then `why:` (the entry's `reason`) and `verify:` (its `acceptanceCriteria`). AGENTS.md's runtime-string rule applies to all of it: 「Runtime strings — refusal prose, prescriptions, anything an author is shown — carry no tracker number (`pnpm check:doc-authoring`): the lesson goes into the text.」 Form **D** of ruling C+D on the parent card sets the shape: the lesson in words, and no number, dead or alive. This stage covers the next two families by site count, `ui-` and `plugin-`: **111 sites → 0** in the three prose fields, plus the `surface` field of the two entries that carried an id there (ruling A in the stage-1 ACCEPT, `5858839916`). Each site now says what the cited ruling, measurement or fix decided. ADR ids stay. `registry.ts`, `spec-changes.json` and `docs/protocol-upgrade-guide.md` are regenerated from the entries (`gen:migration-registry`, `gen:spec-changes`, `gen:upgrade-guide`), never hand-edited. The stage-1 pin is widened to hold `engine-`, `ui-` and `plugin-`. ## Census — tracker ids in the author-shown fields **Instrument.** The stage-1 instrument, re-implemented: a TypeScript-AST walk over every `packages/spec/src/migrations/entries/**/*.ts`. For each `entry` object literal it evaluates the string value of `replacement`, `reason`, `acceptanceCriteria` and (counted separately) `surface`, joining string literals with `+`, then counts `#` followed by 4 or 5 digits at a word boundary. **Tree:** `objectstack-ai/objectstack` at `2aa25efb4e` (this branch's base, the stage-1 merge). Unevaluable fields: 0. **Controls, same run.** - **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites (replacement 1, reason 6), the same reading stage 1 took. - **Dark (comment lines):** 734 `//` lines in entry files carry a tracker id, and none is counted — for example `18.client-envelope-convergence-analytics-automation.ts` has 5 such lines and counts 0. Comment lines belong to the sibling card, and ⛔ this PR touches none (the count is 734 before and after). - **Dark (field boundary):** `17.authoring-schemas-strict-unknown-keys.ts` carries one id in `surface`; it counts 0 in the three-field total and 1 in the `surface` column. **Re-measured on the base, matching the stage-1 census:** `ui-` 17 entries, **65** sites (replacement 6 / reason 58 / acceptanceCriteria 1), 42 distinct ids; `plugin-` 11 entries, **46** sites (1 / 39 / 6), 31 distinct ids. `surface`: 1 site each. `engine-`: 0 (stage 1). Whole tree: 267 entries, **950** sites, 9 `surface` sites. **After this PR:** `ui-` 0, `plugin-` 0, `engine-` 0; whole tree **950 → 839** sites and `surface` **9 → 7**. The next family by site count is `driver-` / `kernel-` / `system-` (44 each). | entry | sites (replacement / reason / acceptanceCriteria) | `surface` | |---|---|---:| | `17.plugin-activation-events-retired` | 5 (0 / 4 / 1) | 0 | | `18.plugin-auto-restart-never-reinitialised` | 11 (0 / 7 / 4) | 0 | | `18.plugin-manifest-contributes-dead-members-retired` | 3 (0 / 2 / 1) | 0 | | `18.plugin-manifest-contributes-routes-retired` | 6 (1 / 5 / 0) | 1 | | `18.plugin-manifest-dead-containers-retired` | 3 (0 / 3 / 0) | 0 | | `18.plugin-manifest-kind-globs-retired` | 2 (0 / 2 / 0) | 0 | | `17.plugin-manifest-loading-retired` | 2 (0 / 2 / 0) | 0 | | `17.plugin-runtime-family-retired` | 5 (0 / 5 / 0) | 0 | | `18.plugin-security-scan-result-surface-retired` | 6 (0 / 6 / 0) | 0 | | `18.plugin-security-scanner-retired` | 3 (0 / 3 / 0) | 0 | | `18.ui-cloud-connection-widgets-unknown-keys-refused` | 3 (0 / 3 / 0) | 0 | | `18.ui-form-field-length-malformed-refused` | 8 (2 / 6 / 0) | 0 | | `18.ui-form-field-precision-scale-integer-refused` | 4 (1 / 3 / 0) | 0 | | `18.ui-form-view-predicate-features-root-refused` | 2 (0 / 2 / 0) | 0 | | `17.ui-interaction-config-family-retired` | 7 (1 / 6 / 0) | 0 | | `18.ui-list-view-groupbyfield-padded-refused` | 1 (0 / 1 / 0) | 0 | | `18.ui-list-view-grouping-field-padded-refused` | 2 (0 / 2 / 0) | 0 | | `18.ui-mcp-connect-agent-unknown-keys-refused` | 5 (0 / 5 / 0) | 0 | | `17.ui-notification-action-embed-config-retired` | 8 (0 / 8 / 0) | 0 | | `18.ui-object-grid-page-size-positive-integer-refused` | 4 (0 / 4 / 0) | 0 | | `18.ui-react-list-view-binding-aliases-retired` | 2 (0 / 2 / 0) | 1 | | `18.ui-record-blocks-unknown-keys-refused` | 3 (0 / 3 / 0) | 0 | | `18.ui-reference-rail-unknown-keys-refused` | 2 (0 / 2 / 0) | 0 | | `17.ui-widget-i18n-family-retired` | 14 (2 / 11 / 1) | 0 | | four entries with no site: `plugin-version-semver-2-0-0`, `ui-action-undoable-unfulfillable-refused`, `ui-bulk-action-param-unknown-keys-refused`, `ui-report-joined-container-selection-refused` | 0 | 0 | | **total, 28 entries** | **111 (7 / 97 / 7)** | **2** | ## Every citation read, and what the text now says I read each cited issue or PR myself with single-card REST reads: the body, and the comments where a ruling or a measurement lives. Ids are in code spans so this body posts no cross-references. `objectui#N` ids were read from `objectstack-ai/objectui`; bare ids from this repository. | cited | what it decided (read) | how the text now carries it | |---|---|---| | `objectstack-ai#3733` | The pruned `cached` field key: the parse succeeded and the removed key was dropped without a word; the orphan schema was deleted. | "an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word" | | `objectstack-ai#3950` | Removed the plugin sandboxing / integrity / approval config nothing read: an exported schema with no consumer is read as a capability. | "the lesson of the unwired plugin sandboxing / integrity / approval config …: an exported schema with no consumer is read as a capability", and the plugin-runtime "earlier removal of this module's discovery/sandbox config island" | | `objectstack-ai#4001` | Maintainer, 2026-08-03: every authorable surface refuses an unknown key (strict), in the v17 window, measured file by file for an authoring door. | "the component-props unknown-key gate (an authorable surface refuses a key it does not declare …)"; "the v17 unknown-key strictness sweep (its ui/ batch 14)"; "the batch of the v17 unknown-key strictness sweep that measured this file as having no authoring door" | | `objectstack-ai#4115` | Ruling A: an objectui symbol named like a spec export must import it, or take a name of its own (or an allowlist row), enforced by a CI guard. | "renamed off the spec's names under objectui's rule that a symbol named like a spec export must import it or take a name of its own" | | `objectstack-ai#4484` | `findStream` removed with no tombstone: a TS/API surface nothing parses, so tsc at the call site carries the ban. | "`contracts.IDataDriver.findStream` (removed with no tombstone, because nothing parses a driver object)" | | `objectstack-ai#4535` | The dual-source cleanup: 52 names declared twice across entry points, taken to 0. | "the dual-source cleanup removed the `./ui` copies …" | | `objectstack-ai#4583` | The datasource capability flags were dead; `readOnly` was precisely validated and inert, and the CRM example called a datasource a read replica while writes went through. The strictness ledger records the "more convincing lie" lesson there. | "(the lesson of the datasource capability flags: `readOnly` was precisely validated and read by nothing, while a shipped example called a datasource a read replica and wrote through it)" | | `objectstack-ai#4610` | Removed the `./ui` `Notification` / `NotificationConfig` copies (zero import sites measured); the `./api` inbox row stayed live. | "the dual-source cleanup removed the `./ui` copies of `NotificationSchema` / `NotificationConfigSchema` (the same names declared differently on other entry points)" | | `objectstack-ai#4653` | Maintainer ruling A, 2026-08-02: converge `activationEvents` on the kernel's structured `{ type, pattern }` shape, re-exported from studio. | "once the kernel and studio copies had converged on the kernel's structured `{ type, pattern }` shape" | | `objectstack-ai#4657` | Retire both `activationEvents` keys (REMOVE, v17 window): kernel tombstone, studio strict refusal, the orphan schema deleted. | "Both keys took ADR-0049's REMOVE answer, not ENFORCE, while protocol 17 was still unreleased"; its id sentence in plugin-runtime is covered by the entry id `plugin-activation-events-retired` | | `objectstack-ai#4834` | Maintainer, 2026-08-03: REMOVE the rest of the plugin-runtime family; hot loading returns with its implementation, if ever. | "The maintainer's ruling of 2026-08-03 is that decision, answered REMOVE: …"; "the maintainer's REMOVE ruling on the rest of the plugin-runtime family"; `plugin-runtime-family-retired` by id elsewhere | | `objectstack-ai#4875` | The health-check timeout guard is cleared when the race settles, and deliberately not `unref`'d (an unref'd guard can swallow the timeout). | "its guard timer (kept ref'd while the race is undecided, cleared the moment it settles)" | | `objectstack-ai#4910` | Inbound rate limiting was built from a new seam: a `server:` key that carries only the keys its executor consumes. | "the way inbound rate limiting came back, as a new key carrying only what its executor consumes" | | `objectstack-ai#4914` | Maintainer, 2026-08-04: REMOVE `manifest.loading`, with a hard precondition of a clean cloud and objectui bare-name sweep. | "the maintainer ruled REMOVE on 2026-08-04, on condition that a bare-name sweep of cloud and objectui came back clean first" | | `objectstack-ai#4938` | Maintainer, 2026-08-04: retire `HttpServerConfig`'s seven unreachable keys with their container. | "the `HttpServerConfig` retirement (seven keys no runtime read and no authoring door reached, retired with their container)" | | `objectstack-ai#4988` | Maintainer, 2026-08-04: retire the five ui interaction files; touch, dnd, keyboard and motion are renderer built-in behaviour, offline belongs to a sync engine. | "The 2026-08-04 ruling retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in behaviour and offline belongs to a sync engine …"; `ui-interaction-config-family-retired` by id in the widget entry | | `objectstack-ai#5015` | REMOVE `NotificationActionSchema` / `EmbedConfigSchema`, 2026-08-04: a no-door dead surface retires implementation-first, as three same-shape rulings that week had decided. | "left the disposition to ADR-0049's enforce-or-remove, which came back REMOVE on 2026-08-04: a dead surface with no authoring door retires implementation-first, …" | | `objectstack-ai#5021` | Maintainer, 2026-08-04: retire all nine unconsumed theme token groups; theme-driven typography is not near-term. | "the theme-token retirement (theme-driven typography is not a near-term capability, so nine token groups nothing consumed were retired)" | | `objectstack-ai#5040` | Build the declarative ApiEndpoint executor in 17.x; the v17 loud refusal of a non-empty `apis:` becomes execution. | "live since protocol 17, once the declarative endpoint executor was built and the loud refusal of a non-empty `apis:` became execution" | | `objectstack-ai#5055` | Maintainer, 2026-08-06: retire the doorless widget and i18n shapes (8 of 9 widget sites; `FieldWidgetProps` kept). | already stated by the entry ("The 2026-08-06 ruling weighed …"); the trailing id is dropped | | `objectstack-ai#5068` | Maintainer, 2026-08-05, direction A: parse a component's `properties` against its `ComponentPropsMap` row by `type`, at publish and lint; a type with no row is skipped because the type union is open. | "the props gate's dispatch (it parses `properties` against the type's row at publish and lint time, and skips a type with no row because the type union is open)" | | `objectstack-ai#5781` | Correction: objectui did re-export the `./ui` notification names; the removal stands. | "falsified for objectui, which re-exported both names, … the removal itself stands" | | `objectstack-ai#6011` | Maintainer: close the `ctx.user` `roles` alias now, no window. | "`actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window)" | | `objectstack-ai#7751` | Maintainer, 2026-08-12, direction A: the `object-*` blocks get `ComponentPropsMap` rows, key sets from renderer read points. | "the shape it was given when the `object-*` blocks first got `ComponentPropsMap` rows measured from their read points" | | `objectstack-ai#8321` | `Field.scale` / `precision` refuse non-integer and negative values (`int().min(0)`). | "(that surface tightened first, to a non-negative integer)"; "after that surface converged on `z.number().int().min(0)`" | | `objectstack-ai#8691` | A strict `record:reference_rail` row, key set from the renderer's read points. | "the class already closed for `record:reference_rail` …"; "after the rail was given its strict row" | | `objectstack-ai#8744` | Strict rows for `record:alert` / `record:quick_actions` / `record:history`. | "… and then for `record:alert` / `record:quick_actions` / `record:history`, each by declaring a strict `ComponentPropsMap` row" | | `objectstack-ai#11168` | The kind-registration log names the declared `id`; the `kind` bucket is reachable through `GET /metadata/:type`; `globs` had zero readers. | "Measured by the engine-lane fix that made kind registration log its declared `id` (which also found the `kind` bucket itself reachable …)" | | `objectstack-ai#11169` | Maintainer, 2026-08-24 (「接受你的建议。」): remove `globs` through the full ADR-0049 ceremony. | "maintainer ruling 2026-08-24 (「接受你的建议。」) …: remove, through the full ADR-0049 ceremony" | | `objectstack-ai#11327` | The doc-correction half of the routes ruling (2026-08-22, 「接受所有」, Option B): four author-facing sites redirected to the imperative `http.server` mount. | "the author-facing corrections landed FIRST (the skill's decision table, the dispatcher protocol doc, ADR-0088:40 and app.mdx, each redirected to the imperative mount)"; the ruling sentence states Option B's content | | `objectstack-ai#11566` | Maintainer, 2026-08-24: `maxLength` → `z.number().int().min(1)`. | "`maxLength` by the maintainer's 2026-08-24 ruling"; "tightened first, to a positive integer, by maintainer rulings" | | `objectstack-ai#11575` | Strict, empty rows for `cloud-connection:panel` / `marketplace:installed-list`. | "the strict, empty `cloud-connection:panel` / `marketplace:installed-list` rows closed the previous two" | | `objectstack-ai#11825` | Maintainer, 2026-08-25: retire the declarative `AdvancedPluginLifecycleConfig` container; the classes stay a host-driven library. | "which is why the maintainer retired its declarative config container on 2026-08-25 and kept the classes as a host-driven library"; "The maintainer's 2026-08-25 keep of the host-driven library still stands" | | `objectstack-ai#11852` | Both failure routes (returned, thrown or timed out) funnel into one failure step: one counter, one threshold comparison. | "the two failure routes — a returned failure and a thrown or timed-out check — sharing one failure counter and one threshold comparison" | | `objectstack-ai#11949` | Maintainer, 2026-08-25, option B: `minLength` → `int().min(1)`, zero refused. | "`minLength` by the 2026-08-25 one, which refused zero too" | | `objectstack-ai#11955` | `successThreshold` binds from every status that records a failure. | "The fix that made `successThreshold` bind from every status that records a failure made that MORE convincing" | | `objectstack-ai#12174` | The form-field row keys are live, so they were shape-tightened in place on the object-field templates. | "The form-field row still carried the object field's old shape … The row keys are LIVE … The schema now refuses …" (unchanged tail) | | `objectstack-ai#12269` | Closure A: `packages/mcp` gets its own canonical-envelope gate. | "door 3 of the canonical-envelope gate `@objectstack/mcp` was given for its shipped page" | | `objectstack-ai#12340` | Maintainer, 2026-08-26: retire the `'disk'` / `'distributed'` state strategies (silent memory fallbacks) and `distributedConfig`; "a vocabulary of nothing is not a vocabulary". | "the `'disk'` / `'distributed'` state strategies that fell back to memory in silence"; "(ruled 2026-08-26: a vocabulary of nothing is not a vocabulary)" | | `objectstack-ai#12400` | The cloud leg for `capabilities` / `configuration` / `extensions` measured clean at cloud `15f55df`. | "dispatched once the cloud half of the census below came back clean" (the census clause names `15f55df`) | | `objectstack-ai#12428` | Refuse/retire: `startWatching` throws instead of logging success; `watchPatterns` is tombstoned because a key leaving a surviving def has no route-3 exit. | "the file-watching placeholder whose `startWatching` logged success while watching nothing"; "for the reason the file-watching retirement recorded" | | `objectstack-ai#12665` | Implement the maintainer's 2026-08-27 option B: a form view may not name `features.*` in a predicate; refused at authoring. | "Ruled by the maintainer on 2026-08-27 (option B — vocabulary narrowing: a form view may not name `features.*` in a predicate, and the authoring door refuses it loudly)" | | `objectstack-ai#14791` | Maintainer, 2026-09-07: retire the `objectName` / `viewType` aliases now, no window. | "(2026-09-07)" in the sentence that states the retirement | | `objectstack-ai#15513` | Maintainer, 2026-09-05: retire the incident-response, training and change-management families whole; not roadmapped. | "retired whole, with the training and change-management families (maintainer ruling 2026-09-05: not roadmapped, so retired rather than marked experimental …)" | | `objectstack-ai#15930` | Retired `PluginSecurityScanner`; no replacement, repair refused. | "the scanner retirement recorded as plugin-security-scanner-retired. That retirement removed PluginSecurityScanner …" | | `objectstack-ai#15932` | Maintainer, 2026-09-07 (「同意」): retire the scan-result family and `securityScan`. | "maintainer ruling 2026-09-07 (adopted verbatim 「同意」): retire the scan-result family and its securityScan sibling, because once the scanner was gone nothing so much as imported their types" | | `objectstack-ai#16526` | Maintainer, 2026-09-07, option A: cloud does not re-host the consumer-less control-plane files; they are deleted. | "(ruled 2026-09-07: cloud does not re-host the control-plane files it never consumed)" | | `objectstack-ai#17360` | Ruling C: refuse a padded grouping field name at the producer (not a trim). | "Ruled by the maintainer on 2026-09-10 (「其他同意」): refuse at the producer." | | `objectstack-ai#17499` | Refuse a padded `groupByField` on kanban, gantt and timeline. | "The same padded-name defect the grouping-level narrowing … refused, on the axis that one scoped out by name, and given the same refusal." | | `objectstack-ai#19046` | Bound the grid component arm's page sizes to positive integers. | the sentence now opens "This door still carried the shape …"; the declaration half is stated in the entry | | `objectui#3161` | Batch 7/8 of the objectui burn-down under the `objectstack-ai#4115` rule (renames). | folded into the `objectstack-ai#4115` sentence | | `objectui#3169` | objectui stopped declaring symbols under names the spec owns; its rename tripwire fails both ways. | "the rename tripwire objectui added when it stopped declaring symbols under names the spec owns" | | `objectui#3289` (PR) | `@object-ui/fields`' validation slot renamed onto the spec's `error` and connected to its producer. | "an objectui fix of 2026-08-03, made to follow the spec, renamed …" | | `objectui#5595` | The console FormPage honours the form's own `maxLength` override. | "(fixed so that a form's own bound wins over the object's, as its docstring promised)" | | `objectui#5898` | The form-view bridge maps every spec key or explains why not. | "mapField, which maps every spec key or explains why it does not" | | `objectui#6262` | The measured `features.*` asymmetry, and the ruling record. | folded into the `objectstack-ai#12665` sentence | | `objectui#7347` | The measured padded-grouping failure, and ruling C's record. | folded into the `objectstack-ai#17360` sentence | | `objectui#9853` | Measured `pagination.pageSize: 0` reaching `ObjectGrid`. | "an objectui grid measurement found that …" | | `objectui#9896` (PR) | A non-positive `pageSize` is refused at all three grid read points. | "objectui's grid plugin repaired the consumer half — it now refuses a non-positive page size at all three read points …" | The eight dead ids, and the two ids whose page does not say what the text claimed (`objectstack-ai#2561`, `objectstack-ai#3896`), are in **Acceptance notes**. No call-shaped token moves: a `name(` census over `registry.ts` is identical before and after, so textual call-spelling ratchets read the same. ## Pin — `packages/cli/test/migrate-meta-engine-guidance.test.ts`, widened The stage-1 pin now selects every entry whose id starts with a covered prefix: `engine-`, `ui-` or `plugin-`. It spawns the real CLI (`os migrate meta --from 16 --to 18`) once, locates each covered block **verbatim** in stdout, and asserts the printed block carries no `#` plus 4 or 5 digits. That block includes `surface`. Anti-vacuity: - the derived set must contain all 29 rewritten entries (5 `engine-`, 10 `plugin-`, 14 `ui-`), and every covered prefix must select at least one entry; - presence in stdout is asserted before cleanliness; - the detector is exercised on both sides first (lit on 4 and 5 digits, dark on 3, 6 and `ADR-0112`). The chain reports every semantic entry of every crossed hop, whatever the stack authors (`applyMetaMigrations` maps `step.semantic` straight to TODOs), so the fixture is kept as-is and the header now says so. The file keeps its stage-1 name; a rename is left to the stage that covers the last family. An entry added later to a covered family is held on arrival — see Acceptance notes for the one known in-flight case. ## Ablation — the widened pin can fail on a `ui-` block and on `surface` From committed state, HEAD `8a1076b0a6`, with `scripts/ablation-replace.mjs` in wrap mode and `scripts/ablation-dist-preflight.mjs` gating each leg. The bundle is built from the generated `registry.ts`, so that is the file mutated (stage 1's attempt 2 records why the entry file is the wrong target). - **Mutation.** In `registry.ts`, the `surface` of `ui-react-list-view-binding-aliases-retired`: anchor `(the react-tier overlay aliases published as deprecated` → `(the react-tier overlay aliases objectstack-ai#11284 published as deprecated`. The tool read anchor 1 → 0 and replacement 0 → 1, blob `ab26922f` → `9a200491`. - **Mutate leg.** Spec build under the lock: command-exit 0. Preflight: marker present in 4 built files. Pin: **red**, `1 failed | 2 passed` — `ui-react-list-view-binding-aliases-retired: the printed guidance cites a tracker id: expected 'objectstack-ai#11284' to be undefined`. - **Restore.** Tool-proven: blob `ab26922f` == HEAD, `git diff HEAD` empty. - **Restore leg.** Spec build under the lock: command-exit 0. The `--absent` preflight found the marker in none of 222 built files, with the working tree clean against HEAD. Pin: **green**, `3 passed`. ## Verification Final head **`071dc7fc1d`** unless a line says otherwise. - **Pin and its neighbour:** `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/migrate-meta-engine-guidance.test.ts test/migrate-meta-default-range.test.ts` gives `Test Files 2 passed`, `Tests 10 passed | 1 skipped` (the skip is the default-range file's own pre-existing `skipIf`). - **Spec tests that read these entries or the registry:** `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/migrations` plus the 14 other spec test files that read `MIGRATIONS_BY_MAJOR` or one of these entries' text (`plugin-runtime-tier-truthful-text`, `interaction-config-retirement`, `widget-i18n-retirement`, …) and `scripts/build-schemas-check-mode.test.ts`: `Test Files 15 passed`, `Tests 438 passed`. -⚠️ The first `src/migrations` run, at `8a1076b0a6`, went **red, 2 failed**: `migrations.test.ts` pinned the two tracker numbers in `ui-notification-action-embed-config-retired`'s `reason`. The second commit re-pins the same guard on the sentences that now carry the lesson (see Acceptance notes). - **CLI unit:** `src/utils/spec-release-changes.test.ts` 6 passed; `test/vitest-tiers-partition.test.ts` 22 passed (at `8a1076b0a6`; no CLI file changed after it). - **Call-spelling census that reads `registry.ts`:** `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 src/sql-driver-query-signature.test.ts` gives 15 passed. - **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exits 0. `pnpm --filter @objectstack/cli typecheck` exits 0 (at `8a1076b0a6`), and its test layer holds the recorded 3 files / 28 errors, unchanged. - **Build:** `pnpm exec turbo run build --filter="@objectstack/cli^..." --concurrency=2` gives 55/55 (at `8a1076b0a6`); `@objectstack/spec` rebuilt at the final head, command-exit 0. - **Gate families:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives **88** families at `071dc7fc1d` (the same set as at `8a1076b0a6`). `--ran` over the recorded exit codes reads **88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN**, all exit 0. They include `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:generated` (15 artifacts current), `check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`, `check:adr-0087-registration`, `check:changeset-no-major` and `check:dual-build-cjs-loads` (104 require entry points across 66 packages load). - Union discipline: the first pass started before the second commit, so the 21 families that started before that edit were re-run at the final head, and the 6 that refused on a spec `dist` stamp made stale by that edit (5 × exit 3, `check:generated` exit 1 naming `api-surface` stale by stamp, `check:dual-build-cjs-loads` exit 3) were re-run after rebuilding spec at the final head. The reconciled list takes each family's latest run. - **Lint (a proven narrowing, not the repo-wide run, which is CI's):** `eslint --no-inline-config --format json` over the 27 changed `.ts` files reports 27 files, 0 errors, 0 warnings. - The population is read from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 27 are in it (no file-ignored warning). - Invariance: the config enables no type-aware linting (no `parserOptions.project`, no typed rules), so a text edit cannot move the verdict on a file it does not touch. - **Mergeability:** a driver-free bare clone's `merge-tree --write-tree` of this head against `origin/main` `7b1e4a4871` (six commits past the base, one of them adding 25 semantic entries) exits 0 with no conflict. The census over that merged tree reads `engine-` 0, `plugin-` 0 and `ui-` 0 across 18 entries: main's new `ui-report-joined-chart-retired` carries tracker ids only in comment lines. ## Acceptance notes - **Dead ids, rewritten from the code on `main`.** Eight cited numbers answer 404 on both the issues and the pulls endpoint, re-probed with a 200 control (`objectstack-ai#11327`): `objectstack-ai#10627`, `objectstack-ai#10724`, `objectstack-ai#10726`, `objectstack-ai#10812`, `objectstack-ai#11284`, `objectstack-ai#11328`, `objectstack-ai#11332`, `objectstack-ai#14919`. Each sentence was rewritten from what `main` records, and anything it could not confirm was dropped: - `objectstack-ai#10627` / `objectstack-ai#10724` / `objectstack-ai#10726` / `objectstack-ai#10812` / `objectstack-ai#11328` (contributes routes and dead members): `packages/spec/src/kernel/manifest.zod.ts` carries all ten `retiredKey()` tombstones and the census comment; `packages/objectql/src/engine.ts` (`manifest.contributes?.kinds`) is re-measured as the only non-spec read; `plugin-rest-api.zod.ts` and `metadata-plugin.zod.ts` point at the imperative `http.server` mount. Dropped: "triage graded 2026-08-21" and the 2026-08-24 date on the routes entry's cloud sentence (the cloud sha `5b5925a` is kept: `objectstack-ai#12400`'s body corroborates it). The routes ruling's 「接受所有」 and Option B are kept: `objectstack-ai#11327`'s body records them. - `objectstack-ai#11332` (dead containers): the three `manifest.capabilities` / `configuration` / `extensions` tombstones on `main`. Dropped: "triage graded 2026-08-23". - `objectstack-ai#11284` (react-tier convergence): `packages/spec/src/ui/react-blocks.ts` records the 2026-08-23 maintainer ruling that the react tier converges on the metadata-tier vocabulary, deprecating first. - `objectstack-ai#14919` (scanner): `packages/core/src/security/index.ts`'s tombstone and `security-scanner-retirement.pin.test.ts` record the 2026-09-05 ruling, the removed class and types, and repair refused. Dropped: "ruled A: retire in three surfaces" and the batch numbers, which `main` does not state. - **A bare id that names the wrong card.** The widget / interaction entries' "(`objectstack-ai#2561`)" resolves here to an unrelated security-lifecycle umbrella. The claim ("objectui holds TYPE re-exports … never validators, and says so") is objectui's own decision on `objectui#2561`: keep the `@objectstack/spec/ui` re-exports type-only. It was rewritten from objectui's `packages/types/src/__tests__/p2-spec-exports.test.ts` at objectui `main`, which records that decision. - **`objectstack-ai#3896`, cited as "follow-up" and "close-out".** `objectstack-ai#3896` itself is the sharing-rule `criteria` REST bypass and says neither. The "follow-up" is PR `objectstack-ai#3950` (its title says so); the "close-out" is the inert-key sweep recorded on `main` in `docs/protocol-upgrade-guide.md` and the strictness ledger. Both sentences now say what those decided. - **Cross-repo ids.** Ten sites are spelled `objectui#N` (nine) or "objectui PR " plus a number (one). The stage-1 census counted them by number with the rest; they were read from `objectstack-ai/objectui` (all 200), not from this repository, where the same numbers name unrelated cards. - **One bare-number spelling went too.** The scan-result entry spelled a deleted card as "issue" plus its number, twice, without `#`. The instrument cannot see it, but it is a tracker number shown to the author, and it sat in a rewritten sentence. - **In-flight entry the widened pin will hold.** Open PR objectstack-ai#20262 adds `18.ui-form-layout-inline-grid-retired.ts` with one tracker id in its entry text (read from the PR's file list: one `#` plus five digits). It is not on `main`, so it is untouched here. Once this lands, `ui-` is covered: objectstack-ai#20262 must rewrite that site before it lands, or the pin goes red on its merge ref. - **Comment lines are untouched.** `18.ui-list-view-groupbyfield-padded-refused.ts` keeps its `//` comment citing a number; comment and docblock lines are the sibling card's surface. - **Generated projections** (`spec-changes.json`, `docs/protocol-upgrade-guide.md`) are regenerated, as in stage 1; their `--check` legs are green. - **One file beyond the claim's surface: `packages/spec/src/migrations/migrations.test.ts`.** Its guard on `ui-notification-action-embed-config-retired` (the entry must keep explaining its orphaning and name the objectui correction) matched the two tracker numbers by regex, so the rewrite turned it red. The same two assertions now match the sentences that carry the lesson (`dual-source cleanup removed the ./ui copies`, `objectui, which re-exported both names`); the negative assertion beside them is unchanged. No other test pins a covered entry's text. A `git grep` of test files for the 24 ids finds four besides the pin and this one: three (`plugin-runtime-tier-truthful-text`, `interaction-config-retirement`, `widget-i18n-retirement`) were run above and pass, and the fourth (`packages/core`'s `granted-permissions-not-enforced.pin.test.ts`) names the loading entry's file only inside a failure message. ## Line budget Entry files: **333 changed lines** (+210 / −123) across 24 files, against the stage-1 ≈400 budget. The whole diff is **830 lines** (+533 / −297) in 30 files. Of the rest, `registry.ts` is 333, the two projections are 52 (`spec-changes.json` 32, the upgrade guide 20), the widened pin is 85, `migrations.test.ts` is 6 and the changeset is 21. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #14919
Retires
PluginSecurityScannerfrom@objectstack/coreunder ADR-0049enforce-or-remove, across all four surfaces the ruling names, plus the ADR-0087
ledger entry the disposition turned out to owe.
Why removal beat repair
The class was a shell that reported success.
scan()composed five privatescanners:
scanCode,scanMalware,scanLicenses,scanConfigurationeach allocatedan empty issue array, logged, and returned it with no code in between. None
could report a finding for any input.
scanDependenciesran a real loop, but matched only againstvulnerabilityDb,an in-memory Map whose sole writer was the public
addVulnerability— whichhad zero callers in this repo, in objectui at the pinned sha, and in the
example itself.
updateVulnerabilityDatabase()logged twice and fetchednothing.
So the database was empty on every code path that has ever executed, no issue
was ever produced, the score stayed 100, and the result was
status: 'passed'for every plugin the scanner was ever handed — a malicious one included. A
security control that cannot fail is worse than no control, because callers rely
on it. Repair was refused by name: a real vulnerability scanner is a feature with
a design surface, not a defect fix. There is no replacement export.
What changed
packages/core/examples/phase2-integration.tspackages/core/src/security/security-scanner.tspackages/core/src/security/index.tspackages/core/PHASE2_IMPLEMENTATION.mddocs/qa/platform-checklist/FOLLOW-UPS.mdpackages/core/src/security/security-scanner-retirement.pin.test.tspackages/spec/src/migrations/entries/semantic/18.plugin-security-scanner-retired.tspackages/spec/src/migrations/registry.tsgen:migration-registry(157 semantic entries).changeset/plugin-security-scanner-retired.md@objectstack/coreminor, BREAKING banner.changeset/plugin-security-scanner-ledger-entry.md@objectstack/specpatch, the ledger halfThree exports leave the public surface, not one:
ScanTargetandSecurityIssueexisted only to feed the class and were exported beside it. The changeset names
all three.
The pin is an export-list assertion, not a grep
Object.keys()over both barrel namespaces, per the ruling. A grep cannot answerthis: the name legitimately survives in the tombstone comment, in the pin's own
header and in the retired doc section, so a grep pin would go red on the
tombstones that exist to explain the retirement, and would stay green if the
class were re-exported under a different local name.
The pin carries a control assertion —
PluginSandboxRuntime, the exportblock immediately above the retired one, reaching the root barrel by the same
export *line the scanner used. Without it, a barrel that failed to load wouldanswer "absent" for every name and pass forever.
Reverse verification. Re-adding a
PluginSecurityScannerdeclaration to thesecurity barrel turned both retirement assertions red while the control
stayed green —
expected [ 156 names ] to not include 'PluginSecurityScanner'on the root barrel and
[ 82 names ]on the security barrel. Mutation confirmedon disk before the run; restore proven byte-exact afterwards (worktree
git hash-objectequal to the HEAD blob,git diff HEADempty). Both barrelsare reached by relative specifiers into this package's own
src/, so nodistleg is involved and no rebuild gates the result.
The ADR-0087 disposition, and what the gate actually wanted
The first push carried
not-required (no-migration-prescription)andcheck:adr-0087-registrationrefused it. The gate was right, and the reading isworth recording because the fix was not the obvious one.
What it wanted. Not a missing marker — an untruthful one. The changeset
carries a real consumer prescription (delete the import and every call), so
claiming no consumer has to rewrite anything is a self-contradiction the gate
checks statement-against-statement. Every other
not-requiredcategory is falsehere too:
@objectstack/corepublishes, sounpublishedis out;already-registeredhad no entry to name;type-surface-onlyneeds anany/unknown-to-concrete narrowing this is not; andruntime-interface-onlyexplicitly inherits the same prescription refusal rather than escaping it.
So the only truthful disposition was
registered, which required actuallywriting the ledger entry.
That is also the repo's settled convention for this exact shape — a published TS
symbol with no spec schema, no stored source and no tombstone, where the ledger
is the only channel that reaches an upgrader.
contracts.IDataDriver.findStreamand
actor-user-roles-to-positionsare both registered on those grounds, and thegate's own header names the predicament verbatim.
D3 semantic, not a D2 conversion — so this is not the metadata migration the
ruling excludes. The class has no spec schema, so there is no authorable key to
tombstone and no stored
sys_metadatarow to rewrite: a scanner was constructedper call and every result lived in a per-instance Map discarded with the object,
leaving
applyConversionsToStoredItemno seam that would ever see one.Two more things the gate taught, both measured rather than assumed:
nothing; the gate re-read the old text from
HEADuntil the edit wascommitted.
registeredtakes ids only — no trailingwhy. The threenot-requiredforms accept prose after the category;
registeredparses everything after itas a comma/space-separated id list, so a rationale sitting there was read as
131 nonexistent migration ids. The rationale now lives in the changeset body,
where a reader gets it anyway. That asymmetry is real and AGENTS.md spells it.
The regeneration lap did not materialise, and that is measured. The entries
README warns that
spec-changes.jsonanddocs/protocol-upgrade-guide.mdareprojections that must be regenerated when an entry lands. Here
check:generatedreports all 15 artifacts up to date, and running
gen:spec-changesandgen:upgrade-guideexplicitly moved neither file — a major-18 semantic entryis not yet projected into either.
registry.tsis the whole generated diff.Beyond the four named sites, and why
Two edits go past the ruling's literal enumeration. Both are the same defect
class the ruling closes — a shipped document teaching a capability that does not
exist — and leaving either would have produced exactly the dangling reference the
ruling forbids.
PHASE2_IMPLEMENTATION.mdcarried two more capability claims that name noclass and so were invisible to a grep for the symbol: "Security scanner
integrates with CVE databases" (Security) and "Security scanning can be run
asynchronously" (Performance). The first is replaced by a statement that the
platform performs no plugin security scanning; the second is dropped.
FOLLOW-UPS.md's row had two dependents. The paragraph beginning"Compounding the first row" existed only to elaborate it, and cites two files
this PR deletes; after the deletion "the first row" would also point at a
different surface. It is rewritten to record the closure and to preserve the
half that survives. The neighbouring row's evidence column said its "only
consumer is the dead scanner" — this PR makes that false, so it now reads
zero consumers.
Verification
All at head
67e559697. Exit codes captured by redirect before any pipe.pnpm --filter @objectstack/core exec vitest run --maxWorkers=2Test Files 50 passed (50)·Tests 1204 passed (1204)pnpm --filter @objectstack/core run typechecktsc --noEmit+tsc -p tsconfig.examples.json+check:test-typecheck: OKpnpm --filter @objectstack/spec run check:generatedAll 15 generated artifacts are up to datenode scripts/check-adr-0087-registration.mjs --base origin/mainregistered plugin-security-scanner-retired (new here: plugin-security-scanner-retired)node scripts/check-published-readme-exports.mjs60 published document(s) across 79 workspace package(s)scripts/pm/dispatch-gates.mjsderives for this diffpackages/spec(54 before)The examples program still has an input after the deletion
(
kernel-features-example.ts), and--listFilesconfirms the new pin is insidetsconfig.test.json's program while the deleted source is inside neither — sothe typecheck green is a measurement over the new file, not a green over a file
nothing read.
Two derivation notes, since the derived list is a lead rather than a
specification.
check:migration-registry— the gate that provesregistry.tsstill matches the entries directory, and the one most obviously implicated by
this diff — is not in the derived 75; it was run anyway, via spec's
check:generated. Andcheck:api-surfaceentered the derived set only once thediff reached
packages/spec; on the core-only diff it was named by the rulingand by nothing else.
Parked — do not land
Clause-2 card (a public export is removed), reviewable only at
CONTRACT_REVIEW_TIER, which is quota-exhausted. The dispatch exemption coversdispatch only, never contract review.
needs:contract-reviewis on this PR andon the card. Green CI on this PR is not landability.
Generated by Claude Code