Skip to content

docs(spec): state the authoring-surface jurisdiction retiredFromLoadPath actually has - #17888

Merged
os-bill merged 1 commit into
mainfrom
claude/issue-16864-retired-from-load-path-declaration
Sep 12, 2026
Merged

os-bill merged 1 commit into
mainfrom
claude/issue-16864-retired-from-load-path-declaration

Conversation

@os-bill

@os-bill os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16864

Clause-②: no — declaration prose only. No schema key moves, no accepted-set change, no export change, no behaviour change.

What this changes

retiredFromLoadPath declared a jurisdiction it does not have. Two carriers in
packages/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

Retirement is an authoring-surface event. The flag keeps the entry off
normalizeStackInput — the authoring funnel for defineStack, validate, lint,
compile, info and doctor — so a live author meets the tombstone instead of a
silent rewrite. Data-at-rest load paths replay it deliberately: stored-row
rehydration, flow rehydration in the automation engine, and the artifact-ingestion
door. Per ADR-0087's ## Addendum (2026-07-31) and the artifact-door ruling on #12772.

Three files:

  • packages/spec/src/conversions/apply.ts — the includeRetired option docblock, the
    inline comment in the conversion loop, and the module docblock's claim that
    normalizeStackInput is "the single seam every load path funnels through" (it is the
    single seam every authoring path funnels through).
  • packages/spec/src/conversions/types.ts — the MetadataConversion.retiredFromLoadPath
    field 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 3 includeRetired: true
literals; the set of runtime callers reaching them is 4, because
applyArtifactForwardConversions has two non-test callers.

literal what it is non-test callers
packages/spec/src/conversions/stored.ts:75 applyConversionsToStoredItem — includeRetired is PINNED, not a caller's choice (StoredConversionOptions omits the key) every stored-row rehydration seam: metadata-protocol/src/protocol.ts, metadata/src/loaders/database-loader.ts, objectql/src/plugin.ts, services/service-datasource/src/datasource-admin-plugin.ts
packages/services/service-automation/src/engine.ts:3710 flow rehydration, at runtime 1 (the engine itself)
packages/metadata-core/src/artifact-forward-conversion.ts:291 the artifact-ingestion door, inside its declared-floor window 2 — metadata/src/plugin.ts:763 and runtime/src/app-plugin.ts:907

Control for that count: the same grep without the ':!*.test.ts' exclusion returns
strictly 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 meta never passes includeRetired anywhere.
applyMetaMigrations (packages/spec/src/migrations/chain.ts) builds CONVERSION_BY_ID
from ALL_CONVERSIONS and calls each step's conversion.apply(...) directly — it does not
call applyConversions at all, so it never reaches the option. The "fixture CI" half of the
old sentence IS true (conversions.test.ts passes includeRetired: true for the fixture
pairs) 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 drive
live entries. Every existing assertion about the retired split drives applyConversions
directly with and without includeRetired. So if normalizeStackInput ever began passing
includeRetired: true, every one of them would stay green and the corrected sentence would
go stale exactly the way the old one did.

The new case drives the two real doors over the same bytes: normalizeStackInput leaves
an authored hidden: true app verbatim with zero notices, while
applyConversionsToStoredItem over the same object returns _unpublished: true with
hidden gone. It opens with a premise assertion that the entry under test is in fact
retiredFromLoadPath, 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/spec share only. Three other carriers of the same false claim
are not touched:

  1. docs/adr/0087-metadata-protocol-upgrade-contract.md — still carries the
    pre-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 no
    addendum 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 a
    human merge.
  2. .claude/skills/spec-property-retirement/SKILL.md — teaches future retirement
    authors a guarantee the seams do not give, and cites field-required-notnull-explicit as
    its worked example, an entry ADR-0087's field-required-notnull-explicit conversion 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 governed
    and belongs to the domain:skills lane.
  3. docs/protocol-upgrade-guide.md — generated by
    packages/spec/scripts/build-upgrade-guide.ts, which walks MIGRATIONS_BY_MAJOR steps
    and 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 final
commit is 4c82fc7c49.

Both spec vitest projects, on the final commit (CI runs both):

command project result
pnpm --filter @objectstack/spec test local exit 0 — 472 files, 13435 tests passed
pnpm --filter @objectstack/spec test:repo repo (cross-corpus scanners) exit 0 — 30 files passed
pnpm --filter @objectstack/spec typecheck — exit 0
pnpm --filter @objectstack/spec check:generated — exit 0 — all 15 generated artifacts up to date, nothing to regenerate

test:repo was run after the final commit, since a prose diff is exactly what those
scanners 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/objectstack
derived 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-closure and check:type-check-debt each
exited 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
normalizeStackInput pass includeRetired: true.

anchor occurrences BEFORE = 1   injected occurrences BEFORE = 0
anchor occurrences AFTER  = 0   injected occurrences AFTER  = 1
HEAD blob     3456f9d4ecff3e5109ddf5b19b2649d661c0f251
mutated blob  df88fd0081df1bd7cd83c87bdbe700654a9b03ab   (differs -> the mutation landed)
RED   leg: vitest exit 1 — "does NOT replay a RETIRED entry, while the stored-row door does" FAILS
          (1 failed | 194 passed — the mutation is aimed, not a blanket break)
restored blob 3456f9d4ecff3e5109ddf5b19b2649d661c0f251   (== HEAD -> restore proven by hash)
          git diff HEAD: empty · git status --porcelain: empty
GREEN leg: vitest exit 0 — 195 passed

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 to
src/, not to dist/. Restore is git checkout HEAD -- PATH (never a bare checkout, which
reads the index), under an EXIT INT TERM trap with an absolute path, and it is proven by
blob hash rather than by an exit code.

Changeset: owed, and measured rather than asserted. packages/spec files[] ships
dist plus src/**/*.zod.ts — neither edited file is a .zod.ts, so the sources themselves
do not publish (negative control: both are absent from npm pack --dry-run --json, whose
tarball has 2012 entries including 200 .zod.ts sources, so the list is selective rather than
empty). What does publish is the TSDoc: after a real build the corrected text appears in two
shipped declaration files, dist/index.d.ts and dist/types-DBIjqK7N.d.ts, both confirmed
present in the pack listing. Controls: an untouched TSDoc line (Stable, kebab-case id)
reaches .d.ts too, while a function-body comment from the same edit
(graduated chain history) reaches 0 .d.ts files and the exact corrected phrase reaches
0 dist/*.js files. So a patch changeset is owed for text an upgrading author reads in
their editor, and skip-changeset would be wrong.

Lint: narrowed to the three changed files and declared as such —
eslint --no-inline-config --format json over 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.mjs never enables type-aware linting for any file — no
parserOptions.project, no typed @typescript-eslint rules (the config says so at its own
line 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

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 136 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 2266438ce0ba039634f9dcec06db7be12c0d58ee → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cb9e607c6247b5075cc0cd93a64499229af9a775 — the merge of head 4c82fc7c49ee47428edf066f2d73d3a5e94db7e1 into base 2266438ce0ba039634f9dcec06db7be12c0d58ee, 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 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

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

@os-bill
os-bill marked this pull request as ready for review September 12, 2026 18:34
@os-bill
os-bill added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 29dd1a6 Sep 12, 2026
37 checks passed
@os-bill
os-bill deleted the claude/issue-16864-retired-from-load-path-declaration branch September 12, 2026 19:42
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants