fix(metadata-core,spec): the artifact door stops replaying the default-flip class, so an authored hidden: true app is no longer registered unpublished - #17899
Conversation
…fact door WIP — implementation only, pins and changeset follow. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
…lip class Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 2 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 23bbc326c125af46c89e0baf0c4b1624a00b5482 && git checkout 23bbc326c125af46c89e0baf0c4b1624a00b5482
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c44eed4cb5f4c466727d0d2a1e5f62d859b48be ac82df72d1944294ac15feace9c917d83d7ccc87 && git checkout -B drift-repro 9c44eed4cb5f4c466727d0d2a1e5f62d859b48be && git merge --no-ff ac82df72d1944294ac15feace9c917d83d7ccc87
node scripts/docs-audit/affected-docs.mjs --json 9c44eed4cb5f4c466727d0d2a1e5f62d859b48be |
…ersions key `ApplyConversionsOptions` is an `export interface` in `packages/spec/src/conversions/apply.ts`, reached from the package entry through the star chain `export * from './conversions/index.js'` in `packages/spec/src/index.ts`. The new `excludeConversionIds?: readonly string[]` member is therefore an additive key on an already-published surface, which grades `minor`, not `patch` — a `patch` under-reports public-surface growth in the changelog. `@objectstack/metadata-core` stays `patch`; the changeset prose is unchanged. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead reviewed: ① derived judgments —— 接受集与公开面变化,逐条点名
② semver 定级 —— 与 changeset 声明一致?
⭐ 闸门自查: ③ 边界旗处置 —— 逐旗dev 本轮
独立性对Implemented-by: claude/issue-17885-artifact-door-retired-replay (dev 为 判词PASS。 ①②③ 三项均无欠改。⇒ 本席同笔剥双载体(PR body 的 Generated by Claude Code |
🔓 双载体已剥 —— provenance
|
| 跑 | 构造 | 结果 |
|---|---|---|
| SUBJECT | 双侧标签移除 · draft:false · 两条 event 流各追加一条 unlabeled |
exit 0 + ℹ️ C6-RECORD 点名 comment 5648857603 与 head ac82df72d1 |
| ⭐ CONTROL | 同上,但不追加 unlabeled |
exit 4 + C3「the labels say bare, the events say hung」 |
⇒ 控制项发火,所以那个 exit 0 是读数,⛔ 不是空转。
origin/main 相差 1414 行删除(检出陈旧)。本席⛔ 没有跑工作区那份,而是把 origin/main 的 scripts/ 整树导到会话临时目录后跑的,并核对了内容哈希一致。跑陈旧那份会让整场演练失去意义。
下一步
按 landing-operations.md:落地前检 → 转 ready → 挂 auto-merge。⛔ 本席不自行合并、不绕合并队列。
Generated by Claude Code
Fixes #17885
Clause-②: yes
The artifact-ingestion door replayed
app-hidden-to-unpublished— a DEFAULT FLIP — over authored metadata, so a compiled artifact carryingdefineApp({ hidden: true })reached registration as_unpublished: trueand was then withheld byfilterAppForUserfrom every user withoutstudio.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
rest-server.ts:3215(the consumer)_unpublishedis the ADR-0045 gate. The defect is who WRITES_unpublished.includeRetired: falseallowRestore/allowPurgestill boots instead of dying at the tombstone. This branch's own suite proves it would break: the firing control atartifact-forward-conversion.test.tsdrives exactly that artifact through the same window.applyhidden: trueyesterday 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.retiredFromLoadPathon the entry29dd1a6ddb): both recorded decisions bought the SEAMS arm, the flag islivebut its jurisdiction is the authoring funnel and nothing else.types.tsnow 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.
applyConversionsgainsexcludeConversionIds— "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.protocolfloor, not its age — and^17.0.0is the rangecreate-objectstackstamps, 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 #16693 made forfield-required-notnull-explicit, and the WITHDRAWN block that removal left inregistry.tssays 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
retiredFromLoadPath: truedoes not keep a conversion off any load path — three runtime seams replay every retired entry withincludeRetired: true, andapply.tssays onlymigrate metadoes #16864 ledger question is not reopened.retiredFromLoadPathkeeps exactly the jurisdiction that determination gave it. This adds a seam-level refusal beside it; it does not redefine the flag.registry.tsis untouched. No entry is withdrawn, noapplynarrowed, no fixture moved. The entry still fires at the stored-row seam and throughos migrate meta, which is what its own docblock claims — narrowing the door is what makes that claim TRUE again rather than falsifying it further.packages/rest/src/rest-server.tsis 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 #16693 block directly above them:^17.0.0, runtime17.4.0:verdict === 'converted-forward'andauthoredFloor === '17.0.0'(anti-vacuity: the window really is open),apps[0].hidden === true,_unpublishedundefined, noapp-hidden-to-unpublishednotice, and copy-on-write hands back the same reference.ObjectStackDefinitionSchema.parse, which is whatMetadataPlugin._parseAndRegisterArtifactdoes atplugin.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.^99.0.0:verdict === 'authored-current', zero notices. The instrument can answer "no" for the other reason.page-kind-jsx-to-html) still fires in the subject's own window, on an artifact that also carries the hidden app.allowRestore/allowPurgestripped). This is the control this particular fix could plausibly have broken, and it is what makes "Artifacts built by released 17.x tooling are REFUSED by the 17.2 runtime: retired-key tombstones fire at artifact parse, and no artifact-ingestion door runs the ADR-0087 conversion that exists for exactly this #12772 is not reversed" a measurement rather than a claim.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 andgit diff HEADis empty. Atrapon 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, blob309b8444tof2e84cab:⭐ 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 HEADempty.B — the primitive half (
if (excluded?.has(conversion.id)) continue;removed fromapply.ts). Marker 1 to 0, blobde2efc0ctoee65383a:Restore: hash back to
de2efc0c,git diff HEADempty.Verification
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 79 commands from the real change set (5 paths vs merge base7e74af3df). All 79 run, all exit 0;--ranreconciles 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 specdist-readers that refused on a src/dist digest mismatch; the closure was built (turbo run buildover./packages/*+./packages/*/*, 72 tasks) and all seven re-run at exit 0.pnpm --filter @objectstack/metadata-core test— 16 files / 278 tests, exit 0.typecheckexit 0.pnpm --filter @objectstack/spec test— 473 files / 13453 tests, exit 0.typecheckexit 0. ⛔ No--projectnarrowing was added by this branch (spec's owntestscript carries--project localitself; [finding] vitest 的 --project 过滤器落空即静默成功 —— 点名一个 integration 文件跑 --project unit,报它是通过的文件、执行零个用例,并把它从文件计数里减掉 #17853's trap is a narrowing added over a named file, which is not done here).check:api-surfaceis green without regeneration — the published export listing does not move..d.tsfor symbol NAMES, with a fabricated-name negative control):ApplyConversionsOptions7 hits,MetadataConversion6,applyConversions8, 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:excludeConversionIdsappears once indist/index.d.ts.check-widening-tells --declaration noexits 0 (no T1/T2/T3/T4 tell), andcheck-clause2-carriers --pair 17899exits 0.packages/spec/src/conversions/registry.tsis NOT in the diff, so the dispatch's registry-plus-surface fork does not fire by its own terms. The.d.tstext change is reported here anyway, because the seat owns the declaration and this is the measurement it asked for.rest-server.ts:3215link 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:40does authorhidden: 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/NAMEvalidates againstAppSchema, which acceptshidden, so a row written after the 2026-08-09 split withhidden: trueand no_unpublishedis indistinguishable from a pre-split materialized one and is converted on rehydration. The entry's population argument ("under the old regime ahidden: truerow could only have come from the materialization path") is an argument about the OLD regime only. Not filed: it is the same ledger questionretiredFromLoadPath: truedoes not keep a conversion off any load path — three runtime seams replay every retired entry withincludeRetired: true, andapply.tssays onlymigrate metadoes #16864 answered for the flag and would settle the stored seam's evidence by side effect, which triage ruled out for this card. 承接者: thedomain:specseat holdingretiredFromLoadPath: truedoes not keep a conversion off any load path — three runtime seams replay every retired entry withincludeRetired: true, andapply.tssays onlymigrate metadoes #16864's remaining two carriers (ADR-0087 still states that a retired entry is skipped by the loader, 35 lines above its own addendum saying the opposite — and the artifact-ingestion door policy from #12772 is recorded in no addendum at all #17894,spec-property-retirementteaches thatretiredFromLoadPathkeeps a conversion off every load path — three seams replay it deliberately, and its worked example was deleted by #16693 #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 movepackages/spec's built entry.d.tsand re-open the Clause-② fork this card's dispatch draws. 承接者: whoever takes the general mechanism follow-up toretiredFromLoadPath: truedoes not keep a conversion off any load path — three runtime seams replay every retired entry withincludeRetired: true, andapply.tssays onlymigrate metadoes #16864.Authored by the
domain:enginedev round of sessionsession_01RuoNSXUbBoWHkNS4AknTrM(https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM), dispatched from seat post #6367.Generated by Claude Code