Skip to content

Commit db87a02

Browse files
fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved (#21979)
Fixes #21967 Clause-②: no Another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved. This is triage's direction A (6012036101, unlocked by 6013043442): the view list's expansion upsert is keyed per package. A package-bound container row writes only its own package's slot, and a package-less row stands in for every package with no row of its own (ADR-0048). The by-name read selects through the same function, so the list and the by-name read agree. All source edits are in `@objectstack/metadata-protocol` (`packages/metadata-protocol/src/protocol.ts`). The other edits are a docblock in `@objectstack/metadata-core` and the done-when sentence on `content/docs/ui/public-data-collection.mdx` (declared to `domain:devx` on #6023). No `rest-server.ts` edit, no `packages/spec` edit, no governed path. ## Commits (each can be read on its own) 1. `2981f6eb78` test: the pins below, committed red against the base `protocol.ts`. 2. `c457f99935` the view list's expansion upsert, per package (the card's direction A): - `servedViewExpansion` (new, module level) picks the expansion that serves a name at a package's address. The package's own container row's expansion comes first, then a package-less one's, never another package's. Within one package the last expansion still wins. - The package order is the package dimension of `servedOverlayRowCandidates` (#21804's one prefer-local resolution, "no second resolver"). - `readFlattenedMetaItems`' view branch serves that function's answer for each slot of a name (the package of a listed item, or of a container row that expands it). A slot neither reaches keeps its item. - A package-less container's expansion standing in for a package is stamped with that package, as a package-less row standing in is, so the list's last pass grafts that package's envelope, as the by-name read does. - A list scoped to a package expands the package-less container rows as stand-ins too, in the slots the package seats only. Membership does not grow (#21817). - `resolveRowlessExpandedView` reads, when it names a package, the package-less rows as the scoped list reads them, and selects through `servedViewExpansion`. 3. `c966e63a0a` the own-row test (#21510's one predicate, `namesWithOwnStoredRow`) takes the package whose slot is being filled. A row counts when it is bound to that package or package-less; with no package, every row counts. The list asks it per slot and the by-name read for the package it names, so it is still the one predicate both doors ask. - **Why it is in this PR:** without it, one package's stored row of a form's exact name kept every other package's copy expansion of that name out of the env-wide list. On an environment-scoped kernel the endpoints then missed a withdrawal saved in the other package's copy, while the by-name read naming that package served it (pin (e)). - The done-when sentence "The endpoints do too" is only true with this commit. - **It can be dropped on its own at review.** If it is, the docs sentence needs a narrower exception for that case. 4. `dc853419db` the done-when wording and the changeset. The page's "with one exception" sentence, its remedy and its "tracked in #21967" clause are removed; the over-close sentence stays. The `anonymousFormIntakeWithdrawnIn` docblock says the list holds one item per package, a package's saved container copy serving that package's item. The released #21934 changeset is not edited; `.changeset/21967-view-expansion-per-package.md` (`@objectstack/metadata-protocol` patch) states the change. ## Hypotheses (PM's H1 to H4), measured at base `f76c6221ac` - **H1, confirmed.** `protocol.ts:9070`–`:9105` derived `written` from `expandStoredViewContainers(request.type, overlays)`, upserted by name into one `byName` map (the last expansion wins), and `merged` dropped every other item of the name. - Each expansion entry carries its row's `package_id` as `container.packageId` (`storedOverlayEntries`, `:9596`). - The stand-in logic (`standInNames`, `unseated`) did not encode a package-less container rule. It serves a package-less ROW of exactly the expanded name in a list scoped to a package (#21817). A package-less container was not expanded in a scoped list at all. - Both loaders register a shipped container's expansions per package: `packages/objectql/src/engine.ts:7159`–`:7165` (`registerItem(..., ownerId)`) and `packages/metadata/src/plugin.ts:1198`–`:1210`. - **H2, partly falsified.** - Naming a package, `resolveRowlessExpandedView` (`:9690`) already read only that package's rows, so for two packages' containers it served that package's own expansion. Naming none, it served the last expansion in row order. - The disagreement was elsewhere: naming a package, it never consulted a package-less container. So `getMetaItem` naming a package served that package's shipped item while the list served the package-less copy's expansion (pin (d), package-less case, red at base). - **H3, no contradiction found.** Ruling by ruling: - #21334 (triage 5946423948, seat 5955628428): the naming arm and "the object door and the by-name read answer the same row". `expandRuntimeViewContainer` is unchanged, and per-package slots make the two doors agree where they did not. Its controls (a package-less overlay of a package's own container keeps `OBJECT.default`, one item) pass. - #20301 (`20301-spec-view-container-name-ledger-note.md`): a liveness-ledger note about the container body's `name` stamp. No expansion rule; untouched. - #21639 (triage 5973827435): the save door's one collision predicate, and "No merge rule: that would be a second precedence order to maintain" for two containers that collide in one selection. - The save door is unchanged, and within one package the last expansion still wins. - Per-package slots apply ADR-0048's existing package resolution, not a new precedence order. - The save door already accepts each package's overlay of its own container: the row under the save name is left out of the siblings. That is the case this PR serves per package. - #21804 (triage 5987404976): "one prefer-local resolution ... no second resolver". The package order is read from `servedOverlayRowCandidates` (`addressPackages`). - #21817 (triage 5988911029): "the scoped list's membership does not grow". A package-less container stands in only in slots the package seats, and is never appended to a scoped list. Its landing note recorded "a package-less stored view container is not expanded in a scoped list" as a fact, not a ruling. Expanding it as a stand-in follows direction A ("Teach `resolveRowlessExpandedView` the same rule"). - #21442 (triage 5957375321): "No second expansion rule. No kernel-specific branch." One selection function serves both doors, with no kernel branch. - #21510 (triage 5964342087): "the stored row wins on both doors ... one predicate, read by both doors". Commit 3 keys that predicate by the slot's package. It is still one predicate, read by both doors, and a row still wins its own slot. - **H4, confirmed.** `findPublicFormView` (`packages/rest/src/rest-server.ts:10735`) and `resolveFormBySlug` (`:10784`–`:10819`) read the organization's list and the env-wide list. `anonymousFormIntakeWithdrawnIn` (`packages/metadata-core/src/anonymous-form-intake.ts:336`) compares names, not packages. So once the list holds each package's item, the shared verdict judges each package's withdrawal. The organization-scoped save check's first judgment (`anonymousFormIntakeReopenRefusal`) reads the same env-wide list. No `rest-server.ts` edit. ## Pins (`packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts`, 23 cases) They are driven through the real protocol reads, as the file's existing cases are. The doors are modelled by their own composition (the organization's list, each open candidate judged over the env-wide list). The registry double holds each package's shipped container and its expanded views under `PACKAGE:NAME`, as the real `SchemaRegistry` holds them. Every case runs on an environment-scoped and an unscoped kernel. - (a) package A's env-wide copy saved with the form open, package B shipping it withdrawn: the env-wide list holds B's withdrawal beside A's item, and the doors serve no copy of a restored organization overlay. Run with no code package owning the object, and with A owning it. Control: with no package withdrawing it, the doors serve the overlay. - (b) the same with B's withdrawal saved in B's own copy, both save orders. - (c) a package-less copy stands in for every package with no copy of its own; a package's own copy serves that package's slot ahead of it, both save orders. - (d) for a name two packages' copies expand, and for a package-less copy: each package's slot in the env-wide list, `getMetaItem` naming the package, and the list scoped to the package serve the same item. - (e) one package's stored row of the form's exact name serves that package's slot and hides no other package's copy. Control: a package-less row of the name serves every slot. No dogfood case was added: the doors' verdict is a pure composition of `getMetaItems` and the shared function, pinned in-process as #21934's pins are. ## Measured - **Base reading** (pins at `2981f6eb78` over base `protocol.ts`): 19 red, 4 green. The greens are the (a) control, both (e) controls, and (e) on the unscoped kernel, where registry hydration served B's copy. Every red failed in the defect's shape, for example `expected [ [ 'pkg_a', true, … ] ] to deeply equal [ [ 'pkg_a', … ], [ 'pkg_b', … ] ]`. - **Reverse verification** on committed head `dc853419db`, through `scripts/ablation-replace.mjs`. The anchor hit once, the blob changed on disk, and each restore was proved (blob equal to HEAD `def1f5213f34`, `git diff HEAD` empty). The subject is imported from source (`./protocol.js`), so there is no `dist` leg. Predictions were written before the runs. - A1, the order's ablation (the view branch set back to base's by-name upsert, verbatim): predicted 19 red / 4 green, measured 19 red / 4 green, the same split as the base. - A2 (the own-row test asked with no package): predicted 1 red, measured 1 red: (e) on the environment-scoped kernel. - A3 (the by-name read without its package-less read): predicted 2 red, measured 1 red, on the environment-scoped kernel. The prediction missed the unscoped kernel: there the by-name read falls through to the registry, whose bare-name `getItem` answers the hydrated expansion with the same body. - **Clause-②** measured on the built entry declarations, base `protocol.ts` against head, comments stripped: - `@objectstack/metadata-protocol` `dist/index.d.ts` and `index.d.cts`: identical (2162 lines each). - `@objectstack/metadata-core` `index.d.ts`, `index.d.cts`, `repository-*.d.ts` and `testing.d.ts`: identical. - The raw bytes differ in comments only. No exported signature moves, so `Clause-②: no`, as claimed. - **Tests:** - `@objectstack/metadata-protocol` at `c966e63a0a` (later commits change no source in it): `typecheck` green (`tsc --noEmit`). Full suite in two halves: 218 files passed, 3 skipped; 28019 tests passed, 19 skipped. - The related suites again after the own-row commit: 8 files, 884 tests passed. - `@objectstack/metadata-core` (comment only): both `typecheck` programs green; 18 files, 411 tests passed. The edited test file is in metadata-protocol's tsc program (counted with `--listFiles`). - **Gates**, all at head `dc853419db` (`origin/main` is still the base `f76c6221ac`, so the merge is a no-op): - `node scripts/pm/dispatch-gates.mjs --commands` with no paths derived 93 commands from the 5 changed paths. All 93 were run and exit 0. `--ran` with recorded exit codes: "93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN". - Three of them first answered `PREREQUISITE NOT MET` (unbuilt workspace packages): `check:skill-examples`, `check:lean-entry-closure` and `check:dual-build-cjs-loads`. `check:type-check-debt`'s own re-measure then built the workspace, and all three were rerun green: 262 examples, 2 conditions, 106 entry points in 66 packages. - The artifact-roster block printed outside the total (53 commands) was run as well. 51 exit 0. `check-closing-target-claim` and `check-single-claim-paths` need this PR's context, so they run after it opens (in the report). `check-partof-closing-keyword` ran green over this body. - The four symbol-anchor sweeps are green: `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors`. - Lint, narrowed to the 3 touched TypeScript files with `eslint --no-inline-config` (as `pnpm lint` runs it): 3 files, 0 errors, 0 warnings. - The `.mdx` and the changeset match no eslint `files` glob. - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project` or `projectService`), and it reads only two baselines from disk, neither touched. So the diff cannot move an untouched file's verdict. - No raw control byte in any changed file (`grep -P` over the five paths: 0 each). `check:nul-bytes` is green. ## Acceptance notes - The save door's collision predicate (#21639) reads sibling expansions with no package filter. So two different containers of two packages that expand one name are still refused at save, though the read doors would now serve them in separate slots. Stricter than the readers, and it fails closed: noted, not changed. - A by-name read that names no package still answers any row's expansion (the last one), as `servedOverlayRowCandidates` does for no package. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e4e881 commit db87a02

5 files changed

Lines changed: 494 additions & 55 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** Where packages ship the same view container, the view list (`getMetaItems` for `view`) served one item for each name a saved environment-wide copy of that container expands: the copy's own expansion. Every other package's item of that name, shipped or saved, was left out. The anonymous form endpoints judge a withdrawal against the environment-wide view list, so they could miss another package's withdrawal of such a form.
10+
- **What it does now.** The view list serves each package its own item of such a name:
11+
- a package's saved copy of the container serves that package's item of each name it expands;
12+
- a package-less saved copy stands in for every package that has no copy of its own (ADR-0048);
13+
- any other package keeps its own item.
14+
15+
So another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of the container are saved. The organization-scoped save check reads the same list, so it judges each package's item too.
16+
- **A stored view row of exactly such a name** keeps its own package's slot only. A package-less row still serves every package's slot. Before, any package's row of the name kept every package's copy expansion of it out of the list.
17+
- **The by-name read agrees.** `getMetaItem` naming a package serves the item that package's slot in the list serves. Where no copy belongs to that package, a package-less copy now stands in for it. A list scoped to a package (`GET /api/v1/meta/view?package=`) serves the same item in each slot the package lists. A package-less copy adds no item to that list.
18+
- **What does not change.** Within one package, a later expansion of a name still replaces an earlier one, and the save door's view container collision check is unchanged. A by-name read that names no package answers as before. No key, export, status or error code changes.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ A withdrawal is a kill switch across metadata layers. If the environment-wide de
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, 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.
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.
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: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,10 +326,9 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string;
326326
* name in every package only when its layer holds every package's body of the
327327
* name. The organization-scoped write door anchors one body per package. The
328328
* 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
329+
* name: a package's saved env-wide copy of a view container serves that
330+
* package's item of each form it expands, and a package-less copy stands in
331+
* for every package with no copy of its own. A layer
333332
* with no body of the row, or whose body has no explicit withdrawal, withdraws
334333
* nothing, so a form published only in an organization stays open there.
335334
*/

0 commit comments

Comments
 (0)