Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/23645-delete-heal-boot-hydrated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): deleting a runtime object that was loaded at boot unregisters it at once, instead of leaving it registered and served until a restart

Clause-②: no

Before this fix, deleting such an object could leave it registered. This happened on a kernel whose ObjectQL plugin has no `environmentId`: the CLI's host-config boot, or a control plane. The object was a runtime object created before the server's last start. `DELETE /api/v1/meta/object/NAME` answered "it no longer exists" and removed the stored row, but the object stayed registered. Until a restart, data CRUD kept serving its table's records. `GET /api/v1/meta/object/NAME`, the object list and the layered read kept serving the deleted definition.

The cause: at boot the ObjectQL plugin copies every registered object into the `metadata` service. The delete's registry heal read that copy as the code baseline and registered it again. An object created in the same process was never copied, so its delete already worked.

Now an object body in the `metadata` service stamped tenant-authored (`_provenance: 'org'`) is never read as the object's code layer. What changes on those kernels:

- After the delete, the object is unregistered, as one created in the same process is. Data CRUD answers `404 OBJECT_NOT_FOUND`. The by-name read and the object list no longer serve it. Its table and records are kept: a delete without `dropStorage` drops nothing. This holds whether or not the row was bound to a package.
- A save afterwards that names a package makes that package the object's owner at once, over the kept table.
- The layered read's `code` layer for a runtime object is `null`, as it already was on a kernel with an `environmentId`. Before, it was the boot copy.

Unchanged: an object a code package ships keeps its baseline through the same delete. The `metadata` service itself still holds its boot copy. Other types are not affected: the security catalog's `permission` and `position` entries in the `metadata` service are still read as before.
55 changes: 53 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9436,6 +9436,37 @@ export class ObjectStackProtocolImplementation implements
* `degraded` must be the verdict of the WHOLE lookup: a first read that
* failed is not made trustworthy by an alternate spelling that cleanly
* missed.
*
* ## [#23645] A tenant-authored OBJECT body is not a code layer
*
* Every caller reads this as the CODE layer: the delete heal's baseline
* ({@link restoreArtifactRegistryView}, tier 2), the by-name read's step 2,
* the layered read's `code` layer and the carry-forward's code layer. For
* an `object` the service can also hold a copy of a stored row. On a kernel
* with no `environmentId`, ObjectQL's boot bridge
* (`bridgeObjectsToMetadataService`) copies every object the registry holds
* into the service, so every runtime object hydrated from `sys_metadata`
* lands there too, stamped `_provenance: 'org'`. Nothing removes that copy
* when the row is deleted. Measured on a real `ObjectKernel` with
* `MetadataPlugin` and `ObjectQLPlugin`: after `DELETE` of a boot-hydrated
* runtime object answered "it no longer exists", tier 2 found the copy,
* re-registered it and returned before tier 3's `unregisterObject`. The
* owner, the data plane and `getMetaItem` (step 2, the same copy) all kept
* serving the deleted definition until a restart. An object saved in the
* same process was never copied, so its delete worked.
*
* So an `object` body stamped tenant-authored answers here as a clean miss.
* This is the discriminator {@link getMetaItemLayered} already applies to
* the registry's plain key ([#21059]): the ADR-0010 marker the hydrator
* writes, read through `isTenantAuthored`. ⛔ No new marker. A stored row is
* read from the store, never from this code-layer read. A miss here is not
* degraded, because the service answered.
*
* ⛔ `object` only. The security catalog's echo (`plugin-security`)
* registers stored `permission` and `position` rows in the service on
* purpose, stamped the same way. It keeps them current on every mutation,
* and this read serves them as the code layer by design: its own pins read
* the layered `code` layer as the echo.
*/
private async readItemFromMetadataService(
type: string,
Expand All @@ -9452,15 +9483,21 @@ export class ObjectStackProtocolImplementation implements
// occupant of the `metadata` slot makes of a third argument today is
// unchanged by which of the two methods answers.
const read = async (t: string): Promise<{ data: unknown; degraded: boolean; errors: string[] }> => {
let answer: { data: unknown; degraded: boolean; errors: string[] };
if (typeof metadataService.getDiagnosed === 'function') {
const diagnosed = await metadataService.getDiagnosed(t, name, packageId);
return {
answer = {
data: diagnosed?.data,
degraded: diagnosed?.degraded === true,
errors: Array.isArray(diagnosed?.errors) ? diagnosed.errors : [],
};
} else {
answer = { data: await metadataService.get(t, name, packageId), degraded: false, errors: [] };
}
return { data: await metadataService.get(t, name, packageId), degraded: false, errors: [] };
// [#23645] A stored object row's copy is not a code layer. See the header.
return canonicalMetaType(t) === 'object' && isTenantAuthored(answer.data)
? { ...answer, data: undefined }
: answer;
};

const primary = await read(type);
Expand Down Expand Up @@ -9986,6 +10023,14 @@ export class ObjectStackProtocolImplementation implements
const metadataService = services?.get('metadata');
if (metadataService && typeof metadataService.list === 'function') {
let runtimeItems = await metadataService.list(request.type);
// [#23645] The service's copy of a stored object row is not the
// base this merge lays the stored rows over: the rows above
// already serve every live one, and a copy of a deleted one
// would list it until a restart. `object` only, as in
// {@link readItemFromMetadataService}.
if (request.type === 'object' && Array.isArray(runtimeItems)) {
runtimeItems = runtimeItems.filter((item: unknown) => !isTenantAuthored(item));
}
// When filtering by packageId, only include runtime items that
// belong to the requested package. MetadataService.list() returns
// items from ALL packages, so we must filter here to respect the
Expand Down Expand Up @@ -20473,6 +20518,12 @@ export class ObjectStackProtocolImplementation implements
* MetadataService baseline (FilesystemLoader-sourced types) and
* re-register it, preserving the historical refresh behaviour
* for items the SchemaRegistry never held as artifacts.
* [#23645] A tenant-authored object body is no baseline: on a kernel
* with no `environmentId` the service holds a copy of every object
* hydrated at boot, which is the deleted row itself. The read
* answers it as a miss ({@link readItemFromMetadataService}), so the
* walk reaches step 3 for a runtime object, as for one saved in the
* same process.
* [#22057] Tier 1 also declines when there is no plain-key entry,
* which is what a VIEW row bound to one package leaves under a name
* another package ships ({@link hydrateOverlayIntoRegistry}). So a
Expand Down
Loading
Loading