Skip to content

feat(core)!: retire PluginSecurityScanner — plugin security scanning is not a platform capability - #15930

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-14919-retire-plugin-security-scanner
Sep 5, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-14919-retire-plugin-security-scanner

Conversation

@zhuangjianguo

@zhuangjianguo zhuangjianguo commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #14919

Retires PluginSecurityScanner from @objectstack/core under ADR-0049
enforce-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 private
scanners:

  • scanCode, scanMalware, scanLicenses, scanConfiguration each allocated
    an empty issue array, logged, and returned it with no code in between. None
    could report a finding for any input.
  • scanDependencies ran a real loop, but matched only against vulnerabilityDb,
    an in-memory Map whose sole writer was the public addVulnerability — which
    had zero callers in this repo, in objectui at the pinned sha, and in the
    example itself. updateVulnerabilityDatabase() logged twice and fetched
    nothing.

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

Surface Change
packages/core/examples/phase2-integration.ts deleted — the only constructor in the tree
packages/core/src/security/security-scanner.ts deleted
packages/core/src/security/index.ts export block dropped; tombstone comment left in its place
packages/core/PHASE2_IMPLEMENTATION.md section 6 rewritten to state that plugin security scanning is not a platform capability
docs/qa/platform-checklist/FOLLOW-UPS.md row deleted; dependent prose repaired
packages/core/src/security/security-scanner-retirement.pin.test.ts new — export-list pin
packages/spec/src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts new — the ADR-0087 D3 ledger entry
packages/spec/src/migrations/registry.ts regenerated by gen:migration-registry (157 semantic entries)
.changeset/plugin-security-scanner-retired.md new — @objectstack/core minor, BREAKING banner
.changeset/plugin-security-scanner-ledger-entry.md new — @objectstack/spec patch, the ledger half

Three exports leave the public surface, not one: ScanTarget and SecurityIssue
existed 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 answer
this: 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 export
block 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 would
answer "absent" for every name and pass forever.

Reverse verification. Re-adding a PluginSecurityScanner declaration to the
security 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 confirmed
on disk before the run; restore proven byte-exact afterwards (worktree
git hash-object equal to the HEAD blob, git diff HEAD empty). Both barrels
are reached by relative specifiers into this package's own src/, so no dist
leg 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) and
check:adr-0087-registration refused it. The gate was right, and the reading is
worth 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-required category is false
here too: @objectstack/core publishes, so unpublished is out;
already-registered had 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 rather than escaping it.
So the only truthful disposition was registered, which required actually
writing 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.findStream
and actor-user-roles-to-positions are both registered on those grounds, and the
gate'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_metadata row to rewrite: a scanner was constructed
per call and every result lived in a per-instance Map discarded with the object,
leaving applyConversionsToStoredItem no seam that would ever see one.

Two more things the gate taught, both measured rather than assumed:

  1. It reads committed state. Fixing the marker in the working tree changed
    nothing; the gate re-read the old text from HEAD until the edit was
    committed.
  2. registered takes ids only — no trailing why. The three not-required
    forms accept prose after the category; registered parses everything after it
    as 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.json and docs/protocol-upgrade-guide.md are
projections that must be regenerated when an entry lands. Here 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. registry.ts is 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.

  1. PHASE2_IMPLEMENTATION.md carried two more capability claims that name no
    class 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.
  2. 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.

Command Exit Verdict line
pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 0 Test Files 50 passed (50) · Tests 1204 passed (1204)
pnpm --filter @objectstack/core run typecheck 0 tsc --noEmit + tsc -p tsconfig.examples.json + check:test-typecheck: OK
pnpm --filter @objectstack/spec run check:generated 0 All 15 generated artifacts are up to date
node scripts/check-adr-0087-registration.mjs --base origin/main 0 registered plugin-security-scanner-retired (new here: plugin-security-scanner-retired)
node scripts/check-published-readme-exports.mjs 0 60 published document(s) across 79 workspace package(s)
the 75 families scripts/pm/dispatch-gates.mjs derives for this diff all 0 re-derived after the diff widened into packages/spec (54 before)

