Skip to content

Commit 0008397

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): tier 3 refuses to unregister an object bound to an installed package (#7012) (#7093)
Deleting a metadata overlay row for an `object` whose `package_id` names an installed package took the object off the whole data plane until the next restart: `OBJECT_NOT_FOUND` / 404 on every CRUD call while the table still held the rows, and a delete receipt saying `reset: true`. `SchemaRegistry.registerObject` splices out the same-package `own` contributor rather than shadowing it, so hydrating such a row destroys the packaged definition at write time and stamps `_provenance: 'org'`. Tier 3 of `restoreArtifactRegistryView` then consulted `isArtifactBacked` — which for an `object` is exactly that provenance — and read "not code-shipped" for an object the package still ships. Tier 3 now also refuses when the owner contributor's package binding names a currently-installed package, and states the divergence in the log. The binding survives the overwrite (the replacement fires only when the package ids match); the definition does not. Accepted cost, ruled on #6853 (option C, maintainer 2026-08-09): a package-bound runtime-authored object is indistinguishable from a package-shipped one by binding alone, so some genuinely deleted objects stay listable-but-rowless until restart. REGISTER WIDE / RETIRE NARROW. Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw Co-authored-by: os-zhuang <noreply@anthropic.com>
1 parent 2f23c55 commit 0008397

3 files changed

Lines changed: 585 additions & 5 deletions

File tree

.changeset/lucky-buttons-shave.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the delete heal no longer unregisters an object bound to an installed package
6+
7+
Deleting a metadata overlay row for an `object` whose `package_id` names an installed package took the object off the whole data plane until the next restart: every CRUD call answered `OBJECT_NOT_FOUND` / 404 while the table still held the rows, and the delete receipt said `reset: true`.
8+
9+
`SchemaRegistry.registerObject` replaces (splices out) the same-package `own` contributor rather than shadowing it, so hydrating such a row destroys the packaged definition at write time and stamps `_provenance: 'org'`. Tier 3 of `restoreArtifactRegistryView` then consulted `isArtifactBacked` — which for an `object` is exactly that provenance — and read "not code-shipped" for an object the package still ships.
10+
11+
Tier 3 now also refuses when the owner contributor's package binding names a currently-installed package, and says so in the log. The binding survives the overwrite (the replacement fires only when the package ids match); the definition does not.
12+
13+
Known cost, deliberate: a package-bound runtime-authored object is indistinguishable from a package-shipped one by binding alone, so a genuinely deleted one stays registered until the next restart — listable, and rowless. A surplus entry is the cheap error here; a wrongly retired one 404s data CRUD for every tenant.

packages/metadata-protocol/src/protocol.ts

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8240,7 +8240,8 @@ export class ObjectStackProtocolImplementation implements
82408240
// which for `object` reads the contributor definition and applies
82418241
// exactly the artifact test the sibling verb applies to the plain
82428242
// key), so this limb inherits that judgement instead of open-coding
8243-
// a second one.
8243+
// a second one — PLUS the package-binding check below, which exists
8244+
// because that inherited judgement is measurably falsifiable here.
82448245
//
82458246
// Not theoretical, and NOT already covered by the gate at the top of
82468247
// `deleteMetaItem`: that two-tier authorization — which refuses an
@@ -8261,20 +8262,131 @@ export class ObjectStackProtocolImplementation implements
82618262
&& !this.isArtifactBacked(singular, name)
82628263
&& typeof registry.unregisterObject === 'function'
82638264
) {
8264-
try {
8265-
registry.unregisterObject(name);
8266-
} catch (err: any) {
8265+
// [#7012] …AND `isArtifactBacked` ALONE CANNOT SEE THAT.
8266+
// See {@link installedPackageBindingForObject} for the whole
8267+
// argument; the one-line version is that an overlay row bound
8268+
// to the packaged owner's id DESTROYS the packaged contributor
8269+
// at write time, which turns the predicate above `false` for an
8270+
// object the package still ships.
8271+
const boundPackageId = this.installedPackageBindingForObject(name);
8272+
if (boundPackageId !== undefined) {
82678273
console.warn(
82688274
`[Protocol] object '${name}' was deleted from sys_metadata but stays registered: `
8269-
+ `${err?.message ?? err}`,
8275+
+ `its owner contributor is bound to installed package '${boundPackageId}'. `
8276+
+ `A package-shipped object must not be retired by an overlay delete, and this seam `
8277+
+ `cannot tell one from a package-bound runtime-authored object (#7012 / #6853), so `
8278+
+ `the entry survives — listable, and rowless if the delete really was the whole item `
8279+
+ `— until the next restart.`,
82708280
);
8281+
} else {
8282+
try {
8283+
registry.unregisterObject(name);
8284+
} catch (err: any) {
8285+
console.warn(
8286+
`[Protocol] object '${name}' was deleted from sys_metadata but stays registered: `
8287+
+ `${err?.message ?? err}`,
8288+
);
8289+
}
82718290
}
82728291
}
82738292
} catch {
82748293
// Best-effort registry refresh; next read fixes it anyway
82758294
}
82768295
}
82778296

