Repository navigation
Commit 222293f
fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused (ADR-0029 D9.9) (#23510)
Fixes #23416
Clause-②: no
## What this does
`classifyObjectContribution`
(`packages/metadata-protocol/src/protocol.ts`) asked the registry one
question, `getPackagedObjectOwner`, which answers for a CODE artifact
only. An object that package P's `sys_metadata` row created at runtime
has no packaged owner, so a save of it naming package Q classified as
`own` and was written. The write-through then called `registerObject(…,
Q, …, 'own')`, the registry's single-owner rule (ADR-0029 D3) refused
it, and `applyObjectRegistryMutation`'s best-effort `console.warn`
swallowed the refusal. The save answered `200` while `sys_metadata` held
a Q-bound row the registry could not hold: the disagreement ADR-0029
D9.9 (`overlayPackageMismatchError`) exists to prevent.
The classifier now asks one more question when there is no packaged
owner: the registry's own `own` contributor (`getObjectOwner(name)`,
keyed on the object name exactly as `registerObject` keys its
contributors). When that owner is bound to a real package and the row's
binding names a different package, it answers the existing `mismatch`
arm. So the save door refuses with the code-owner path's refusal and
prescription (`OBJECT_OVERLAY_PACKAGE_MISMATCH` / `422`), before
`ensureOverlayIndex` and every `put`. One arm answers both owners; no
second refusal path, no new error code, no new message.
Unchanged by construction:
- A binding equal to the owner's package: `own`, an ordinary
re-registration (the owner's re-save stays `200`).
- A save naming no package: still `own`. A direct publish-mode save
naming no package is addressed to the owner's row before this runs
(`packagelessAddress`).
- An owner keyed by the `sys_metadata` sentinel (a package-less runtime
object) or by no package: left to today's verdict. It names no package
the prescription could direct a save to. See the acceptance notes.
- A code-packaged owner: the existing table, untouched.
## Premise check (the seat's ruling premise)
The ruling needed a runtime owner's package that can be read
deterministically at that point of `saveMetaItem`. It holds. The
registry keeps exactly one `own` contributor per object name
(`registerObject` enforces it), and that contributor is the very fact
`registerObject` judges an incoming `own` claim against. The arm
predicts the registry's own refusal and judges nothing else. The object
write-through is not kernel-gated (`applyRegistryWriteThrough`'s object
branch), so every saved runtime object is in the registry on every
kernel topology. Measured at the HTTP door on both topologies (below).
## Measured before the fix (real kernel, base `cb9841aa25`)
`ObjectKernel` + `ObjectQLPlugin` + an in-memory driver, `saveMetaItem`
as `PUT /api/v1/meta/object/NAME?package=Q` calls it:
| setup | Q's save | rows stored | registry owner | warn |
|:--|:--|:--|:--|:--|
| P saved `qa_probe_gadget` | `success: true`, "Saved object
'qa_probe_gadget' (env-wide, package=com.qa.q, state=active)" |
`com.qa.p`, `com.qa.q` | `com.qa.p` | `registerObject failed … already
owned by package "com.qa.p" … cannot claim ownership` |
## Measured after the fix
- Real kernel pin (new file, 4 cases): Q's save answers
`OBJECT_OVERLAY_PACKAGE_MISMATCH` / `422` (with `packageId` Q and
`ownerPackageId` P). No row and no history entry is stored for Q. The
registry and `sys_metadata` both still say P. CONTROLS: P's own re-save
answers success and the registry follows it; a save naming no package
addresses P's row; a code-packaged owner answers `NOT_OVERRIDABLE` /
`403`, as it did before this change (measured on the base).
- HTTP door, one-time reading (the real `RestServer` `PUT
/api/v1/meta/object/:name` route over a real better-sqlite3 engine,
control-plane and environment kernels): P `200`; Q `422` with body
`code: OBJECT_OVERLAY_PACKAGE_MISMATCH` and the D9.9 sentence; P again
`200`; stored bindings `[com.probe.p]`.
- Other faces of the same door, one-time readings on the real kernel,
before → after:
- a draft save into Q: success with a Q draft row stored → `422`, no
draft row;
- `duplicatePackage` P → Q of an object whose name lacks P's namespace
prefix (so the copy keeps the name): listed in `copied` with a Q row the
registry never held → listed in `failed` with the D9.9 text, nothing
stored;
- boot (`loadMetaFromDb`) with a stored Q row beside P's: P registered
and the Q row counted in `errors` either way. The warn line changes from
`code=OBJECT_OWNERSHIP_CONFLICT` to
`code=OBJECT_OVERLAY_PACKAGE_MISMATCH`.
## Fixtures re-judged (the rule's consumer radius)
Two suites built the two-package runtime shape by saving the second
package's row through the door. Both are RE-SEEDED, not weakened: the
first package's row is still saved through the door, and the second row
is written straight into the store, as a row stored before this refusal
existed.
- `packages/objectql/src/protocol-delete-object-registry-heal.test.ts`,
`twoPackageObject` (PR #23408's pin, per pointer `6163309335`). The
seeded row is the first row's own shape, rebound and re-labelled, with
the repository's checksum (`hashSpec`). The fixture asserts that formula
on the row the door just wrote, and that the registry still holds the
first package.
- `packages/rest/src/meta-delete-drop-storage-other-rows.test.ts`,
`twoPackageObject` (PR #23464's pin, landed after the claim). Its
existing `seedUnboundRow` is generalised to `seedStoredRow(packageId)`.
This file is a **surface increment** over the claim's declared surface:
it is the PM's swept consumer radius, and the PR breaks it without the
re-seed.
Each re-seeded fixture was ablated against the behaviour it pins, to
show it still bites (mutation through `scripts/ablation-replace.mjs`,
`dist/` proven by `scripts/ablation-dist-preflight.mjs`, restored blob
equal to `HEAD` and the tree clean):
| ablation | mutated | restored |
|:--|:--|:--|
| this PR's arm (`mismatch` return replaced by `own`), new pin | 1
failed / 3 passed (the refusal case: "expected null to be an instance of
Error") | 4 passed |
| #23408's heal (its remaining-rows branch made unreachable), heal file
| 5 failed / 10 passed (the four re-seeded object cases + the dashboard
case) | 15 passed |
| #23464's drop refusal (its remaining-rows check made unreachable),
rest file | 3 failed / 2 passed | 5 passed |
## Tests (consumer contract face)
All readings below are at this PR's head `55bb8619e7`, which merges
`origin/main` at `394f65e89`. Each command ran under the shared verify
lock (`scripts/pm/os-verify-lock.sh`), and each verdict is that
wrapper's `VERDICT command-exit` line.
| command | result |
|:--|:--|
| `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2` | exit 0 · 236 files passed, 3 skipped · 28397 tests
passed, 19 skipped |
| `pnpm --filter @objectstack/metadata-protocol typecheck` | exit 0 |
| `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2` | exit 0 · 416 files · 8097 tests passed |
| `pnpm --filter @objectstack/objectql typecheck` (tsc +
`check:test-typecheck`) | exit 0 · the test layer compiles, debt
unchanged |
| `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2` | exit 0 · 283 files · 5852 tests passed, 326 skipped
(first attempt: lock exit 99, re-run with the same slot) |
| `pnpm --filter @objectstack/rest typecheck` (tsc +
`check:test-typecheck`) | exit 0 |
The objectql suite's `repo` project, which holds the tests that read
outside the package, is CI's (`test:repo`). The red-before reading for
the new pin was taken at the base: 2 failed of 4. The refusal case
answered `null` (a success). The code-owner control then read
`NOT_OVERRIDABLE`, and that measured value is what the control now pins.
## Gates
Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `55bb8619e7`: 67 commands. All 67 ran at
that head with exit codes recorded, and `--ran` reconciles: "67 derived,
67 run, 0 NOT-MEASURED, 0 UNRUN". Every command exited 0.
`check:type-check-debt` first got lock exit 99 (its own PREREQUISITE NOT
MET, exit 3) and passed when re-run under the lock: "1 ledger entr(ies)
re-measured … none above its recorded number". On an earlier head,
`check-adr-0087-registration` refused a `## FROM → TO` section label
beside `no-migration-prescription`. The changeset now states the FROM/TO
as request answers, and the gate passes (`not-required
(no-migration-prescription)`).
CI owns everything the derivation does not place: the artifact-roster
families, the wide-population families, the type-check lanes, Test Core,
Build Core, dogfood and the temporal conformance job. `packages/runtime`
and `packages/qa/dogfood` suites were not run locally. A heuristic sweep
of their tests for a second package's object save through the door found
none.
`origin/main` has moved past `394f65e89` by two commits (`ad5a7d7c39`
spec page-action rows, `111b5821ce` a runtime test). Neither touches
this diff's packages or behaviour; CI's merge ref covers them.
## Acceptance notes
- **Package-less owner, same family, not fixed here.** An object a row
bound to NO package owns (`sys_metadata` sentinel) behaves as this card
did before the fix. On a real kernel at `cb9841aa25`, a package-less
save then a `?package=Q` save answered success with rows `[null,
com.qa.q]`, while the registry kept the sentinel owner and warned
`already owned by package "sys_metadata"`. The arm deliberately excludes
it: the D9.9 prescription would tell the author to bind to
`'sys_metadata'`, which is not a package, and the right remedy (orphan
adoption, or saving without a package) is a wording decision. Reported
to the seat for the D9.9 family's close-out, not filed by this PR.
- **`duplicatePackage` now reports what it used to fake.** A source
object whose name lacks the source namespace prefix keeps its name in
the copy. Before this PR that copy was a row the registry refused, over
the same physical table (object name = table name). Now it lands in
`failed[]`. The docblock of `duplicatePackage` already calls a same-name
copy a collision. The prescription text it carries ("bind … to P, or
have Q extend") is the code-owner path's and fits the save door better
than the duplicate face; renaming the object is the remedy there.
- **The prescription's second remedy is a code-package affordance.**
"have Q extend the object instead" names `objectExtensions`, which a
code package declares. No runtime metadata door authors an extension.
The ruled prescription is reused unchanged; the first remedy (bind to P)
is always available.
- **Callers left as they are.** `applyObjectRegistryMutation` and
`loadMetaFromDb` already route a `mismatch` to their warn and per-record
`errors`. The heal comment in `restoreArtifactRegistryView` ("a second
package's runtime-authored row never registered (its write-through was
refused at save)") stays true of stored rows and sits in the delete heal
that `deleteMetaItem` runs, outside this card's surface.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6f21216 commit 222293f
5 files changed
Lines changed: 359 additions & 23 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
- rest/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 | |
|---|---|---|---|
| |||
19219 | 19219 | | |
19220 | 19220 | | |
19221 | 19221 | | |
| 19222 | + | |
| 19223 | + | |
| 19224 | + | |
| 19225 | + | |
| 19226 | + | |
| 19227 | + | |
| 19228 | + | |
| 19229 | + | |
| 19230 | + | |
| 19231 | + | |
| 19232 | + | |
| 19233 | + | |
| 19234 | + | |
| 19235 | + | |
| 19236 | + | |
| 19237 | + | |
| 19238 | + | |
| 19239 | + | |
| 19240 | + | |
| 19241 | + | |
| 19242 | + | |
| 19243 | + | |
| 19244 | + | |
| 19245 | + | |
| 19246 | + | |
19222 | 19247 | | |
19223 | 19248 | | |
19224 | 19249 | | |
| |||
19238 | 19263 | | |
19239 | 19264 | | |
19240 | 19265 | | |
| 19266 | + | |
| 19267 | + | |
| 19268 | + | |
| 19269 | + | |
| 19270 | + | |
| 19271 | + | |
| 19272 | + | |
| 19273 | + | |
| 19274 | + | |
| 19275 | + | |
| 19276 | + | |
| 19277 | + | |
| 19278 | + | |
| 19279 | + | |
19241 | 19280 | | |
19242 | 19281 | | |
19243 | 19282 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
432 | 433 | | |
433 | 434 | | |
434 | 435 | | |
435 | | - | |
436 | | - | |
437 | | - | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
438 | 439 | | |
439 | 440 | | |
440 | 441 | | |
441 | 442 | | |
442 | 443 | | |
443 | 444 | | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
444 | 455 | | |
445 | 456 | | |
446 | | - | |
447 | | - | |
448 | | - | |
449 | | - | |
450 | | - | |
451 | | - | |
452 | | - | |
453 | | - | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
454 | 472 | | |
| 473 | + | |
455 | 474 | | |
456 | 475 | | |
457 | 476 | | |
458 | 477 | | |
459 | 478 | | |
460 | | - | |
| 479 | + | |
461 | 480 | | |
462 | 481 | | |
463 | 482 | | |
| |||
0 commit comments