Repository navigation
fix(metadata-protocol)!: refuse the per-item publish and a package-less save over a binding to an uninstalled package - #27084
Conversation
…s save over a binding to an uninstalled package Claude-Session: https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X Co-authored-by: Claude <noreply@anthropic.com>
…ient message bound Claude-Session: https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X Co-authored-by: Claude <noreply@anthropic.com>
…binding refusals Claude-Session: https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X Co-authored-by: Claude <noreply@anthropic.com>
…in four fixtures Claude-Session: https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X Co-authored-by: Claude <noreply@anthropic.com>
…ind their rows to Claude-Session: https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 f5e0fade3745c9b9433908f8de67710adb7e10ec && git checkout f5e0fade3745c9b9433908f8de67710adb7e10ec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cbb3a1a1a5f858d2f34fcf874fc631b158fae9d1 6135db9b7195db854bb91580bdf51952e60aaf8d && git checkout -B drift-repro cbb3a1a1a5f858d2f34fcf874fc631b158fae9d1 && git merge --no-ff 6135db9b7195db854bb91580bdf51952e60aaf8d
node scripts/docs-audit/affected-docs.mjs --json cbb3a1a1a5f858d2f34fcf874fc631b158fae9d1
|
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Contract reviewServed-tier: Face: narrowing ( ① Derived judgmentsAccept-set changes the diff implies, each judged against the triage ruling (per-item publish refuses with the ONE rule; inherit arm leans refuse after measure-first; no migration, no deletion, no second copy, no new code, no
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26921
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: not needed — no
skills/**page teaches a save naming no package over a package-bound item or the per-item publish (git greponskills/for/publishanddiscard-draftsfinds one row, thepublish-draftsevent inskills/objectstack-platform/references/plugin-hooks.md, which this does not change); the docs paragraph incontent/docs/concepts/metadata-lifecycle.mdxis updated here维护者速读
?package=的保存(草稿和直接保存)若沿用了这种绑定,回 422WRITABLE_PACKAGE_REQUIRED;单项发布POST /meta/:type/:name/publish(带或不带?package=)若草稿会发布到这种包里,同样回 422。什么都不写,也不迁移、不删除旧行。DELETE ?package=ID)、可丢草稿(discard-drafts),装上同名包后又能正常保存和发布(实测)。回滚即还原本 PR,无数据迁移。packages/metadata-protocol/src/protocol.ts里uninstalledNamedBaseRefusal(新增的how参数与三种句子)、saveMetaItem中紧跟包地址解析之后的继承分支、promoteDraftForPublish中地址解析之后的发布分支。PUT /api/v1/meta/dashboard/x?package=com.example.nosuch的库上启动本分支,PUT /api/v1/meta/dashboard/x(不带包)与POST /api/v1/meta/dashboard/x/publish?package=com.example.nosuch都应回 422WRITABLE_PACKAGE_REQUIRED,GET读到的行不变。What changes
@objectstack/metadata-protocol— two more doors ask the one named-base rule (uninstalledNamedBaseRefusal, the helper the save door and the flow arm ask since metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708), about a binding the caller did not name but the item's stored rows give:packagelessAddress, and for a draft save thepackagelessDraftAddressthe object overlay check already read, now read once for every type), the package that address lands in must be installed. Refused422 WRITABLE_PACKAGE_REQUIREDwithpackageId= that id, before anything is written: "Cannot save TYPE 'NAME' without naming a package: its stored rows are bound to package 'ID', which is not installed in this deployment, and this save would extend that binding. …". It is never re-homed package-less in silence. Saves naming a package (already judged as named), the server-stated rewrites (migrate-stored,package-duplicate) and an ambiguous draft address (the repository's409, as before) are not asked.promoteDraftForPublish, soPOST /meta/:type/:name/publishnamed or not): right after the draft address is resolved, the package the draft goes live in must be installed. Refused the same way before the draft is read: "Cannot publish TYPE 'NAME' into package 'ID': no package with that id is installed in this deployment, so the TYPE would go live bound to a package that does not exist. …".how: { verb?, inherited? }and only the sentence changes. The flow arm's sentence and the named-save sentence are byte for byte unchanged. Both new sentences name the way out that exists for such rows: create a package with that id, or remove the rows bound to it (a delete naming the id;discard-draftsfor drafts) and save again. They stay under the REST door's 500-character client message bound for ordinary names (measured: a first draft of the save sentence was cut bytruncateClientMessage).packages/specis untouched.content/docs/concepts/metadata-lifecycle.mdx; changeset26921-named-base-stored-binding.md(patch, BREAKING banner, both declaration lines, ADR-0087not-required (no-migration-prescription)).Why
After #26708 (PR #26920) a save or a
publish-draftsnaming an uninstalled package id is refused, but rows bound to such an id that a database already holds (written before that change) could still be extended or published by two doors: a save naming no package inherited the phantom binding (#11087's rule), and the per-item publish served a phantom-bound draft live. Such an item has no manifest, so no namespace, and no uninstall or export.Measure-first (the order's stop condition on the inherit arm): a refusal does NOT strand a published phantom row. On a database written by
mainbefore #26920 (7eb613c3), with five dashboards bound tocom.example.nosuch(active only, active + draft, draft only):DELETE /api/v1/meta/dashboard/NAMEanswered 200 and removed the active row, with?package=com.example.nosuchtoo;DELETE …?state=draft&package=com.example.nosuchdiscarded the draft; andPOST /api/v1/packageswith that id answered 201, after which a save naming no package over the row answered 200. The served item read already reportsdeletable: true. So the arm was implemented, not handed back.Tests
New
packages/metadata-protocol/src/protocol.named-base-stored-binding.test.ts(16 tests). Rows are written the way they were in production: saved while the package was installed, then the package leaves the registry. Refusals (11): the per-item publish named (dashboard, page), naming none over a phantom draft, naming none over a phantom active row; a save naming no package over a phantom active row (dashboard, page × draft, active); a draft save over a lone phantom draft. Each assertscode,status,packageId, the first sentence, rows unchanged and nothing registered or inserted. Controls (5): saves and publishes over an installed package's rows, package-less items, and the delete naming the id still removing the row.Red first: with
protocol.tsat the base blob (c3c93949, written over and restored by hash), the file fails 11 of 16; the 5 controls pass.Fixtures (each now installs the package its rows are bound to, ⛔ none widens the rule):
protocol.batch-verb-driver-code.test.ts,protocol.batch-verb-driver-text.test.tsandprotocol.destructive-409-face-inventory.test.ts(their registry double gainsgetPackageforcom.acme.crm);objectql/src/protocol-sentinel-owner-draft-save-address.test.ts(installscom.qa.p/com.qa.q, asprotocol-adopt-orphans-object-registry.test.tsdoes).protocol.package-closure-gate.test.ts› "narrows NOTHING when the registry cannot produce the written package" pinned exactly the promotion this card refuses (a draft promoted after its package left the registry, unnarrowed,success: true). Replaced, not narrowed: renamed to "a promotion into a package the registry cannot produce is refused before the closure gate"; the installed leg still asserts the closure refusal, the uninstalled leg now asserts422 WRITABLE_PACKAGE_REQUIRED.Package suites (vitest,
--maxWorkers=2, under the verify lock), after rebuilding@objectstack/metadata-protocolso the consumers read the changeddist/:Counts
6135db9b:@objectstack/metadata-protocol277 files passed / 4 skipped, 21749 tests passed / 26 skipped;@objectstack/objectql477 files, 8893 tests passed.0842cef6(the same source as the final head for these packages, before mergingmain, whose incoming commits touch none of these doors):@objectstack/runtime8529 passed / 19 skipped;@objectstack/rest6827 passed / 344 skipped;@objectstack/client678 passed;@objectstack/plugin-security5049 passed / 45 skipped;@objectstack/cliunit layer 4951 passed (the integration layer is declared to CI).typecheckexit 0 on@objectstack/metadata-protocoland@objectstack/objectql.Acceptance notes
objectonly): twosys_metadatareads, active and draft, the readsresolveWriteHeadmakes for the same save.Verification
pnpm dev -- -p PORT --database file:DB, signed in as the seeded dev admin). The database is a copy of one snapshot written bymainbefore fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 (7eb613c3): five dashboards saved with?package=com.example.nosuch(active, active + draft, draft only), all 200 there.Red on the base (`25a697ec`, #26920 merged) vs green on this branch
com.example.nosuch)PUT /meta/dashboard/ghost_dash_a?package=com.example.nosuch(control: #26708's rule)PUT /meta/dashboard/ghost_dash_d(active row bound to the id)package=com.example.nosuchWRITABLE_PACKAGE_REQUIRED;GETstill serves labelGhost DPUT /meta/dashboard/ghost_dash_a?mode=draftGET ?state=draftanswers 404NO_DRAFT(nothing written)POST /meta/dashboard/ghost_dash_e/publish?package=com.example.nosuchGETstill serves the old active labelGhost EPOST /meta/dashboard/ghost_dash_c/publish(lone phantom draft, no package named)GET404 (still not live)POST /packages/com.example.nosuch/publish-drafts(control)POST /packagesapp.probe1(201), save into it, save naming none, draft, publish?package=app.probe1app.probe1plain_dashsave, draft, publishDELETE /meta/dashboard/ghost_dash_b?package=com.example.nosuch(the way out)GET404Inherited-save sentence as served (length 423 for these names, under the 500-character client bound): "Cannot save dashboard 'ghost_dash_d' without naming a package: its stored rows are bound to package 'com.example.nosuch', which is not installed in this deployment, and this save would extend that binding. Create a package with that id first, or remove the rows bound to it (a delete naming that package; discard-drafts for drafts) and save again, into an installed writable package or with no package. Nothing was written."
Measure-first, on the pre-fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 tree with the same snapshot:
DELETE /meta/dashboard/ghost_dash_a(no package) 200 andGET404;DELETE …ghost_dash_d?package=com.example.nosuch200;DELETE …ghost_dash_b?state=draft&package=com.example.nosuch200 and the draft read 404NO_DRAFT;POST /packageswith idcom.example.nosuch201, thenPUT /meta/dashboard/ghost_dash_enaming none 200 with the row still bound to that (now installed) id. The two doors this PR closes are not on any of those paths.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the final head6135db9b: 101 commands, each run with its exit code captured before any pipe, all 101 exit 0.--ranreconciliation: "101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN".The two first-run reds and what cleared them
check:engine-double-contractexit 1: the new test file's engine double answersdelete,findOneandupdatethe ledger did not record ("New pinned coverage is GOOD … Run--write"). Recorded withnode scripts/check-engine-double-contract.mjs --write(commit6135db9b), then exit 0.check-plugin-teardown-shape.mjs --self-testexit 3: the shallow clone lacked its positive-control commit; aftergit fetch --depth=1 origin 621a4876…, exit 0. Not a finding.scripts/**now in the change set); those 8 were run on6135db9b, all exit 0.pnpm --filter @objectstack/dogfood testunder the verify lock, after a full build, 116/116 tasks) on16162380(this branch after mergingmainat3230bdbf):VERDICT command-exit 0, Test Files 346 passed / 3 skipped, Tests 2794 passed / 20 skipped. The only commit after it,6135db9b, adds three rows toscripts/engine-double-contract.pinned.json, which no dogfood test reads.Human Minimum
packages/specholds no runtime logic. (packages/specuntouched.)pnpm lintpasses. (Root ESLint--no-inline-configon the 7 touched TS files: exit 0.)main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X