Repository navigation
Commit c9761cd
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
File tree
- .changeset
- content/docs/ui
- packages
- metadata-core/src
- metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
65 | | - | |
| 65 | + | |
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
71 | | - | |
| 71 | + | |
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
322 | 322 | | |
323 | 323 | | |
324 | 324 | | |
325 | | - | |
326 | | - | |
327 | | - | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
328 | 335 | | |
329 | 336 | | |
330 | 337 | | |
| |||
0 commit comments