8297+
/**
8298+
* [#7012] The package binding of a registered `object`, but only when it
8299+
* names a package this process has actually INSTALLED. `undefined` means
8300+
* "no installed package answers for this object" — the only state in which
8301+
* {@link restoreArtifactRegistryView}'s tier 3 may unregister it.
8302+
*
8303+
* ## Why tier 3 needs a second predicate at all
8304+
*
8305+
* `isArtifactBacked` is the natural question ("does a code package ship
8306+
* this?") and it is the WRONG question at this exact point, because the
8307+
* thing it reads has already been destroyed by the time the walk runs.
8308+
*
8309+
* `SchemaRegistry.registerObject` splices out the same-package `own`
8310+
* contributor before pushing the new one. So an overlay row whose
8311+
* `package_id` equals the packaged owner's id does not SHADOW the packaged
8312+
* definition — it REPLACES it, and no second copy exists anywhere in the
8313+
* registry. {@link loadMetaFromDb} replays that replacement on every boot,
8314+
* with no authorization gate and no log, stamping `_provenance: 'org'`
8315+
* server-side (deliberately — cloud#970). `getArtifactItem` for an `object`
8316+
* is exactly `_provenance !== 'org'`, so `isArtifactBacked` answers `false`
8317+
* for an object a code package still ships, and tier 3 then took the whole
8318+
* entry. Measured end to end on a tenant kernel with no escape hatch:
8319+
*
8320+
* ```
8321+
* loadMetaFromDb -> {"loaded":1,"errors":0,"invalid":0} warnings: []
8322+
* DELETE -> {"success":true,"reset":true}
8323+
* objectContributors: [] getObject: null
8324+
* data CRUD: OBJECT_NOT_FOUND / 404 (while the table still holds the rows)
8325+
* ```
8326+
*
8327+
* ## Why the BINDING is trustworthy where the definition is not
8328+
*
8329+
* The replacement rule fires only when the two package ids MATCH, so the
8330+
* surviving contributor provably carries the packaged owner's id — the one
8331+
* fact the overwrite cannot change, precisely because it is the overwrite's
8332+
* own precondition. The second half, "is that package installed", is a fact
8333+
* about the PROCESS rather than about the destroyed body:
8334+
* `SchemaRegistry.installPackage` writes the record and
8335+
* `ObjectQL.registerApp` calls it immediately before registering the
8336+
* manifest's objects, so a package-shipped object always has one. Durable
8337+
* packages (`sys_packages`) are re-installed at boot by `service-package`,
8338+
* and nested `registerPlugin` objects are keyed to the PARENT package,
8339+
* which `registerApp` installed. The `'sys_metadata'` sentinel — the key an
8340+
* overlay row bound to no package keeps — is handled by construction rather
8341+
* than by a special case: nothing installs a package under it, so it never
8342+
* resolves to a record.
8343+
*
8344+
* ## What this deliberately does NOT ask
8345+
*
8346+
* - NOT `enabled` / `status`. `disablePackage` flips lifecycle flags and
8347+
* removes no contributor, so a disabled package's objects stay registered
8348+
* and stay dispatchable; reading the flag here would unregister a
8349+
* definition nothing else removes — the same outage through a second door.
8350+
* - NOT the manifest's `objects` list. That would re-ask "is this
8351+
* code-shipped", which is the question whose answer was destroyed; it is
8352+
* also absent for `registerPlugin`-contributed objects.
8353+
*
8354+
* ## The accepted cost, ruled and not to be worked around
8355+
*
8356+
* A package-bound RUNTIME-authored object (Studio's package workspace,
8357+
* #4636) carries a real `package_id` too, so it is indistinguishable from a
8358+
* package-shipped one by binding alone: some genuinely deleted objects stay
8359+
* registered until restart. Per this walk's own REGISTER WIDE / RETIRE
8360+
* NARROW argument that is the cheap direction — a surplus entry degrades to
8361+
* "listable but rowless" and the next reload heals it, a wrongly retired one
8362+
* 404s data CRUD for every tenant. The honest fix for the distinguishability
8363+
* itself is #6853's direction B (the tenant overlay registers as its own
8364+
* contributor layer instead of splicing out the packaged `own`), which
8365+
* re-arms `isArtifactBacked` here and at `saveMetaItem`'s overlay gate; it is
8366+
* an ADR-0029 amendment and a separate card by maintainer ruling
8367+
* (2026-08-09).
8368+
*
8369+
* Name-addressed, like every other verb in the walk: `getObjectOwner` reads
8370+
* the contributor list under the same key `getObject` and
8371+
* {@link SchemaRegistry.unregisterObject} resolve (`computeFQN` is identity,
8372+
* so the registry key IS the object name), which is what keeps the decision
8373+
* and the removal talking about the same entry.
8374+
*/
8375+
private installedPackageBindingForObject(name: string): string | undefined {
8376+
const registry: any = (this.engine as any)?.registry;
8377+
if (
8378+
!registry
8379+
|| typeof registry.getObjectOwner !== 'function'
8380+
|| typeof registry.getPackage !== 'function'
8381+
) {
8382+
return undefined;
8383+
}
8384+
const packageId: unknown = registry.getObjectOwner(name)?.packageId;
8385+
if (typeof packageId !== 'string' || packageId === '') return undefined;
8386+
const installed = registry.getPackage(packageId);
8387+
return installed === undefined || installed === null ? undefined : packageId;
8388+
}
8389+
82788390
/**
82798391
* Ensure a just-PUBLISHED object's physical table exists so it is usable
82808392
* for data CRUD immediately — without a server restart. Registering the

0 commit comments

Comments
 (0)