Repository navigation
Commit 954bf72
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
- .changeset
- packages
- metadata-protocol/src
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24474 | 24474 | | |
24475 | 24475 | | |
24476 | 24476 | | |
| 24477 | + | |
| 24478 | + | |
| 24479 | + | |
| 24480 | + | |
| 24481 | + | |
| 24482 | + | |
| 24483 | + | |
| 24484 | + | |
| 24485 | + | |
| 24486 | + | |
| 24487 | + | |
| 24488 | + | |
| 24489 | + | |
| 24490 | + | |
| 24491 | + | |
| 24492 | + | |
| 24493 | + | |
| 24494 | + | |
| 24495 | + | |
| 24496 | + | |
| 24497 | + | |
| 24498 | + | |
| 24499 | + | |
| 24500 | + | |
| 24501 | + | |
| 24502 | + | |
| 24503 | + | |
| 24504 | + | |
| 24505 | + | |
| 24506 | + | |
| 24507 | + | |
| 24508 | + | |
| 24509 | + | |
| 24510 | + | |
| 24511 | + | |
| 24512 | + | |
24477 | 24513 | | |
24478 | 24514 | | |
24479 | 24515 | | |
| |||
0 commit comments