docs(skills): the retirement playbook prescribes a minor changeset, not the major a live gate refuses - #19446
Conversation
… not the `major` a live gate refuses
The `spec-property-retirement` Changeset checklist item told the agent to grade
`@objectstack/spec` a `major` bump. The per-PR `Check Changeset` job runs
`scripts/check-changeset-no-major.mjs`, which refuses exactly that while the
launch window stands — so an agent executing the checklist verbatim reddened its
own PR and then had to decide, mid-retirement, whether the playbook or the gate
was authoritative.
Measured in this worktree, both legs on a throwaway changeset:
"@objectstack/spec": major -> exit 1, "This PR introduces changeset(s) that
declare a `major` bump."
"@objectstack/spec": minor -> exit 0, "This diff introduces no `major` bump."
The item now says `minor`, names the gate and the job so the next reader sees
why, and states that the breaking semantics ride the BREAKING banner instead of
the bump level — the same split `.github/workflows/pr-automation.yml` already
states under "WHICH LEVEL". The `AGENTS.md:breaking` pointer, the FROM -> TO
mapping requirement, the `CHANGELOG.md` rationale, the template pointer and the
ADR-0087 disposition marker all survive; the item is repacked in place at 6
lines so the file stays at its ratchet ceiling of 337, every line under 120
bytes.
Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver levelNone — nothing published ( ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
Fixes #19360
Clause-②: no
The contradiction
.claude/skills/spec-property-retirement/SKILL.md:291 — the Changeset item of theretirement checklist — told the executing agent to grade
@objectstack/specamajorbump.That checklist is executed verbatim by every retirement dev, and the per-PR Check Changeset
job (
.github/workflows/pr-automation.yml:244, runningnode scripts/check-changeset-no-major.mjs --base "$MERGE_BASE"at :1075) refuses exactly thatbump while the launch window stands. So the line reddened the author's own PR for doing what the
instructions said — and then left them to decide, mid-round on an already delicate public-surface
deletion, whether the playbook or the gate was authoritative.
Reproduction, both legs measured in this worktree
A throwaway
.changeset/repro-19360.mdwas committed on this branch, the gate run against thebranch point
23f1de078, the exit captured before any pipe, and the tree restored(
git diff HEADempty,git status --porcelainempty) after each leg.Leg 1 — the checklist line copied verbatim (
'@objectstack/spec': major) :: exit 1Leg 2 — the prescription this PR lands (
'@objectstack/spec': minor) :: exit 0The same file's own header states the rule the fix restores (:47-:51): "During the launch window
we ship breaking changes as
minor(pre-1.0 semantics …). This guard makes that conventionenforceable instead of tribal".
.changeset/pre.jsonis absent onmain, so the RC exemption isnot standing the guard down — the
enforcebranch is live.Before / after
Before (:291-:296, 6 lines):
After (:291-:296, 6 lines):
Everything the item already carried survives — the
AGENTS.md:breakingpointer, the FROM → TOmapping requirement, the
CHANGELOG.mdrationale, the.changeset/*-retired.mdtemplate pointerand the ADR-0087 disposition marker. What is added is the level (
minor), the prohibition(
⛔ 不用 major), the gate and job that enforce it, and the sentence that the breaking semanticsride the BREAKING banner rather than the bump level.
Why the level is stated in the playbook at all, on the four axes
It would have been cheaper to delete the level and defer it to each card's ruling. That is the
option this PR rejects, and the four axes say why.
contradiction is disposition for
PluginCapabilityManifestSchema/plugin-registry.zod.ts— the #13285 census closed all-zero across every reachable repo, so enforce-or-remove is now the open question #18623, whose ruling (comment5725503716, maintainer 「同意」, 2026-09-18)states the level
minorexplicitly. That card therefore needed no stop. A retirement whoseruling is silent on the level has nothing to break the tie, and the checklist is the only
artifact it reads. A blank slot in an executed checklist is not neutral — it is a prompt to
guess.
not a new convention:
pr-automation.yml's "WHICH LEVEL" prose says verbatim "During the launchwindow
majorstays refused bycheck-changeset-no-majorand breaking-ness is carried by theBREAKING banner plus the ADR-0087 disposition, not by the level" (maintainer ruling 2026-09-04,
decision batch [WIP] Add query enhancements and advanced validation features #35). Naming the gate in the playbook makes the playbook derive from that rule
instead of racing it, so the line goes stale only when the rule does — and the gate's own header
carries the GA end condition that will retire both together. No workaround: the consumer-side
alternative (teach the dev to add
allow-major) would route around a guard whose error messagesays the label means "a whole-stack major release is genuinely intended", which is false for a
property retirement.
executes verbatim; the failure mode is 「示例照抄即失败」. A declared step that the runtime
refuses is exactly the shape the house rules forbid — 声明即强制, never let an agent declare
what the runtime will not honour. Deferring the level would replace a wrong instruction with an
absent one, which is the softer version of the same defect: the agent still has to guess, and
the guess is now invisible in review. Stating the level AND naming the refusing gate makes the
instruction self-checking — the next author can run the named script and see the verdict for
themselves rather than trusting the line.
no allowlist, no transition window and no second spelling kept alive: the
majorprescriptionis replaced outright rather than deprecated beside its replacement.
scripts/check-changeset-no-major.mjsis untouched — the launch-window guard is the thing being obeyed, not the thing being changed.
Budget
.claude/skills/spec-property-retirement/SKILL.mdlinesscripts/pm/check-skill-line-ratchet.mjsNet 0. The item is repacked in place at 6 lines; every rewrapped line was produced by the
ratchet's own
wrapLine/breakLegalatoms, so no break lands at a Han + ASCII-punctuationjunction.
node scripts/pm/check-skill-line-ratchet.mjs:: exit 0.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranat headee26ec6:18 derived, 18 run, 0 NOT-MEASURED, 0 UNRUN — a derived zero, every family recorded an exit
code and none of them is 3. Route-named additions beyond the derived union, all exit 0:
check-skill-line-ratchet.mjs,check-skill-id-lint.mjs,check-governed-prose.mjs,check-skills-token-ratchet.mjs,check:pm-settings-deny-roster, and the declared cross-packageconsumer
packages/spec/src/shared/retired-key-migrate-sentence.test.ts(14 tests passed), whichreads this playbook as a scanned corpus.
pnpm lintnarrowed to a measurement rather than skipped: eslint's covered population is**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}read fromeslint.config.mjs:971 — a.mdpath is not init;
--format jsonover the one changed file returns 1 entry,errorCount0, with the message"File ignored because no matching configuration was supplied"; and the config declares no
parserOptions.project(type-aware linting deliberately off, :328), so this diff cannot move theverdict on any untouched file. The intersection of this diff with eslint's population is empty.
Acceptance notes
grepfor amajorchangeset prescriptionacross
AGENTS.md, all of.claude/**and the publishedskills/**catalog returns zero hitsoutside the line this PR fixes. The playbook was the only site.
scripts/check-changeset-no-major.mjsis deliberately untouched: the launch-window guard iscorrect and carries its own GA end condition. This PR moves the instruction to the gate, not the
gate to the instruction.
维护者速读(草稿)
改了什么 —— 退役 playbook 的 Changeset 那一条,原来写「
@objectstack/spec用major」,现在写「用
minor,不用major」,并点名是哪个门禁在拒收(Check Changesetjob /scripts/check-changeset-no-major.mjs)。一个文件、一条清单项、净增 0 行。为什么改 —— 这条清单是 agent 照着一条条执行的,不是读着理解的。照抄这一行写出来的 changeset
会被逐 PR 的门禁当场拒收,PR 变红。更贵的是红之后:agent 得在一张本来就很敏感的「删除公开键」
卡上,临时判断到底是 playbook 算数还是门禁算数。上一次撞上它的是 #18623,那张卡的裁决里恰好写了
minor,所以没停;下一张裁决里没写级别的退役卡就没有东西能打破平局。风险与代价(含回滚) —— 风险面就是这一条清单项的措辞,没有代码、没有门禁、没有 schema 改动。
本地实测两条腿:写
major的 changeset 门禁退出码 1,写minor退出码 0。回滚 = revert 这一个commit,退役流程回到今天的状态(即:回到会红的那一版)。发射窗口在 GA 结束时,那条门禁自己的
文件头写着终止条件;到那天
major重新合法,这一行要跟着门禁一起改——所以这里点名了门禁,让下一位读者顺着指针找得到。
席位意见 ——
你要做的 —— 确认一句话:发射窗口期内,退役的 changeset 用
minor+ BREAKING banner 承载破坏性语义,这与
pr-automation.yml里 2026-09-04 的裁决一致。如果同意,这张卡按 Tier S 走席位 contractreview 落地,不需要你再做别的。
Generated by Claude Code