Skip to content

Commit c9761cd

Browse files
fix(metadata-protocol): org overlay withdrawal and publish gate follow-ups (package identity, judged draft, lock key, row anchor) (#21962)
Fixes #21934 Clause-②: yes (widening) Four LOW/INFO follow-ups to the public-form withdrawal work of #21864, one commit and one pin each, so any item can be dropped at review without the others. Each change is described in the card's public terms. All four land in `@objectstack/metadata-protocol`; the only other source edit is a docblock in `@objectstack/metadata-core`, plus the narrowed sentence on the public data collection docs page. ## Item 1: package identity of a served org overlay (`21f75eb892`) **Measured.** The judgement the anonymous form doors and the organization-scoped save check share (`anonymousFormIntakeWithdrawnIn`, `packages/metadata-core/src/anonymous-form-intake.ts:329`) compares no package, and the doors' lookup (`findPublicFormView`, `packages/rest/src/rest-server.ts:10735`) reads no `_packageId`. So the package stamp the list merge puts on a package-less org overlay (`packages/metadata-protocol/src/protocol.ts:2166` and `:9077`) cannot by itself make a withdrawal miss it. The item's outcome was still reachable on `main` (`a3bd157730`), through the same list merge rather than through a package comparison: the env-wide view list the doors judge against could hold only one package's item of a view name that two packages ship. Measured through the protocol's real list reads and the doors' own verdict: an overlay stored package-less before the withdrawal stayed open after one package's withdrawal and closed after the other's. **Changed.** `protocol.ts`, the list merge's view branch: only a name a stored view container's expansion writes is upserted by name. Every other name keeps one item per package that ships it (ADR-0048), as the list already served it while no view row was stored. Neither of the card's two directions applies (nothing compares packages, so marking stamped copies or reading the org row's own `package_id` changes no verdict); the fix is at the producer of the layer the doors read. No door code changes. **Pin** (`protocol.org-scoped-write-refused.test.ts`, "a package-less organization overlay, two packages shipping its view name"): the organization read serves the overlay once per package, each copy stamped with that package; for the package first and the package second in registry order, after it withdraws the name env-wide the env-wide list holds the withdrawal beside the other package's body, the doors serve no copy of the overlay, and a re-save of the overlay is refused. ## Item 2: the publish gate and the promotion are separate reads (`d1365db627`) **Measured** (H2 confirmed). `promoteDraftForPublish` reads the draft through `repo.get` to judge it (`protocol.ts:21623` at base), and `SysMetadataRepository.promoteDraft` reads the draft row again with its own `findOne` (`sys-metadata-repository.ts:969` at base). Nothing tied the two reads together, so a draft saved between them, or a draft that appeared where the gate found none, was promoted without being judged. **Changed.** A publish promotes only the draft its gate judged. `SysMetadataRepository.promoteDraft` takes an optional `expectedDraftHash` (`string | null`): when stated, the draft row it reads must carry that hash (with `null`, no draft row may exist), otherwise it throws a `ConflictError` subclass before anything is written. `promoteDraftForPublish` passes the judged draft's hash (or `null`), and answers the conflict as `409 METADATA_CONFLICT` with its own wording (publish again to judge and promote the current draft). This covers `publishMetaItem` and each promotion of `publishPackageDrafts`. A route without a new public option exists and was not taken: the existing `deriveActiveBody` callback receives the body the promotion read and could compare it with the judged body and throw. It turns a derivation hook into a guard and compares bodies instead of the stored hash the card's direction names, so the explicit option was preferred. That option is the Clause-② widening below. **Pin** ("a publish promotes only the draft its gate judged"): a draft saved after the gate read, and a draft saved where the gate judged none, are not promoted and the conflict answers; control: with no save in between, the judged draft is promoted and its draft row drained. ## Item 3: the lock lookup uses the request's package (`114ed6393c`, follow-up `7c30229b43`) **Measured** (H3 confirmed). The publish path passed `request.packageId` to `lockWriteRefusal` (`protocol.ts:21583` at base), while the gate resolves its draft key a few lines later: the stated binding, else the resolved draft row's own `package_id` (`draftKey`). Since the lock resolution reads every row and every shipping package in scope and takes the strictest lock, the package in the address decides whose lock prose the refusal carries, not whether it refuses: the INFO grade. **Changed.** The draft key is resolved before the lock check and threaded into the lock lookup. The authoring-rule narrowing to the stated package is left exactly as it is. Follow-up `7c30229b43`: with the draft-key read moved above the lock check, a store that cannot be read is answered at that read as the lock read answered it before (an unprovisioned `sys_metadata` holds no draft; any other failure is `503 SERVICE_UNAVAILABLE`, never the driver's own error). **Pin** ("a publish consults the lock of the package key it resolved"): with two packages' env-wide rows of one view both locked, a publish that states no package is refused with the lock of the draft row's own package; control: stating a package consults that package's lock. Follow-up pin ("a publish that states no package, over a store that cannot be read"): it answers 503 and promotes nothing. ## Item 4: the save check's row anchor across packages (`1e271aaae1`) **Measured** (H4 confirmed). `envWideRawViewRows` (`protocol.ts:16092` at base) returned every stored env-wide row of the name when any existed (so one package's row hid every package's artifact), and otherwise fell back to `lookupArtifactItem(type, name)` with no package key (the first package in registry order). **Changed.** The save check anchors each package's row on that package's env-wide definition: the package's own env-wide row, else the package-less env-wide row (which stands in for every package, as in the list merge), else that package's artifact, read through `shippedArtifactsOf`. **Wording.** The "never under-closes" sentence is narrowed in the `anonymousFormIntakeWithdrawnIn` docblock and in the "Known limit: packages and names" paragraph of `content/docs/ui/public-data-collection.mdx` (declared to `domain:devx` on #6023). The released changeset of #21864 is not edited; this card's changeset states the narrowing. As corrected in `3eea8f0995` after the contract review, the narrowed text keeps "a withdrawal of a view name still closes that name in every package, so it may over-close" and the statement that the organization-scoped save check judges every package's environment-wide definition of the name, and states the endpoints' one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. Reading each package's expansion separately is tracked in #21967. The narrowed text assumes items 1 and 4 both land; if item 1 is dropped, the endpoint exception in that paragraph widens to every view name two packages ship. **Pin** ("the save check anchors each package's row on that package's env-wide definition"): with the withdrawing package not first in registry order, a package-less and a package-bound org save that renames the form are refused; another package's env-wide row anchors that package only; controls: the save that keeps the form withdrawn saves, and a package-less env-wide row stands in for every package. ## Clause-② Measured against the built entry declarations, base `a3bd157730` against head `5297072f13`, comments stripped before the diff: - `@objectstack/metadata-protocol` `dist/index.d.ts`: `SysMetadataRepository.promoteDraft(ref: MetaRef, opts: {...})` gains `expectedDraftHash?: string | null;` (head line 9883). An optional input field: a widening. The only other declaration difference is comment placement. - `@objectstack/metadata-core` `dist/index.d.ts`: the declaration of `anonymousFormIntakeWithdrawnIn` (parameters `layer`, `view`, `candidate`, returning `boolean`) is byte-identical (base line 21311, head line 21316); only its docblock changed. Unchanged at the final head `3eea8f0995`: the later commits change a method body, a docblock, the docs page and a changeset, and the rebuilt declarations are identical with comments stripped. So `Clause-②: yes (widening)`, and item 2's changeset is `minor`. The other three changesets are `patch`. `@objectstack/metadata-core` carries no changeset: its edit is a comment. ## Tests Final head `3eea8f0995` (`origin/main` `76fec88b16` merged at `5297072f13`). The last commit, `3eea8f0995`, corrects wording only (the docs page, one changeset, a docblock); `packages/metadata-protocol/src` is byte-identical at `7c30229b43`, where its suites ran: - `@objectstack/metadata-protocol` at `7c30229b43`: typecheck green (`tsc --noEmit`; the edited test file is in the program, counted with `--listFiles`), full suite 218 files passed, 3 skipped; 27984 tests passed, 19 skipped. - `@objectstack/metadata-core` at `3eea8f0995`: typecheck green (both programs); 18 files, 411 tests passed. - `@objectstack/objectql` (a consumer of the protocol, against its `dist` built at `5297072f13`; the later code commit only changes an outage path): 378 files, 7507 tests passed. - Reverse verification through `scripts/ablation-replace.mjs`, each from a committed head, each item's code set back to its base shape (anchor hit once, blob changed on disk; the test imports the source, so no `dist` leg), the item's pin run, then restored and proved (blob equal to HEAD, `git diff HEAD` empty): - from `1e271aaae1` (its `protocol.ts` blob is the one at `5297072f13`): item 1: 5 red, 2 green (the two write-door re-save cases, which item 1 does not touch); item 2: 2 red, 1 green (the control); item 3: 1 red, 1 green (the control); item 4: 3 red, 2 green (the two controls); - from `7c30229b43`: item 3's follow-up, its store-failure classification removed: its pin 1 red. - Gates at `3eea8f0995`, derived by `node scripts/pm/dispatch-gates.mjs --commands` (no paths; the same 93 commands as at `5297072f13` and `7c30229b43`): 92 run green, among them `check:doc-authoring`, `check:docs-audit-scope`, the docs-audit `check-affected-docs` and `check-drift-comment`, `check:docs`, `check:nul-bytes`, and the changeset gates (`check-changeset-no-major` with this PR's payload, `check-empty-changeset`, `check-adr-0087-registration`, `check:changeset-gate-self-tests`). 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` (PREREQUISITE NOT MET: it loads every workspace package's `dist`, 32 of which were not built in this worktree; it was green at `7c30229b43`, whose code this head keeps). Reconciled: `dispatch-gates --ran` answers "93 derived famil(ies) accounted for — 92 run, 1 NOT-MEASURED". The artifact-roster block (53) is green, the PR-context gates run against this PR. The four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`) are green. ## Acceptance notes - Residual of item 1, stated in the docs: the list still upserts a stored view container's expansion by name, so where a package's environment-wide copy of a view container is saved, the anonymous doors read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. The organization-scoped save check still judges every package's environment-wide definition of the name (item 4). Keeping each package's expansion apart changes the expansion rules the list and the by-name read share; that design is tracked in #21967. - No door code changes, so no door-level dogfood case was added (declared to `domain:cli` on #6024). - The new conflict subclass is internal to `@objectstack/metadata-protocol` (not exported from its entry); callers see a `ConflictError`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 753e7a1 commit c9761cd

9 files changed

Lines changed: 466 additions & 45 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
A metadata publish consults the item lock at the package key it resolved
6+
7+
- A publish that states no package promotes the draft row it resolves, under that row's own package. Its ADR-0010 lock lookup now uses that same resolved key (the stated package, else the draft row's own), the key the gate reads the draft under and the promotion writes under, instead of only the package the request stated. Where several packages' rows declare the strictest lock, the refusal now carries the lock of the package whose draft is being promoted.
8+
- The authoring gate's package narrowing is unchanged: it still uses only the package the caller stated.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
A metadata publish promotes only the draft its gate judged
6+
7+
Clause-②: yes (widening)
8+
9+
- A publish (`publishMetaItem`, and each promotion of `publishPackageDrafts`) reads the draft to judge it and then promotes the draft row. The promotion is now handed the judged draft's hash. A draft saved after the judgement, or a draft that appears where the judgement found none, is refused with `409 METADATA_CONFLICT`, and nothing is published. Publishing again judges and promotes the current draft.
10+
- `SysMetadataRepository.promoteDraft` takes a new optional `expectedDraftHash` (`string | null`). When it is stated, the draft row the promotion reads must carry that hash (with `null`, no draft row may exist); otherwise the promotion throws a `ConflictError` before anything is written. When it is omitted, the promotion behaves as before.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
The organization-scoped save check judges a view overlay against every package's environment-wide definition of its row
6+
7+
- An organization-scoped `view` save or publish in the organization the anonymous form endpoints read is refused when it would leave open a form the environment-wide definition withdraws. Its row anchor is now resolved per package, the way the list read resolves each package's item: each package's own environment-wide row, else the package-less environment-wide row (which stands in for every package), else that package's artifact. Before, with no environment-wide row stored, it judged only the first package's artifact in registry order, and a stored row of any one package hid every package's artifact of the name.
8+
- The known limit stated with the public-form withdrawal ("it may over-close, never under-close") is narrowed. A withdrawal of a view name still closes that name in every package, so it may over-close. The organization-scoped save check judges every package's environment-wide definition of the name. The anonymous endpoints do too, with one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped, until the form is withdrawn in every saved environment-wide copy of that container as well. Reading each package's expansion separately is tracked in #21967.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
The view list serves one item per package for a view name that several installed packages ship, whether or not a view row is stored
6+
7+
- Where two installed packages ship a view of the same name, the list read (`getMetaItems` for `view`) now serves each package's item of that name, as it already did while no view row was stored. Only a name that a stored view container's expansion writes is upserted by name.
8+
- The environment-wide view list is the layer the anonymous form endpoints judge a withdrawal against. A package-less organization copy of the view, stored before one package withdrew the form, is now judged against every package's body of the name there, so it stays closed whichever package withdraws.

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ A withdrawal is a kill switch across metadata layers. If the environment-wide de
6262

