Skip to content

Commit 954bf72

Browse files
fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) (#23683)
Fixes #23630 Clause-②: no ## What this does The save door refuses an `object` row bound to a package that does not own the object (ADR-0029 D9.9, `overlayPackageMismatchError`, `OBJECT_OVERLAY_PACKAGE_MISMATCH` / `422`). A draft save is judged against the owner at the time of that save. So package Q's draft of an object that no row held yet was stored, correctly. Then package P saved a runtime-authored row of that object, and P owned it. Promoting Q's draft after that wrote Q's active row beside P's and answered success. The promotion never asked the save door's question. The registry write-through then refused the row into `applyObjectRegistryMutation`'s `console.warn` and kept serving P. `promoteDraftForPublish` (`packages/metadata-protocol/src/protocol.ts`) is the one promotion step that both doors call: the per-item publish (`publishMetaItem`, `POST /api/v1/meta/:type/:name/publish`) and `publishPackageDrafts` (`POST /api/v1/packages/:id/publish-drafts`). It now asks `classifyObjectContribution(name, promoteInto)` after the gated draft read, and throws `overlayPackageMismatchError` on a `mismatch` verdict, before `repo.promoteDraft` writes. It is the save door's judge and envelope, unchanged: one refusal, no second dialect, no new code, no new message. - **The package it judges** is `promoteInto`, the package the active row is written under. That is the stated binding, or, for a package-unbound draft adopted into the item's package (#23495), that package. The save door judges the address it resolved in the same way. - **Not asked of sealed managed content.** The check is guarded by `!this.isSealedManagedItem(singularType, request.name)`. An object a code package ships is sealed (ADR-0131 D6). For that row the save door answers its package door first (`refusePackagedBaseOverride`, `NOT_OVERRIDABLE` / `403`, ahead of its D9.9 call). The promotion reaches the same refusal at the store (`repo.promoteDraft` → `assertAllowed`). Without the guard, `classifyObjectContribution`'s packaged `mismatch` arm answered `422` with a prescription ("bind to the owner") that the sealed rule refuses. This was contract-review finding ①4 (6195338637), and the seat ruled route (A) (6195354019). - **Placement.** It sits with the other draft-body refusals the promotion already asks: the `name` refusal, the permission-set owner binding, the default record page and the elevated flow. So a batch publish aborts on it as on them (ADR-0067 D2), and a publish with no draft at the address still answers `404 NO_DRAFT`. - **Member 2 of the card** is the package-less owner (the `sys_metadata` sentinel). It rides #23514 (its claim 6195283743 and dispatch 6195318252 pin `[P, Q, package-less]`), which will change `classifyObjectContribution`'s sentinel arm; this PR does not touch that arm. #23514 remains open; per 6187770366 the seat reopens this card if #23514's merged PR does not pin member 2. ## Measured before the fix (real composition, base `57994c311f`) Real `ObjectQL` over a real `SqlDriver` (better-sqlite3), the real `PackageServicePlugin`, `ObjectStackProtocolImplementation` and `HttpDispatcher.handlePackages`. Packages `com.probe.p` and `com.probe.q` are installed. Q's draft of `probe_ticket` is saved (`saveMetaItem`, mode draft), then P's active row is saved: | door | answer | stored rows | registry owner / served label | |:--|:--|:--|:--| | `publishMetaItem({ type: 'object', name, packageId: Q })` | `success: true`, "Published draft — type=object, name=probe_ticket [seq=3]" | `active:com.probe.p`, `active:com.probe.q` | `com.probe.p` / P's | | `POST /api/v1/packages/com.probe.q/publish-drafts` | `200`, `outcome: 'published'`, `failed: []` | `active:com.probe.p`, `active:com.probe.q` | `com.probe.p` / P's | The batch door's answer to a refusal inside a promotion was read from the code at the base. Any throw from `promoteDraftForPublish` rolls the batch back and answers `200` with `success: false` and `outcome: 'refused'`. The causal draft is in `failed[]` with its own `code` (`clientFacingFailureCode`; `OBJECT_OVERLAY_PACKAGE_MISMATCH` is in the error-code ledger) and its message (a 4xx is quoted). Every other draft is listed `BATCH_ABORTED`. This PR keeps that declared shape (`PublishPackageDraftsResponseSchema`) and adds nothing to it. ## Measured after the fix New pin `packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts`, 8 cases, on the same composition: - **`publishMetaItem` naming Q:** throws `{ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 }`, with `packageId` Q and `ownerPackageId` P. Its message equals the save door's refusal of the same row; the test compares the two answers and pins no wording. Nothing is written: active rows `[P]`, Q's draft still stored, and the registry serves P. - **`POST /packages/Q/publish-drafts`:** `200`, `success: false`, `outcome: 'refused'`, `publishedCount: 0`, `failedCount: 1`. `failed[0]` is the object with `code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH'` and the save door's message. Nothing is written. - **CONTROL, code-packaged owner, per door.** A code package O ships the object (registered as `registerApp` registers it: contributors only, `_packageId: O`, `_provenance: 'package'`), and Q's draft is a row stored before O shipped it, since the save door refuses that draft today. Nothing is written either way: no active row, Q's draft still stored, and the registry still serves O. - `publishMetaItem` naming Q answers `{ code: 'NOT_OVERRIDABLE', status: 403 }`. The save door answers the same for the same row. - `publish-drafts` answers `outcome: 'refused'` with `failed[0].code: 'NOT_OVERRIDABLE'`. - Neither answers `OBJECT_OVERLAY_PACKAGE_MISMATCH`. - **CONTROL:** with no second-package owner, Q's draft is promoted by `publishMetaItem` and by `publish-drafts` (active `[Q]`, registry Q). - **CONTROL:** the owner P's own draft is published by `publish-drafts` over P's row. - **CONTROL:** `publishMetaItem` naming no package still promotes a package-unbound draft INTO P (#23495's adoption). Red before: - At the base `57994c311f`, the runtime-owner refusal cases read 2 failed and 4 passed. - At `3f0acc173` (the first round's check, no guard), the two code-packaged CONTROLs failed and 6 passed. Each answered `OBJECT_OVERLAY_PACKAGE_MISMATCH` (per-item: `status: 422`) where `NOT_OVERRIDABLE` / `403` was pinned. Finding ①4 is reproduced. Green after: 8 passed at `ea873b091`. **Ablations**, both through `scripts/ablation-replace.mjs` (WRAP mode, with a trap restore). Each rebuilt `@objectstack/metadata-protocol`, and `scripts/ablation-dist-preflight.mjs` proved the marker in `dist/index.js` and `dist/index.cjs`. Each restore proved the blob equal to HEAD, `git diff HEAD` empty, the package rebuilt, `--absent` (marker in none of 24 built files) and the tree clean. | ablation | mutated | predicted / observed | restored | |:--|:--|:--|:--| | the check itself, at `5c2385ebd`: `classifyObjectContribution(...)` replaced by a planted non-`mismatch` verdict | blob `fa49911fd8` → `034c466abb` | 2 red / 4 green; `Tests 2 failed \| 4 passed (6)`, the two refusal cases | 6 passed | | the sealed-item guard, at `ea873b091`: `&& !this.isSealedManagedItem(singularType, request.name)` replaced by an always-true term | blob `700377269e` → `f2e19c39ab` | 2 red / 6 green; `Tests 2 failed \| 6 passed (8)`, the two code-packaged CONTROLs | 8 passed | ## Tests Every command ran under `scripts/pm/os-verify-lock.sh`, and each verdict is its `VERDICT command-exit` line (or, in a `;`-sequenced hold, the part's own echoed exit). **This round, at `ea873b091`.** That head merges `origin/main` at `cbe771270`, which includes #23646's `reassignOrphanedMetadata` change to `protocol.ts`. Every run was targeted, a declared narrowing; the whole-package farms are CI's this round. | command | result | |:--|:--| | the new pin file (`--project local`) | exit 0 · 8 passed | | metadata-protocol, the 46 test files that name the promote doors or the D9.9 refusal (`publishMetaItem`, `publishPackageDrafts`, `promoteDraftForPublish`, `OBJECT_OVERLAY_PACKAGE_MISMATCH`, `classifyObjectContribution`, `overlayPackageMismatch`) | exit 0 · 46 files · 1039 passed | | objectql, 14 save-door / promote-door / D9.9 files (`protocol-runtime-owner-overlay-refusal`, `protocol-object-overlay-layer`, `registry-object-overlay-layer`, `protocol-publish-package-drafts`, `publish-package-drafts-response-conformance`, `publish-meta-response-conformance`, `protocol-publish-canonical-fold`, `protocol-publish-rollback`, `protocol-lock-enforcement`, `protocol-delete-object-registry-heal`, `protocol-duplicate-package-unprefixed-object`, `default-record-page-save-door`, `protocol-commit-history`, `build-probes`) | exit 0 · 206 passed | | runtime, 6 files (`http-dispatcher`, `domains/meta-published-runtime-publish`, `domains/packages-publish-drafts-response-conformance`, the three `package-uninstall-*` integration files) | exit 0 · 282 passed | | `pnpm --filter @objectstack/metadata-protocol --filter @objectstack/runtime run typecheck` | exit 0 · runtime's `check:test-typecheck`: "27 file(s) / 190 error(s) / 68 pinned signature(s) held" | **First round, at `5298b986e`** (merging `origin/main` at `7e6e4e1da`): - metadata-protocol whole suite, 3 shards: 236 files, 20563 tests passed, 19 skipped. - Targeted objectql, runtime and rest files: green. - Typecheck: exit 0. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ea873b091`, the final head, against merge base `cbe771270`: 65 commands. - **Prerequisite build.** A locked full `turbo run build` came first (75 tasks, exit 0). - **The run.** All 65 then ran at that head, each with its exit code captured before any pipe, and every one exited 0. Among them: - `check:dual-build-cjs-loads`: "110 published require entry point(s) across 68 package(s) load". - `check:type-check-debt`: "1 ledger entr(ies) re-measured … none above its recorded number". - `check-adr-0087-registration`: `not-required (no-migration-prescription)`. - **`--ran`:** "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero, since every entry recorded its exit code. - **The working tree** was clean. `origin/main` has since moved one commit, to `3bc407d30` (#23687, objectql's metadata-service copy). It touches none of this PR's three files and none of the derived inputs, so it was not merged. CI owns everything the derivation does not place: the artifact-roster and wide-population families, the type-check lanes, Test Core, Build Core, dogfood and the whole-package suites. ## Acceptance notes - **The batch door refuses inside its declared outcome, not with an HTTP 422.** `publish-drafts` answers `200` with `outcome: 'refused'` and the code and message in `failed[]`, the same shape as every other refusal inside a promotion (ADR-0067 D2, `PublishPackageDraftsResponseSchema`). The card's "same code and prescription" holds in `failed[0].code` and `failed[0].error`. - **The code-packaged owner keeps the sealed refusal.** Its promotion answers `NOT_OVERRIDABLE` / `403` from the store, as before this PR and as the save door answers. Both doors pin it. The changeset's "Unchanged" list names it. - **`applyObjectRegistryMutation`'s note is now true of one more caller, and was left as is.** Its `mismatch` branch says it is unreachable from `saveMetaItem` and lists "rollback, publish promotion" as the other callers. Publish promotion now refuses at its producer too, and a sealed object's promotion never reaches the write-through. The note stays true and sits outside this card's surface. `rollbackMetaItem` restores an existing row under that row's own binding, so it mints no new binding. - **Rows stored before D9.9 stay as they are.** This change rewrites and removes nothing. The uninstall pre-flight (#23473) already handles them. _Body refreshed 2026-10-15T16:52Z by `domain:engine` seat 1 from the dev's round-2 text (report 6197439689)._ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6074eaf commit 954bf72

3 files changed

Lines changed: 459 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: a draft of an object promoted into a second package after another package's runtime-authored row came to own the object is refused before any write, as its save is (ADR-0029 D9.9)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten, converted or deleted by this change, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the two draft promotion doors for one shape: promoting an `object` draft into a package other than the one whose runtime-authored row now owns the object is refused, where it was written and then discarded by the registry. The remedy is in the request a caller sends (re-save the draft bound to the owner's package, discard it, or give the object its own name), never a rewrite of a package source or a stored row; the draft stays stored. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
An object has exactly one registry entry, so it has exactly one owning package (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). The save door refuses an `object` row bound to a package that does not own the object. A draft save is judged against the owner of its own moment, so package `Q`'s draft of an object that no row held yet was stored, correctly. When package `P`'s runtime-authored row of that object was saved after it, `P` owned the object. Promoting `Q`'s draft then wrote `Q`'s active row beside `P`'s and answered success, while the registry refused it into a warning and kept serving `P`'s schema. An uninstall of `P` then refused `409 RESOURCE_CONFLICT` until a row was removed by hand.
14+
15+
Both promotion doors now ask the save door's question of the row the promotion writes, and refuse it before any write, with the save door's code and prescription.
16+
17+
What changes for you, where package `P`'s runtime-authored row owns object `NAME`, `Q` is any other package, and `Q`'s draft of `NAME` was saved before `P`'s row existed:
18+
19+
- FROM `POST /api/v1/meta/object/NAME/publish?package=Q` (`publishMetaItem({ type: 'object', name: NAME, packageId: Q })`) answering `200` "Published draft …", with a `Q`-bound active row stored while the registry kept `P`'s schema, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written and `Q`'s draft still stored: "Cannot layer object 'NAME': the overlay is bound to package 'Q', but the object is owned by package 'P'. …", the save door's text for the same row.
20+
- FROM `POST /api/v1/packages/Q/publish-drafts` (`publishPackageDrafts({ packageId: Q })`) answering `200` with `outcome: 'published'` and `failed: []`, TO `200` with `success: false`, `outcome: 'refused'` and `publishedCount: 0`. `NAME` is in `failed[]` with `code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH'` and that text. Every other draft of `Q` is in `failed[]` with `code: 'BATCH_ABORTED'`. Nothing is promoted (the batch is all-or-nothing, ADR-0067 D2).
21+
22+
**Remedy, in one line.** Re-save the draft bound to the owner (`PUT /api/v1/meta/object/NAME?package=P&mode=draft`) and publish it there, or give `Q`'s object its own name, and discard `Q`'s draft with `DELETE /api/v1/meta/object/NAME?package=Q&state=draft`.
23+
24+
**Unchanged.**
25+
26+
- A promotion of `Q`'s draft of an object no other package's row holds, and the owner `P`'s promotion of its own draft.
27+
- A publish naming no package that promotes a package-unbound draft into the owner's package.
28+
- A code-packaged owner. An object a code package ships is sealed, so a promotion of another package's draft of it still answers `403 NOT_OVERRIDABLE` (the batch door: that code in `failed[]`), as the save door does, and never `OBJECT_OVERLAY_PACKAGE_MISMATCH`.
29+
- A package-less owner (the `sys_metadata` sentinel). Its rule is decided separately.
30+
- Every type except `object`. Two packages may still hold one name.
31+
- Rows stored before ADR-0029 D9.9 stay as they are. Nothing is rewritten on upgrade.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24474,6 +24474,42 @@ export class ObjectStackProtocolImplementation implements
2447424474
{ type: singularType, name: request.name, org: 'env' } as Parameters<typeof repo.get>[0],
2447524475
{ state: 'draft', packageId: draftKey, ...memberOption(request.member) },
2447624476
);
24477+
// [ADR-0029 D9.9, #23630] …and the save door's representability
24478+
// refusal, asked of the row this promotion writes: an `object` row
24479+
// bound to a package that does not own the object. A draft save is
24480+
// judged against the owner of its own moment, so package `Q`'s draft
24481+
// of an object no row held yet was stored, correctly; when package
24482+
// `P`'s runtime-authored row of that object was saved after it, `P`
24483+
// owned the object, and promoting `Q`'s draft wrote `Q`'s active row
24484+
// beside `P`'s and answered success, while the registry write-through
24485+
// refused it into a `console.warn` and kept serving `P` — the
24486+
// disagreement {@link overlayPackageMismatchError} exists to prevent.
24487+
//
24488+
// Judged on `promoteInto`, the package the active row is written
24489+
// under (the stated binding, or for an unbound draft adopted into the
24490+
// item's package, that package), exactly as the save door judges the
24491+
// address it resolved; with the save door's judge and envelope, and
24492+
// before `repo.promoteDraft` writes, so a batch publish aborts on it
24493+
// as on the refusals below (ADR-0067 D2). Both doors reach it: the
24494+
// per-item publish and `publishPackageDrafts` promote through here.
24495+
//
24496+
// ⛔ Not asked of SEALED managed content ({@link isSealedManagedItem}):
24497+
// an object a code package ships. The save door answers that row's
24498+
// package door first ({@link refusePackagedBaseOverride},
24499+
// `NOT_OVERRIDABLE` / 403, ahead of its D9.9 call), and the promotion
24500+
// reaches the same refusal at the store (`repo.promoteDraft` →
24501+
// `assertAllowed`). Asking `classifyObjectContribution` there would
24502+
// answer its packaged `mismatch` arm instead — a second dialect for
24503+
// one row, whose prescription ("bind to the owner") the sealed rule
24504+
// refuses. One row, one refusal: the save door's.
24505+
if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) {
24506+
const layer = this.classifyObjectContribution(request.name, promoteInto);
24507+
if (layer.kind === 'mismatch') {
24508+
throw ObjectStackProtocolImplementation.overlayPackageMismatchError(
24509+
request.name, layer.packageId, layer.ownerPackageId,
24510+
);
24511+
}
24512+
}
2447724513
// [#21470] …and the divergent `name` refusal, on the same body and for
2447824514
// the same reason: a draft stored before `saveMetaItem` judged every
2447924515
// type can carry a `name` that is not its row's, and promoting it

0 commit comments

Comments
 (0)