fix(rest,metadata-protocol): a public form's intake withdrawal at any metadata layer holds; layering can only narrow intake - #21864
Conversation
… metadata layer holds; layering can only narrow intake Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…showcase boot Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
📓 Docs Drift CheckThis PR changes 3 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 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 1d9cbab08cdd36c39c778e14ae4158b77fb235ca && git checkout 1d9cbab08cdd36c39c778e14ae4158b77fb235ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9dce635337c2cc42a4149aa49289ad77d172363d 46d08189a70712bc3ce1e0bcf89f0505725172bd && git checkout -B drift-repro 9dce635337c2cc42a4149aa49289ad77d172363d && git merge --no-ff 46d08189a70712bc3ce1e0bcf89f0505725172bd
node scripts/docs-audit/affected-docs.mjs --json 9dce635337c2cc42a4149aa49289ad77d172363d
|
…ening Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…d only when explicit A withdrawal is judged by view identity (name + slot) across layers: other views sharing a slug never close each other. Only a sharing that keeps the link and clears a switch withdraws; a linkless sharing (raw or schema-parsed) does not. The org-scoped write refusal judges the doors' verdict for every form the save leaves open, over the container's list-read expansion. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…withdrawal is refused Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Contract reviewServed-tier: Inputs read: card #21835 (body and all six comments: triage, claim, os-dev-report round 1, claim correction, the two refining rulings, os-dev-report round 2), PR #21864 (body, 10-file list, net diff against ① Derived judgmentsAccept-set changes:
Public-surface changes (package
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…r slug, and the write door anchors identity on the stored row Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…rm-withdrawal-kill-switch
…v-wide definition as the switch A package artifact that keeps its public link and never switches `enabled` on carries an explicit `false` once parsed, and that is a withdrawal: an organization-scoped save that opens it is refused. An env-wide save may open a form the package ships closed, and the env-wide list then serves that open body. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…nown limit The write door judges an organization-scoped save or publish by the stored row, so renamed keys, `form.name`, slot moves and expansion renames are the same form. The anonymous doors judge by the served item name, by slot or slug, and withdraw only on an explicit false. A package's parsed false is a withdrawal, and the env-wide definition may open a form the package ships closed. An overlay stored before the withdrawal, or restored by rollback or revert, that keeps the form open under another key or slot is a known limit. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #21835 (body, the triage, the claim and its correction, the three os-dev-reports, both ruling comments 5994082238 and 6005722623), the PR #21864 body and its 10-file list, the net diff ① Derived judgments
② Semver levelChangeset:
Clause-②: yes (widening). The same line appears in the PR body, in the changeset body, and in the claim correction 5993499550. The original claim's ③ Boundary flags
Implemented-by: VERDICT: PASS |
…rm-withdrawal-kill-switch
… package A draft is keyed by its package too (ADR-0048), so two packages can each hold a draft of the same view in one organization. The publish gate now reads the draft under the same package key the promotion uses: the stated binding, or, when none is stated, the binding of the draft row resolved once and then stated to both the read and the promotion. The judged body is the body that becomes active. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…f a name The env-wide view list serves one item per package for a name (ADR-0048), so the withdrawal judge compares a layer body only when it and the served view are bound to the same package, or either is bound to none (the package-less definition stands in for every package's row). The write door carries the package a row is saved under the same way. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
…ema-parsed artifacts A package is part of a view row's identity, so one package's withdrawal closes only its own form of a name, and a package-less definition applies to every package's row of it. The schema's default `enabled: false` reaches an artifact only through the stack schema's parse; an artifact loaded without it is judged as written. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
A publish that states no package now reads the pending draft row's package binding once, so the seed self-apply double's engine answers that read (no row) and the repository double answers the rest, as before. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #21835 (body and all 9 comments, rulings 5994082238 and 6005722623, os-dev-reports through round 5, 6007561084), PR #21864 (body, 11 files, net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL FAIL reasons:
Pending check-runs at this head (not waited on): Check Changeset (1 of 3 runs) · Test Core 1/6 to 6/6 · Dogfood Regression Gate 1/3 to 3/3 · Dogfood Verify CLI · Temporal Conformance (live PG + MySQL) · Build Core · Type Check · workspace · Type Check · consumer gates · Type Check · debt ledger · Lint & Repo Gates. |
…rm-withdrawal-kill-switch
Fixes #21835
Clause-②: yes (widening)
Fixes a regression introduced after 17.6.0 (with #21420); it should land before 17.7.0 is cut.
What
Per the rulings recorded on #21835: a public form's withdrawal is a kill switch, layering can only narrow anonymous intake, a withdrawal closes the same form only, and only an explicit withdrawal counts.
GET /forms/:slug,POST /forms/:slug/submit). Both use one resolver and judge by the name of the view item they serve. When an organization is resolved, the env-wide view list beneath it is read as well. A form is served only when the env-wide item of the same name does not explicitly withdraw a form in the same slot (nested form, the sameformViewskey, or the flattened config) or with the same slug. Other views that share the public slug never close each other.publicLinkand setsenabled: falseorallowAnonymous: false. Only an explicit false counts. Not a withdrawal: an absent switch, a sharing with no link (raw, or schema-parsed), a cleared link, a removed sharing block, or no body of the view at that layer. The public data collection docs page has a "Withdraw a public form" section with these rules.viewsave or draft promotion in the organization the doors read is refused with403 NOT_OVERRIDABLEwhen it would leave open a form the env-wide definition explicitly withdraws. It judges by the stored row: the body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package artifact), matched by slot or by slug. So renamedformViewskeys,form.name, slot moves and listViews collision renames are the same form. It is also judged against the env-wide view list the way the doors read it, with container bodies expanded. Re-saving an overlay that was open before the withdrawal is refused. The message names both remedies.defineStack, the default) carries the schema's defaultenabled: false, so a shipped form that keeps its link without switchingenabledon is an explicit withdrawal and fails closed. An artifact loaded without that parse (defineStack(..., { strict: false })or a hand-built manifest) is judged as written: a switch it omits is absent, which is not a withdrawal. The env-wide definition is the administrator's switch, so an env-wide save may open a form the package ships closed.@objectstack/metadata-coreadds one export,anonymousFormIntakeWithdrawnIn(minor).@objectstack/restand@objectstack/metadata-protocolarepatch.Tests
The first bullet is round 5, the second round 4; the bullets after them were measured at
e8778acb96(round 2):d8657b5c19: metadata-core 18 files, 411 passed; metadata-protocol 216 files (3 skipped), 27938 passed, 19 skipped; rest 260 files, 4911 passed, 326 skipped; objectql 374 files, 7464 passed. Typecheck green for metadata-core, metadata-protocol, rest and objectql, test layers included. 97 of 97 derived gates green, reconciled withdispatch-gates --ran. New pins: two packages' drafts of one view in one organization are each judged on their own publish; one package's withdrawal of a name leaves another package's form of it open and closes its own (metadata-core and both doors). Ablation: removing the package key from the publish gate's draft read turned the two-package pin red, and removing the package comparison turned the cross-package pin red; both restored to HEAD (git diff HEADempty).79b847042d(targeted): metadata-coreanonymous-form-intake.test.ts39/39, metadata-protocolprotocol.org-scoped-write-refused.test.ts41/41, restpublic-form-withdrawal+public-form-intake-availability43/43. Typecheck green for metadata-core and metadata-protocol. Docs and changeset gates green. New pins: a package parsedfalseis a withdrawal; an env-wide save opens a package-closed form.@objectstack/metadata-core: 18 files, 394 passed.@objectstack/rest: 260 files, 4906 passed, 326 skipped.@objectstack/metadata-protocol: 214 files (3 skipped), 27752 passed, 19 skipped.check:skill-examples,check:dual-build-cjs-loadsandcheck:type-check-debtare NOT MEASURED locally (workspace-wide prerequisites) and left to CI.Acceptance notes
strict: false, a hand-built manifest) is judged as written; giving every load path the schema's sharing defaults is left as a possible follow-up (see the round 5 report on security(forms): a public-form setting at one metadata layer can re-open intake that another layer withdrew — 17.7 regression, detail withheld pending maintainer #21835).protocol-publish-package-drafts.test.ts); that is test-only.Generated by Claude Code