6363
**Which form a withdrawal closes.** Two checks apply the rule, and they match forms differently:
6464

65-
- **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view.
65+
- **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by, in each package that ships it). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view.
6666
- **The anonymous endpoints** judge each form by the name of the view item they serve. Beneath the organization's read they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place or under the same link.
6767
- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it.
6868

6969
**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed.
7070

71-
**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name. This may close more than was meant, but it never leaves a withdrawn form open. Per-package precision is tracked in #21934.
71+
**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. The organization-scoped save check judges every package's environment-wide definition of the name. The endpoints do too, with one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. To close such a form at the endpoints, withdraw it in every saved environment-wide copy of that container as well. Reading each package's expansion separately is tracked in #21967.
7272

7373
**Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused.
7474

‎packages/metadata-core/src/anonymous-form-intake.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,16 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string;
322322
* and closes nothing. The package a body is bound to is NOT compared: a
323323
* withdrawal of a name closes that name's form in every package (a known
324324
* limit that fails closed: it may over-close another package's form of the
325-
* same name, never under-close). A layer with no body of the row, or whose body has no
326-
* explicit withdrawal, withdraws nothing, so a form published only in an
327-
* organization stays open there.
325+
* same name). It judges only the bodies the layer holds, so a caller closes a
326+
* name in every package only when its layer holds every package's body of the
327+
* name. The organization-scoped write door anchors one body per package. The
328+
* env-wide view list the anonymous doors read holds one item per package of a
329+
* name, with one exception: where a package's env-wide copy of a view
330+
* container is saved, the list holds that copy's expansion alone for each form
331+
* it expands, so the doors can miss another package's withdrawal of that
332+
* form, whether saved or shipped (per-package expansion is #21967). A layer
333+
* with no body of the row, or whose body has no explicit withdrawal, withdraws
334+
* nothing, so a form published only in an organization stays open there.
328335
*/
329336
export function anonymousFormIntakeWithdrawnIn(
330337
layer: ReadonlyArray<unknown>,

0 commit comments

Comments
 (0)