The examples program still has an input after the deletion
(kernel-features-example.ts), and --listFiles confirms the new pin is inside
tsconfig.test.json's program while the deleted source is inside neither — so
the 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 proves registry.ts
still 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. And check:api-surface entered the derived set only once the
diff reached packages/spec; on the core-only diff it was named by the ruling
and 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 covers
dispatch only, never contract review. needs:contract-review is on this PR and
on the card. Green CI on this PR is not landability.


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/spec, touching 34 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/PHASE2_IMPLEMENTATION.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f1e91595f706b7eefb73457754f4eb05fa13e362.

⛔ 1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/PHASE2_IMPLEMENTATION.md) — pages documenting those are invisible to this run
  • 23 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f1e91595f706b7eefb73457754f4eb05fa13e362 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3bd95c5c5e6b2d4f98cf5da03fa76e27737191a3 — the merge of head 67e559697ada8e10715b6dfa73cadf71446164de into base f1e91595f706b7eefb73457754f4eb05fa13e362, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f1e91595f706b7eefb73457754f4eb05fa13e362 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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

Copy link
Copy Markdown
Contributor

Contract review (clause ②) PASS at head 67e55969 — director seat, session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T16:1xZ. Verdict with the item-by-item derived judgments, semver reading and boundary-flag answers: objectstack#14919 comment 5553061275. needs:contract-review removed from this PR and from the card in the same stroke (a legitimate clear: two removals seconds apart, both citing this comment). The dev's open question — the packages/spec D3 ledger entry — is ruled inside the ruling, not a scope breach.

Landing follows landing-operations.md: carrier pair check, all checks green on 67e55969, governed test 0 of 10 paths ⇒ ready + auto-merge (SQUASH). Closes #14919 is correct; the card's pm:dispatched comes off at MERGED confirmation.

Implemented-by: session_01ARYe3yQTQCUFm5qPYNgKaJ
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 5, 2026 16:11
@os-zhuang
os-zhuang enabled auto-merge September 5, 2026 16:12
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 5, 2026
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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 Clause-②: yes, and no contract review at CONTRACT_REVIEW_TIER was ever performed on it — not by this seat, not by anyone. The implementing round was dispatched at opus under the quota exemption, and that exemption covers dispatch only; it has never covered contract review. Fable has been rate-limited since roughly 15:10; a real probe task at 15:55 returned HTTP 429 ("You've reached your Fable limit"), so the tier was unavailable rather than skipped by choice.

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:

  1. When the fable tier returns, this seat can still run the clause-② review and post the result, whether or not the PR has landed by then.
  2. One scope question from the implementing round remains open: the packages/spec ledger entry (commits 5b3d1a9ed + 67e559697, +63/−0). If the intent was no ledger entry at all, backing out those two commits is the whole remedy — they are cleanly separable from the rest of the change.

Generated by Claude Code

Copy link
Copy Markdown
Contributor

Correction to the PM note above (5553093398) — a clause-② review at CONTRACT_REVIEW_TIER IS on file for this head. The note's "Reviews on record: none" read GitHub's PR-review objects; this repo's contract verdicts live as card comments, and the one for this PR is objectstack#14919 comment 5553061275 (director seat, session_01TezFG8ZMrNH6n5VTNpPpdH, tier read by get_session as claude-fable-5-1 on both session_context.model and last_served_model; posted 16:0xZ, before the labels came off). The provenance comment on this PR is 5553069685 (16:12Z). So the carrier reads clear because a review PASSED, not on maintainer authority — and the label removals (card, then PR, seconds apart, both citing the PASS) are the legitimate-clear signature. The note's "not queued" reading was also a few seconds early: the timeline shows auto_merge_enabled 16:12:03Z and added_to_merge_queue 16:12:09Z.

The open scope question the note repeats was ruled in that PASS (row 4): the packages/spec D3 ledger entry (5b3d1a9e + 67e55969) is inside the ruling — a semantic entry is not the metadata migration the ruling excludes, and check:adr-0087-registration left registered as the only truthful disposition. No back-out. Thank you for recording the note rather than assuming; it is exactly the ambiguity a legitimate clear needs to be distinguishable from a strip, and the answer is now beside it.


Generated by Claude Code

Merged via the queue into main with commit cc00df2 Sep 5, 2026
42 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-14919-retire-plugin-security-scanner branch September 5, 2026 16:41
os-warren pushed a commit that referenced this pull request Sep 21, 2026
…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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants