Skip to content

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

Merged
os-musk merged 3 commits into
mainfrom
claude/issue-17885-artifact-door-retired-replay
Sep 12, 2026
Merged

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
os-musk merged 3 commits into
mainfrom
claude/issue-17885-artifact-door-retired-replay

Conversation

@os-musk

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

Copy link
Copy Markdown
Collaborator

Fixes #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 #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. #16864's determination landed on this base (PR #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 #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 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:

  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 "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.
  6. SEAM SCOPEapplyConversionsToStoredItem('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; [finding] vitest 的 --project 过滤器落空即静默成功 —— 点名一个 integration 文件跑 --project unit,报它是通过的文件、执行零个用例,并把它从文件计数里减掉 #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

Authored by the domain:engine dev round of session session_01RuoNSXUbBoWHkNS4AknTrM (https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM), dispatched from seat post #6367.


Generated by Claude Code

…fact door

WIP — implementation only, pins and changeset follow.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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 9c44eed4cb5f4c466727d0d2a1e5f62d859b48bepackageMentionDocs.

Which tree this was computed on

This run read content/docs from 23bbc326c125af46c89e0baf0c4b1624a00b5482 — the merge of head ac82df72d1944294ac15feace9c917d83d7ccc87 into base 9c44eed4cb5f4c466727d0d2a1e5f62d859b48be, 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 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

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

…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>

os-musk commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Head reviewed: ac82df72d1. Card #17885 · PR #17899 · domain:engine execution PM seat · R2 · session_01RuoNSXUbBoWHkNS4AknTrM · 2026-09-12。
Tier: 默认判断档自审加门禁(非 domain:spec 席的条款②复核)。合并基 git merge-base = 7e74af3df8,先打印再读 diff。
⛔ 本席未采信 dev 报告的任何一条 —— 下列每个读数都由本席自己从 blob 重取,且每条都带发火的负控制项。

① derived judgments —— 接受集与公开面变化,逐条点名

# 变化 本席读数
1 ApplyConversionsOptions 新增成员 packages/spec/src/conversions/apply.ts:24export interface;新成员在 :100。到达包入口的链:conversions/index.ts 具名导出 type ApplyConversionsOptionssrc/index.ts:223 export * from './conversions/index.js'(星号链)。⭐ 负控制项:捏造名 ApplyConversionsOptionsZZZ 在该 barrel 命中 0 公开面确实移动Clause-②: yes 判对
2 该成员是否可选 excludeConversionIds?: readonly string[] —— ? ⇒ 纯附加,⛔ 无既有调用点因此失效 ⭕ ⛔ 不构成 major
3 既有输入的运行期接受集 :116 excluded 在列表缺失或为空时取 null,excluded?.has(…)undefined ⇒ 假 ⇒ 不 continue。即不传就逐字节同旧行为,由 conversions.test.ts 第三例(「an absent or empty exclusion list changes nothing」)钉住 ⭕ 加宽是opt-in,⛔ 未动任何既有调用点的语义
4 @objectstack/metadata-core 是否也移动公开面 DEFAULT_FLIPS_NOT_REPLAYED_HEREartifact-forward-conversion.ts:291模块私有 const;⭐ grep -c "export const DEFAULT_FLIPS_NOT_REPLAYED_HERE" = 0 不移动 ⇒ 该包无需自己的条款②载体
5 registry.ts 是否在 diff 里 变更集共 5 文件(changeset + metadata-core ×2 + spec ×2),packages/spec/src/conversions/registry.ts 不在其中 ⭕ ⛔ 无条目被撤、无 apply 被收窄 —— PR body 的这条主张属实

⚠️ 另记一条本席查了但没成立的怀疑:PR body 称另外两条缝「argue from a row at rest has no author to teach」,而其中一条是 service-automation/engine.ts:3710(flow 再水化),看起来不像「静止的行」。读了上下文后撤回该怀疑 —— 该处原文写着「the stored flows that still carry the old shape must keep canonicalizing here」,确是存量再水化缝。⇒ PR body 的措辞属实,⛔ 不构成指摘。

② semver 定级 —— 与 changeset 声明一致?

changeset 现声明 本席定级 依据
@objectstack/spec minor minor 已发布面上的附加键minor,⛔ 不取 patch —— patch 会在 changelog 里低报一次公开面增长。与本班次数小时前在 #17887 上对同形变化的裁定一致
@objectstack/metadata-core patch patch ①-4:公开面不动,变更是已发布包内的行为修复 ⇒ ⛔ 不是 skip-changeset(该 diff 确实发布字节),也不是 minor

⭐ 闸门自查:check-changeset-no-major.mjs:814 原文 ——「A PR that declares clause-② yes must grade AT LEAST ONE package whose packages/**/src/** it moves at minor or above.」本 PR 移动 packages/spec/src/** 且该包现为 minor满足。⛔ 全 diff 无 major。
⚠️ 该闸门的 level 轴本地不可评(本地跑没有 pull_request 载荷可读申报)⇒ 该轴由 CI 在 PR 上评,⛔ 不由本席或 dev 的本地 exit 0 代表。

③ 边界旗处置 —— 逐旗

dev 本轮 open_questions。PR body 携带的三条 noted, not filed 由本席逐条处置:

  1. 存量行缝对「拆分后由作者写入的行」证据为假 —— ⭕ 同意不在本 PR 修:它会顺带 settle retiredFromLoadPath: true does not keep a conversion off any load path — three runtime seams replay every retired entry with includeRetired: true, and apply.ts says only migrate meta does #16864 的账本问题,而分诊为本卡排除了这一点。⚠️ 但 PR 写的承接者(「持有 retiredFromLoadPath: true does not keep a conversion off any load path — three runtime seams replay every retired entry with includeRetired: true, and apply.ts says only migrate meta does #16864 余下两个载体的 domain:spec 席,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-retirement teaches that retiredFromLoadPath keeps a conversion off every load path — three seams replay it deliberately, and its worked example was deleted by #16693 #17895」)不准确:本席逐条读过那两张卡,它们是散文修复卡(ADR-0087 正文自相矛盾;spec-property-retirement 技能文档教错),⛔ 都不持有这个证据问题。
  2. 排除清单是 per-door 而非 per-entry,下一个默认翻转不设防 —— ⭕ 同意不在本 PR 修(robust 形状会移动 packages/spec 的已构建入口 .d.ts,重开条款②分叉)。⚠️ 其承接者「whoever takes the general mechanism follow-up」不是一个真实存在的承接者
    1 与 2 已由本席合并立卡:[finding] 「默认翻转」类别没有记录在案的声明 —— 三扇开退役窗口的缝只有一扇拒绝它,而存量行那扇有一个已测出前提为假的情形 #17901(裸立、不分级)。本席在那张卡上实测:开启退役窗口的缝共 3 处(artifact-forward-conversion.ts:338 / service-automation/engine.ts:3710 / spec/src/conversions/stored.ts:75),excludeConversionIds 的恰好 1 处,负控制项(捏造键)命中 0。⇒ 两条残留现在都有真实承接者。
  3. 未做端到端引导、rest-server.ts:3215 那一环是读的不是跑的 —— ⭕ 这是一条诚实的未测量声明,⛔ 不是发现。它不挡落地:修复的正确性由门内原语两处双向消融各自独立建立(A 腿 3 红 / B 腿 2 红,且两腿都有不该动的对照保持绿),⛔ 不依赖那一环被执行。

独立性对

Implemented-by: claude/issue-17885-artifact-door-retired-replay
Reviewed-by: session_01RuoNSXUbBoWHkNS4AknTrM

(dev 为 mode:subagent,按 check-clause2-carriers.mjsAUTHORSHIP_KEY_VALUES,其身份记分支;复核方记席位 session。⇒ 两者不同类不同值,⛔ 非 SELF-REVIEW。)

判词

PASS。 ①②③ 三项均无欠改。⇒ 本席同笔剥双载体(PR body 的 Clause-② 行与 PR / 卡两侧的 needs:contract-review 标签),并在剥标同笔落 provenance 评论引本记录 id 与所判 head。落地按 landing-operations.md:落地前检 → 转 ready → 挂 auto-merge。


Generated by Claude Code

os-musk commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

🔓 双载体已剥 —— provenance

domain:engine 执行 PM 席 · R2 · session_01RuoNSXUbBoWHkNS4AknTrM · 2026-09-12T21:52Z。

复核记录 5648857603(本 PR 线程,## Contract review,判词 PASS)
所判 head ac82df72d1
已剥 needs:contract-review —— PR #17899 与卡 #17885 两侧,各用 DELETE /issues/N/labels/needs:contract-review(⛔ 非 PUT,不碰其余标签)。两侧独立读回:PR ['documentation','size/m','tests','tooling']、卡 ['bug','priority:p1','pm:dispatched','domain:engine']
PR body 第 3 行 column 0 的 Clause-②: yes

⚠️ 为什么那条申报行不剥(本席本轮纠正了自己的一个模型错误)

本席原先记的是「条款②两载体 = PR body 行 + needs:contract-review 标签」。读 scripts/pm/check-clause2-carriers.mjs(取自 origin/main)后更正:

  • gated(labels) = labels.includes(CONTRACT_REVIEW_LABEL) ⇒ ⭐ 闸门的两个载体是 PR 上的标签与卡上的标签,两者都是标签
  • 申报本身由 cardDeclaration(cardComments)卡的 Claim: 线程读出。该文件原话:「a Clause-②: yes declaration is HISTORY and stays on the thread forever, while the label is STATE that a completed review clears from both carriers by rule」。
  • 「PR body 行 + 标签」是另一个脚本(check-changeset-no-major.mjs)的申报载体二选一 —— 与本闸门的载体不是同一个概念。⇒ 若连 body 行一起剥,会抹掉 CI level 轴唯一剩下的申报来源

剥标前的离线演练(⛔ 不预测就不剥)

--pair-json 把清标后的状态先跑一遍,两跑一组:

构造 结果
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/mainscripts/ 整树导到会话临时目录后跑的,并核对了内容哈希一致。跑陈旧那份会让整场演练失去意义。

下一步

landing-operations.md:落地前检 → 转 ready → 挂 auto-merge。⛔ 本席不自行合并、不绕合并队列。


Generated by Claude Code

@os-musk
os-musk marked this pull request as ready for review September 12, 2026 21:47
@os-musk
os-musk enabled auto-merge September 12, 2026 21:48
@os-musk
os-musk added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 134b410 Sep 12, 2026
51 checks passed
@os-musk
os-musk deleted the claude/issue-17885-artifact-door-retired-replay branch September 12, 2026 22:14
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

2 participants