Repository navigation
docs(spec): state the authoring-surface jurisdiction retiredFromLoadPath actually has - #17888
Conversation
…ath actually has The flag's own docs claimed a retired conversion is "never at load" and that "the load seam never sets this — the migration chain and the fixture CI set it". Neither half held on this tree. Three data-at-rest call sites pass `includeRetired: true` deliberately: applyConversionsToStoredItem (which pins it rather than offering it — the option key is omitted from StoredConversionOptions), flow rehydration in the automation engine, and the artifact-ingestion door applyArtifactForwardConversions, which is reached from two callers. And `objectstack migrate meta` never passes the option at all: applyMetaMigrations looks each step's conversion up by id in ALL_CONVERSIONS and calls its `apply` directly, so it never goes through applyConversions. What the flag governs is the authoring surface — it keeps the entry off normalizeStackInput, the single funnel for defineStack / validate / lint / compile / info / doctor, so a live author meets the tombstone instead of a silent rewrite. That is the split ADR-0087's Addendum (2026-07-31) and the artifact-door ruling both bought. Declaration only: no behaviour, no schema key, no export moves. The split is now pinned by a case that drives normalizeStackInput and applyConversionsToStoredItem over the same bytes, so the prose and the behaviour cannot drift apart again — the existing cases all drive applyConversions directly and would stay green through exactly that drift. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 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 cb9e607c6247b5075cc0cd93a64499229af9a775 && git checkout cb9e607c6247b5075cc0cd93a64499229af9a775
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2266438ce0ba039634f9dcec06db7be12c0d58ee 4c82fc7c49ee47428edf066f2d73d3a5e94db7e1 && git checkout -B drift-repro 2266438ce0ba039634f9dcec06db7be12c0d58ee && git merge --no-ff 4c82fc7c49ee47428edf066f2d73d3a5e94db7e1
node scripts/docs-audit/affected-docs.mjs --json 2266438ce0ba039634f9dcec06db7be12c0d58ee |
…t-flip class, so an authored `hidden: true` app is no longer registered unpublished (objectstack-ai#17899) Fixes objectstack-ai#17885 Clause-②: yes The artifact-ingestion door replayed `app-hidden-to-unpublished` — a DEFAULT FLIP — over authored metadata, so a compiled artifact carrying `defineApp({ hidden: true })` reached registration as `_unpublished: true` and was then withheld by `filterAppForUser` from every user without `studio.access` / `setup.access`. The door now refuses that one class by id. Nothing else about the door, the flag, the entry or the consumer moves. ## Where the fix is, and why it is not anywhere else | candidate | why not | |---|---| | `rest-server.ts:3215` (the consumer) | READ-ONLY in the declared surface, and correct: withholding on `_unpublished` is the ADR-0045 gate. The defect is who WRITES `_unpublished`. | | flip the door to `includeRetired: false` | Reverses objectstack-ai#12772. The retired window exists so an artifact built by 17.1.0 tooling carrying `allowRestore`/`allowPurge` still boots instead of dying at the tombstone. This branch's own suite proves it would break: the firing control at `artifact-forward-conversion.test.ts` drives exactly that artifact through the same window. | | the registry entry's `apply` | The entry cannot answer the question. A machine-written pre-split row and an author who wrote `hidden: true` yesterday are byte-identical at the item level, so no predicate over the app distinguishes them. Whether the rewrite is sound depends on the CALLER's evidence, not on the item. | | `retiredFromLoadPath` on the entry | Does not reach here at all. objectstack-ai#16864's determination landed on this base (PR objectstack-ai#17888, commit `29dd1a6ddb`): both recorded decisions bought the SEAMS arm, the flag is `live` but its jurisdiction is the authoring funnel and nothing else. `types.ts` now says so in terms: "For a conversion whose old and new shapes are both legal and mean different things (a default flip, not a rename), the data-at-rest seams will still apply it." | ⇒ The seam is where the evidence lives, so the seam is where the refusal goes. `applyConversions` gains `excludeConversionIds` — "my evidence cannot carry this entry" — and the door names the one class it refuses, with the reason beside the id. **Why THIS seam and not the others.** The other two data-at-rest seams argue from "a row at rest has no author to teach". An artifact does have one: it is compiled from a source that still exists, and the door's own boot warning already tells that author to rebuild. Worse, the door's evidence is the artifact's **declared `engines.protocol` floor**, not its age — and `^17.0.0` is the range `create-objectstack` stamps, so an app authored today lands inside the window. For a lossless delete or a rename of a shape the schema now refuses, guessing wrong costs nothing: the key is inert and the replay is a rescue. For a default flip, guessing wrong destroys authored intent. Same measurement objectstack-ai#16693 made for `field-required-notnull-explicit`, and the WITHDRAWN block that removal left in `registry.ts` says it in advance: "Before setting that flag on a DEFAULT FLIP — as opposed to a lossless delete or a rename — read that card, because the flag does not mean what its name and every docblock around it say it means." ## ⛔ What is deliberately NOT settled here - **The objectstack-ai#16864 ledger question is not reopened.** `retiredFromLoadPath` keeps exactly the jurisdiction that determination gave it. This adds a seam-level refusal beside it; it does not redefine the flag. - **`registry.ts` is untouched.** No entry is withdrawn, no `apply` narrowed, no fixture moved. The entry still fires at the stored-row seam and through `os migrate meta`, which is what its own docblock claims — narrowing the door is what makes that claim TRUE again rather than falsifying it further. - **The consumer is untouched.** `packages/rest/src/rest-server.ts` is not in the diff. ## The pin: subject, two controls, and the post-parse leg `packages/metadata-core/src/artifact-forward-conversion.test.ts` — six cases, modelled on the objectstack-ai#16693 block directly above them: 1. **SUBJECT** — floor `^17.0.0`, runtime `17.4.0`: `verdict === 'converted-forward'` and `authoredFloor === '17.0.0'` (anti-vacuity: the window really is open), `apps[0].hidden === true`, `_unpublished` undefined, no `app-hidden-to-unpublished` notice, and copy-on-write hands back the same reference. 2. **⭐⭐ POST-STRICT-PARSE** — the same door output fed to `ObjectStackDefinitionSchema.parse`, which is what `MetadataPlugin._parseAndRegisterArtifact` does at `plugin.ts:915`, and the app read back OUT of the parsed object. This is the object that reaches registration; a pin on the conversion's return value alone would not catch a parse that re-introduced the key. 3. **NEGATIVE CONTROL** — floor `^99.0.0`: `verdict === 'authored-current'`, zero notices. The instrument can answer "no" for the other reason. 4. **FIRING CONTROL 1** — a NON-retired conversion (`page-kind-jsx-to-html`) still fires in the subject's own window, on an artifact that also carries the hidden app. 5. **FIRING CONTROL 2** — a RETIRED conversion still fires in that same window (`allowRestore`/`allowPurge` stripped). This is the control this particular fix could plausibly have broken, and it is what makes "objectstack-ai#12772 is not reversed" a measurement rather than a claim. 6. **SEAM SCOPE** — `applyConversionsToStoredItem('app', …)` still converts. A fix that had neutered the entry would go green on all five legs above and silently strand the stored population. Plus three cases on the primitive in `packages/spec/src/conversions/conversions.test.ts`: the refusal, its firing control (one id refused, the rest of the chain runs, in the same call), and "absent or empty list changes nothing". ## Ablation — both halves, on-disk proof and hash-verified restore Each leg: assert the file equals its HEAD blob · count the marker (must be 1) · delete the line · re-count (must be 0) and assert the blob hash MOVED · run · restore with `git checkout HEAD -- FILE` · assert the hash is back and `git diff HEAD` is empty. A `trap` on EXIT/INT/TERM holds the restore on the crash path, and every path is absolute. **A — the door half** (`excludeConversionIds: DEFAULT_FLIPS_NOT_REPLAYED_HERE,` removed). Marker 1 to 0, blob `309b8444` to `f2e84cab`: ``` Test Files 1 failed | 15 passed (16) Tests 3 failed | 275 passed (278) FAIL leaves `hidden: true` alone on an artifact the retired window IS open for AssertionError: the authored navigation choice is untouched: expected undefined to be true FAIL registers `hidden: true` — asserted AFTER the strict parse the door feeds AssertionError: what registration receives: expected undefined to be true FAIL still applies a non-retired conversion in that same window (its app-side line) ``` ⭐ The direction is not just "red": the SUBJECT and the POST-PARSE legs fail, while the NEGATIVE control, FIRING CONTROL 2 and the SEAM-SCOPE leg stay green — exactly the three that must not depend on the fix. Restore: hash back to `309b8444`, `git diff HEAD` empty. **B — the primitive half** (`if (excluded?.has(conversion.id)) continue;` removed from `apply.ts`). Marker 1 to 0, blob `de2efc0c` to `ee65383a`: ``` Test Files 1 failed | 471 passed | 1 skipped (473) Tests 2 failed | 13449 passed | 1 skipped (13452) FAIL a seam can refuse a named entry by id even with `includeRetired: true` FAIL refuses only the named id — the rest of the chain still runs AssertionError: the refused entry did not fire: expected undefined to be true ``` Restore: hash back to `de2efc0c`, `git diff HEAD` empty. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **79** commands from the real change set (5 paths vs merge base `7e74af3df`). All 79 run, all **exit 0**; `--ran` reconciles 79 derived / 79 run / 0 UNRUN. Three answered **exit 3 (PREREQUISITE NOT MET, read as NOT MEASURED, never a pass)** on the first pass — `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt` — plus four spec `dist`-readers that refused on a src/dist digest mismatch; the closure was built (`turbo run build` over `./packages/*` + `./packages/*/*`, 72 tasks) and all seven re-run at exit 0. - `pnpm --filter @objectstack/metadata-core test` — 16 files / **278 tests**, exit 0. `typecheck` exit 0. - `pnpm --filter @objectstack/spec test` — 473 files / **13453 tests**, exit 0. `typecheck` exit 0. ⛔ No `--project` narrowing was added by this branch (spec's own `test` script carries `--project local` itself; objectstack-ai#17853's trap is a narrowing added over a named file, which is not done here). - `check:api-surface` is **green without regeneration** — the published export listing does not move. - Published-surface measurement, the sound way (grep the BUILT entry `.d.ts` for symbol NAMES, with a fabricated-name negative control): `ApplyConversionsOptions` 7 hits, `MetadataConversion` 6, `applyConversions` 8, fabricated control **0**. ⇒ the symbol set is UNCHANGED — no export added, moved or removed. What does move is one line of text inside an existing exported interface: `excludeConversionIds` appears once in `dist/index.d.ts`. `check-widening-tells --declaration no` exits 0 (no T1/T2/T3/T4 tell), and `check-clause2-carriers --pair 17899` exits 0. - `packages/spec/src/conversions/registry.ts` is NOT in the diff, so the dispatch's registry-plus-surface fork does not fire by its own terms. The `.d.ts` text change is reported here anyway, because the seat owns the declaration and this is the measurement it asked for. - ⛔ Still NOT measured, and not claimed: no end-to-end boot of a scaffolded app; the `rest-server.ts:3215` link is read, not executed. One cheap reading was taken and it only confirms the card's own sentence — `packages/platform-objects/src/apps/account.app.ts:40` does author `hidden: true` — while whether a code-declared app enters a compiled artifact through this door remains unread. ## Acceptance notes - `noted, not filed:` the same rewrite is reachable at the STORED-ROW seam for a post-split authored row: `PUT /meta/app/NAME` validates against `AppSchema`, which accepts `hidden`, so a row written after the 2026-08-09 split with `hidden: true` and no `_unpublished` is indistinguishable from a pre-split materialized one and is converted on rehydration. The entry's population argument ("under the old regime a `hidden: true` row could only have come from the materialization path") is an argument about the OLD regime only. Not filed: it is the same ledger question objectstack-ai#16864 answered for the flag and would settle the stored seam's evidence by side effect, which triage ruled out for this card. 承接者: the `domain:spec` seat holding objectstack-ai#16864's remaining two carriers (objectstack-ai#17894, objectstack-ai#17895). - `noted, not filed:` the exclusion list is per-door rather than per-entry, so the NEXT default flip is not covered until somebody adds its id here. The robust shape is a declaration on the registry entry, which would move `packages/spec`'s built entry `.d.ts` and re-open the Clause-② fork this card's dispatch draws. 承接者: whoever takes the general mechanism follow-up to objectstack-ai#16864. Authored by the `domain:engine` dev round of session `session_01RuoNSXUbBoWHkNS4AknTrM` (https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM), dispatched from seat post objectstack-ai#6367. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ed example in the retirement checklist (objectstack-ai#17947) Fixes objectstack-ai#17895 Share 3 of 3 of objectstack-ai#16864's correction. Share 1 (the `packages/spec` carriers) landed as PR objectstack-ai#17888; share 2 is the ADR-0087 half, tracked as objectstack-ai#17894 and left to its own PR — different file, different acceptance. **This PR touches no `packages/` path at all**: the whole diff is one file, `.claude/skills/spec-property-retirement/SKILL.md`, 12 insertions / 12 deletions. ## What the checklist taught, and why it was wrong The `retiredFromLoadPath: true` item asserted two things that are not true of this tree. **1. Jurisdiction.** It said that only `os migrate meta --from OLD_MAJOR` may apply a retired conversion — that setting the flag keeps the rewrite off every load path. Measured on `origin/main` at `84e6b05`, three non-test `includeRetired: true` literals replay retired entries deliberately: | seam | file | count | |:--|:--|:--| | stored-row rehydration (pinned, not offered) | `packages/spec/src/conversions/stored.ts` | 1 | | flow rehydration, automation engine | `packages/services/service-automation/src/engine.ts` | 1 | | artifact-ingestion door | `packages/metadata-core/src/artifact-forward-conversion.ts` | 1 | `StoredConversionOptions` is an `Omit` over `includeRetired`, so no caller of the first seam can turn it off. **2. Worked example.** It argued the default-flip rule with `field-required-notnull-explicit`, which the registry deliberately does not contain — `packages/spec/src/conversions/registry.ts` carries a tombstone at that spot reading `⛔ WITHDRAWN — there is deliberately NO field-required-notnull-explicit conversion in this registry`. A reader grepping the name met the checklist's worked example and the tombstone at the same time. ## What changed The corrected jurisdiction wording is **copied in substance from what PR objectstack-ai#17888 already landed** in `packages/spec` (the `retiredFromLoadPath` docblock on `MetadataConversion` in `conversions/types.ts` and the `includeRetired` docblock in `conversions/apply.ts`) — deliberately not a third wording. The checklist translates register, not meaning. - The flag's jurisdiction is named as the authoring funnel `normalizeStackInput` **and nothing else**, and the item now says in as many words that it stops none of the three seams, naming each one. - The default-flip half is restated as the landed docblocks state it: soundness belongs to the **seam**, not to the entry or the flag — only a seam that can treat "this input predates the flip" as a fact may replay one, and a seam that cannot opts the id out through `excludeConversionIds`. - The worked example is now `app-hidden-to-unpublished` (`registry.ts` :6242), re-confirmed on the tree: `retiredFromLoadPath: true`, old and new shapes both legal and meaning different things, and its own docblock argues the rule in the same terms the removed entry did. It is also the live instance of the opt-out sentence above — `artifact-forward-conversion.ts` lists exactly that id in `DEFAULT_FLIPS_NOT_REPLAYED_HERE`. - One sentence records **why** the previous example was withdrawn: its docblock's "only `migrate meta` may apply it" claim was false of this tree, the artifact door applied it at boot, and the conversion was withdrawn as a result. Cited to `packages/spec/CHANGELOG.md`, whose entry carries the measurement. - The fixture-disjointness item lost its stale illustration, which named the same withdrawn conversion as if it still fired (see *Bounded in-place fix* below). ## Line budget — the dispatch's budget was wider than the ratchet's The dispatch allowed at most +2 net lines. **Measured, the real budget is 0**: `scripts/pm/check-skill-line-ratchet.mjs` pins this file at `337` and the file was at 337 lines, headroom 0. The item is therefore paid for entirely out of deletions, and the file lands at 337 again. ``` ✓ check-skill-line-ratchet: .claude/skills/spec-property-retirement/SKILL.md is 337 lines (ceiling 337; headroom 0). ✓ check-skill-line-ratchet: .claude/skills/spec-property-retirement/SKILL.md: widest table row is 326 bytes (pin 326; headroom 0). ``` Where the lines came from, per the rule that additions are paid by deleting content and re-wrap is not currency: | item | before | after | what was deleted | |:--|:--:|:--:|:--| | `retiredFromLoadPath: true` | 7 | 8 | the withdrawn worked example; the false `migrate meta` exclusivity sentence; the parenthetical on why a retired entry still exists (already carried by this section's own opening, which names `spec-changes.json`, the upgrade guide and the `spec_changes` MCP tool as the main channel) | | fixture disjointness | 6 | 5 | the illustration `(a new objects[].fields fixture may not carry a bare required: true, or the notNull conversion fires on it)` — false today, see below | | **file** | **337** | **337** | | No line was merged into another to free a line; every line of headroom above is a clause that was removed because it was false or because the same fact is stated elsewhere in the same section. Every line of the new item is under the gate's 120-byte cap (widest 113). ### Bounded in-place fix — the fixture item's illustration `packages/spec/src/conversions/registry.ts` has no conversion that reads `required: true` and writes `storage.notNull`; the only `notNull` occurrences in that file are inside the tombstone comment. The `required` key is touched by exactly one live entry, `field-conditionalRequired-to-requiredWhen`, which fires on `conditionalRequired`. So the illustration described the same withdrawn conversion as live. It sits inside the region this card declared (:209–:231), it is the same defect class, and the rule it illustrates (`before` stays minimal and avoids other entries' keys) survives intact — only the stale example is gone. ## Acceptance — both directions Run on the branch at `c4838a3`, against `.claude/skills/spec-property-retirement/SKILL.md` unless stated: | check | expected | measured | |:--|:--:|:--:| | `field-required-notnull-explicit` in this file | 0 | **0** | | literal control: `retiredFromLoadPath` in this file | > 0 | **3** | | `app-hidden-to-unpublished` in this file | > 0 | **1** | | `app-hidden-to-unpublished` in `packages/spec/src/conversions/registry.ts` | > 0 | **1** | | `normalizeStackInput` in this file | > 0 | **1** | | `applyConversionsToStoredItem` in this file | > 0 | **1** | | `applyArtifactForwardConversions` in this file | > 0 | **1** | | `flow rehydration` in this file | > 0 | **1** | | `includeRetired` in this file | > 0 | **1** | | negative control: a conversion id that does not exist | 0 | **0** | Seam re-measurement on `origin/main`, `git grep -c 'includeRetired: true'` over the three files: **1 / 1 / 1**. No fourth non-test seam appeared; the other hits in that grep are CHANGELOG prose and the `types.ts` docblock. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths — the tool takes its own change set from the merge base), all 17 run, then reconciled with `--ran` carrying each exit code: ``` ✓ dispatch-gates --ran: 17 derived famil(ies) accounted for — 17 run, 0 NOT-MEASURED (a DERIVED zero — all 17 recorded an exit code and none of them is 3). ``` All 17 exited 0. One needed a second pass: `check:doc-formula-expressions` first exited **3 — `PREREQUISITE NOT MET`, not a finding** (`@objectstack/formula` and `@objectstack/lint` unbuilt); after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` through the shared verify lock it exited 0. **Repo-wide `pnpm lint` is CI's run, and the narrowing here is measured rather than assumed.** ① Population, read from eslint's own config: `eslint.config.mjs` declares no `files:` glob matching `.md` — every block is `{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. ② File count, from `--format json` on the one changed file: 1 file, 0 errors, message `File ignored because no matching configuration was supplied`. ③ Invariance: the diff is one Markdown file and nothing in it enters any eslint program, so no verdict on any untouched file can move. `check:skills-token-ratchet` covers the **published** `skills/` bundle (34 authored files) and does not carry `.claude/skills/`, so there is no token reading to pair with the line reading here; it was run anyway and exited 0. ## Changeset `skip-changeset`, applied as a label. `.claude/**` publishes nothing: it is not in any package's `files[]`, ships in no tarball, and no released symbol moves. ## A measurement the dispatch did not carry While confirming the seams I found that the artifact-ingestion door has changed since the card was written. `artifact-forward-conversion.ts` now passes `excludeConversionIds: DEFAULT_FLIPS_NOT_REPLAYED_HERE`, a module-local list naming `app-hidden-to-unpublished` — landed by PR objectstack-ai#17899 at `134b410`. The card that reported it, objectstack-ai#17885, went to `completed` on 2026-09-12, before this card was graded. The door still opens the retired window; it refuses the default-flip class by id inside it. That does not weaken the card — the flag's jurisdiction is unchanged and the checklist was still teaching the false sentence — but it does change what the corrected item has to say, so the item now teaches the opt-out duty rather than implying the flag confines a default flip by itself. Leaving it out would have let the new worked example read as "a default flip the flag holds back", which is the same failure mode this card exists to end. Not touching objectstack-ai#17885 itself, per the dispatch. ## 维护者速读(草稿) **改了什么** —— 只改一份内部 agent 手册 `.claude/skills/spec-property-retirement/SKILL.md` 的一条清单项(外加同一区域里一句同类的过期举例)。不碰任何 `packages/` 代码、不碰协议、不发布任何东西。 **为什么改** —— 这份 checklist 是每个做「属性退役」的 agent 照着执行的作业单。它教的一句话是假的:它说设了 `retiredFromLoadPath: true`,改写就只会发生在 `os migrate meta` 里。实际上有三处运行期入口故意重放退役条目。这条假话已经造成过一次已发布的事故 —— 一个 conversion 在 boot 时把 NOT NULL 写到作者声明为可空的字段上,最后以撤销那个 conversion 收场。而 checklist 至今仍在教同一句话,并且仍拿那个已被撤销的 conversion 当样例。这一改把它拉回到 `packages/spec` 里已经落地的说法上,并换成树上还活着的样例。 **风险与代价(含回滚)** —— 风险极低:纯文档面,不进 npm 包,不影响运行时,也不影响任何构建产物。行数棘轮 337/337 原地不动,没有抬任何上限。回滚就是 revert 这一个 commit,无任何后续清理。唯一的取舍是行数预算为 0,所以为了写下正确的说法,删掉了三处内容:已撤销的样例、那句假的管辖权断言,以及一句在本节开头已经说过的括注。 **席位意见** —— **你要做的** —— 这是 governed 面(`.claude/**`),按 Prime Directive objectstack-ai#14 只能由维护者手合。PR 停在 draft,席位不会转 ready、不入队、不挂 auto-merge。请确认两件事:① 新写的管辖权句子与 `packages/spec` 里已落地的 docblock 说的是同一件事(而不是第三种说法);② 为了守住 0 行预算而删掉的三处内容,你认可它们的确可删。 --- _Generated by [Claude Code](https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16864
Clause-②: no — declaration prose only. No schema key moves, no accepted-set change, no export change, no behaviour change.
What this changes
retiredFromLoadPathdeclared a jurisdiction it does not have. Two carriers inpackages/spec/src/conversions/said a retired conversion is "never at load" and that"the load seam never sets this — the migration chain and the fixture CI set it". This PR
corrects both to what the tree actually does, and pins the split with a test so the
sentence and the behaviour cannot drift apart again.
Nothing about behaviour changes. The flag is live, not inert, and it is not narrowed,
removed or re-flagged here.
The corrected declaration
Three files:
packages/spec/src/conversions/apply.ts— theincludeRetiredoption docblock, theinline comment in the conversion loop, and the module docblock's claim that
normalizeStackInputis "the single seam every load path funnels through" (it is thesingle seam every authoring path funnels through).
packages/spec/src/conversions/types.ts— theMetadataConversion.retiredFromLoadPathfield doc.
packages/spec/src/conversions/conversions.test.ts— the pin.Seam inventory, re-measured on this branch's base
git grep -n "includeRetired" -- '*.ts' ':!*.test.ts'returns 3includeRetired: trueliterals; the set of runtime callers reaching them is 4, because
applyArtifactForwardConversionshas two non-test callers.packages/spec/src/conversions/stored.ts:75applyConversionsToStoredItem—includeRetiredis PINNED, not a caller's choice (StoredConversionOptionsomits the key)metadata-protocol/src/protocol.ts,metadata/src/loaders/database-loader.ts,objectql/src/plugin.ts,services/service-datasource/src/datasource-admin-plugin.tspackages/services/service-automation/src/engine.ts:3710packages/metadata-core/src/artifact-forward-conversion.ts:291metadata/src/plugin.ts:763andruntime/src/app-plugin.ts:907Control for that count: the same grep without the
':!*.test.ts'exclusion returnsstrictly more lines, including test paths — so the exclusion is really excluding rather
than silently swallowing the corpus, and a count of 3 could have come back otherwise.
The second half of the old sentence is false in its own way, verified here rather than
carried over:
objectstack migrate metanever passesincludeRetiredanywhere.applyMetaMigrations(packages/spec/src/migrations/chain.ts) buildsCONVERSION_BY_IDfrom
ALL_CONVERSIONSand calls each step'sconversion.apply(...)directly — it does notcall
applyConversionsat all, so it never reaches the option. The "fixture CI" half of theold sentence IS true (
conversions.test.tspassesincludeRetired: truefor the fixturepairs) and is kept.
The pin, and why the existing tests were not one
The three tests in the
normalizeStackInput integration (the load seam)block all drivelive entries. Every existing assertion about the retired split drives
applyConversionsdirectly with and without
includeRetired. So ifnormalizeStackInputever began passingincludeRetired: true, every one of them would stay green and the corrected sentence wouldgo stale exactly the way the old one did.
The new case drives the two real doors over the same bytes:
normalizeStackInputleavesan authored
hidden: trueapp verbatim with zero notices, whileapplyConversionsToStoredItemover the same object returns_unpublished: truewithhiddengone. It opens with a premise assertion that the entry under test is in factretiredFromLoadPath, so it cannot pass vacuously if someone un-retires it.Out of scope, named here so they are not lost
This round is the
packages/specshare only. Three other carriers of the same false claimare not touched:
docs/adr/0087-metadata-protocol-upgrade-contract.md— still carries thepre-addendum claim that a retired entry is skipped by the loader, superseded by its own
## Addendum (2026-07-31)some 35 lines below; and the artifact-ingestion door has noaddendum there at all, so a reader of the ADR sees the stored-row policy and nothing
about the boot seam.
docs/adr/**is a governed surface: a separate draft-only PR with ahuman merge.
.claude/skills/spec-property-retirement/SKILL.md— teaches future retirementauthors a guarantee the seams do not give, and cites
field-required-notnull-explicitasits worked example, an entry ADR-0087's
field-required-notnull-explicitconversion asserts an implication ADR-0113 abolished — the boot calls it a forward conversion, but its output and the source it prescribes disagree at the storage layer #16693 removed from the registry..claude/**is governedand belongs to the
domain:skillslane.docs/protocol-upgrade-guide.md— generated bypackages/spec/scripts/build-upgrade-guide.ts, which walksMIGRATIONS_BY_MAJORstepsand therefore renders only 50 of the 75 retired entries; the within-major 17.x
retirements are wired to no step and are absent from the guide entirely. Not "fixed" here.
Separately: #17885 carries the default-flip question this correction does not answer —
whether the artifact door should keep replaying entries whose old and new shapes are both
legal and mean different things. This PR only stops the flag from claiming it prevents
that.
Verification
All readings below were taken in a dedicated worktree at base
be5c60291c, and the finalcommit is
4c82fc7c49.Both spec vitest projects, on the final commit (CI runs both):
pnpm --filter @objectstack/spec testlocalpnpm --filter @objectstack/spec test:reporepo(cross-corpus scanners)pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec check:generatedtest:repowas run after the final commit, since a prose diff is exactly what thosescanners read. Heavy runs went through
scripts/pm/os-verify-lock.sh(
VERDICT command-exit 0).Gate families:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderived 79 families for this change set. All 79 are accounted for with recorded exit codes:
75 exit 0, and 4 NOT MEASURED —
check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debteachexited 3, the code a gate uses when refusing its own prerequisite (they need builds of
packages this diff does not touch). Exit 3 is not a red and is not reported as one; CI builds
the tree first and runs them for real.
Ablation of the new pin (mutate on disk, prove it landed, RED, restore, prove the
restore, GREEN). The mutation is the exact drift the pin exists to catch: make
normalizeStackInputpassincludeRetired: true.The ablation needs no rebuild: the test reaches its subjects through same-package relative
specifiers (
../shared/metadata-collection.zod.js,./stored.js), which vitest resolves tosrc/, not todist/. Restore isgit checkout HEAD -- PATH(never a bare checkout, whichreads the index), under an
EXIT INT TERMtrap with an absolute path, and it is proven byblob hash rather than by an exit code.
Changeset: owed, and measured rather than asserted.
packages/specfiles[]shipsdistplussrc/**/*.zod.ts— neither edited file is a.zod.ts, so the sources themselvesdo not publish (negative control: both are absent from
npm pack --dry-run --json, whosetarball has 2012 entries including 200
.zod.tssources, so the list is selective rather thanempty). What does publish is the TSDoc: after a real build the corrected text appears in two
shipped declaration files,
dist/index.d.tsanddist/types-DBIjqK7N.d.ts, both confirmedpresent in the pack listing. Controls: an untouched TSDoc line (
Stable, kebab-case id)reaches
.d.tstoo, while a function-body comment from the same edit(
graduated chain history) reaches 0.d.tsfiles and the exact corrected phrase reaches0
dist/*.jsfiles. So apatchchangeset is owed for text an upgrading author reads intheir editor, and
skip-changesetwould be wrong.Lint: narrowed to the three changed files and declared as such —
eslint --no-inline-config --format jsonover them reports 3 files, 0 errors, 0 warnings(file count read from the JSON, not estimated). The universe read from ESLint's own config
rather than guessed: its API selects 6670 files at the repo root. Invariance: this repo's
single
eslint.config.mjsnever enables type-aware linting for any file — noparserOptions.project, no typed@typescript-eslintrules (the config says so at its ownline 328 and cites the positive control it was measured with) — so a comment-and-test diff in
three files cannot move the verdict on any file it does not touch. The repo-wide run is CI's.
Generated by Claude Code