Skip to content

Commit cad343e

Browse files
fix(metadata-protocol): deleting a runtime object hydrated at boot unregisters it at once; the metadata service's boot copy is not its code layer (#23659)
Fixes #23645 Clause-②: no #23514 remains open. This PR is the delete-heal prerequisite that card's refusal needs. That card's prescription is "delete the package-less definition without `dropStorage`, then save naming the package", and after this PR it is a real in-process way out, with the table and records kept. This PR does not touch that card's branch or its refusal. The delete door's status and wording are unchanged. ## The defect, measured on `origin/main` `a647783d46` The kernel was a real `ObjectKernel` with `MetadataPlugin` (a `NodeMetadataManager`, `watch: false`), `ObjectQLPlugin` with no `environmentId`, and a memory driver. A second kernel over the same store served as the boot. A runtime object was saved, given 2 records, and then hydrated at boot. `DELETE` with no package and no `dropStorage` answered `{ success: true, reset: true }` and "it no longer exists". The stored row was gone, but until the next boot: - the registry owner stayed (`sys_metadata`, or the bound package); - the data plane served the 2 records; - `getMetaItem`, the object list and the layered read all served the deleted definition. The same run repeated on the kernel's fallback metadata (no `MetadataPlugin`) gave the same result. Registry verbs spied for this object: `removeObjectOverlay -> false | removeRuntimeShadow -> false | registerItem(object)`, then a return. The same object saved and deleted in one process gave `... | removeOverlayEntry -> true | unregisterObject -> true`. ## The producer `ObjectQLPlugin.start()` runs `bridgeObjectsToMetadataService` on a kernel with no `environmentId`. It copies every object the registry holds into the `metadata` service. That includes each runtime object hydrated from `sys_metadata`, whose copy carries the registry's `_provenance: 'org'` (measured: `{ _packageId: 'sys_metadata' or the bound package, _provenance: 'org' }`). Nothing removes the copy when the row is deleted. The protocol reads the `metadata` service as the CODE layer in four places, through one helper, `readItemFromMetadataService`: - the heal's tier-2 baseline (`restoreArtifactRegistryView`); - `getMetaItem` step 2; - the layered `code` layer; - the carry-forward's code layer. The list merge reads the service through `metadataService.list`. So the heal re-registered the copy and returned before tier 3, and the reads served it too. An object saved in the same process was never copied, so its delete worked. A kernel with an `environmentId` never runs the bridge (the standalone stack defaults to `env_local`). Measured there, the same boot-hydrated delete was already correct. ## The fix, and its landing point `readItemFromMetadataService` answers an `object` body stamped tenant-authored as a clean miss (not degraded). It uses the discriminator `getMetaItemLayered` already applies to the registry's plain key: the ADR-0010 marker read through `isTenantAuthored`, with no new marker. The list merge drops such `object` items from its base. The stored rows above that merge already serve every live object. The heal's own code is unchanged. Only its tier-2 header gained a note. The walk now reaches tier 3, which unregisters the object, for a boot-hydrated runtime object as for one saved in the same process. Why not the PM's suggested route alone (an `isArtifactBacked` predicate at tier 2): that route makes the registry and data plane agree. But measured with tier 3 reached and the copy left in the service, `getMetaItem` still served the deleted definition, the list still held it, and the layered `code` layer was still the copy. The card's "metadata read agrees" needs the read fixed too. Two other landing points were rejected: - **The bridge** (stop copying tenant-authored objects). MCP's stdio data bridge reads `metadataService.getObject` for `enforceApiExposure` (`packages/mcp/src/stdio-data-bridge.ts`). Removing the copy would make that gate fall open to schema defaults for every boot-hydrated runtime object. - **Retiring the copy from the heal** through `metadataService.unregister`. `MetadataManager.unregister` deletes through every writable `datasource:` loader, and `DatabaseLoader.delete` matches only `type` and `name`. On a host that wires one, that would delete an arbitrary `sys_metadata` row of the name, for example a draft. **Scoped to `object`.** `plugin-security`'s security-catalog echo registers stored `permission` and `position` rows in the service on purpose, stamped `_provenance: 'org'` too. It keeps them current, and its pins read the layered `code` layer as the echo. Measured: with the predicate made type-agnostic, `security-catalog-echo.test.ts` went red (1 failed | 17 passed, `layered.code` read `null`). It is green with the `object` scope. **File surface.** The claim named `restoreArtifactRegistryView`, its tier-2 baseline read included. That read is the shared helper above, so the change also reaches the three other code-layer readers of `object` bodies, all with the same defect class. The list merge (`readFlattenedMetaItems`) is outside the claimed surface. It is fixed in place under the bounded-fix exemption: same defect class, a mechanical one-predicate change, no other claim on that function, and the same gate family. Its pin and ablation leg are below. ## Hypotheses - H1 confirmed, by the verb spy above. - H2: the copy is not a code baseline. Confirmed, but the predicate that holds is the copy's own provenance at the shared read, not `isArtifactBacked` at tier 2 alone (see above). - H3 confirmed. The service's copy is stale after the delete. Its producer is ObjectQL's boot bridge (`packages/objectql`), not `packages/metadata*`, and no delete propagates to it. The fix is at the protocol's code-layer read (reasons above). - H4 measured. A runtime object bound to a real package has the identical defect (owner stayed `com.qa.p`, and a following save naming Q was refused `OBJECT_OVERLAY_PACKAGE_MISMATCH` because P still owned it). The same fix closes it, pinned below. ## Pins New file: `packages/objectql/src/protocol-delete-boot-hydrated-object-heal.test.ts`, on a real kernel with `MetadataPlugin`. Each case asserts the boot copy is present first, so a pass is never for an empty reason. - For a package-less object and a package-bound one, after the delete: - the owner, `getObject`, `getMetaItem` (no item), the list, the layered read (`code`/`overlay`/`effective` all `null`) and the data plane (`OBJECT_NOT_FOUND` / `404`) agree the object is gone; - the table keeps its 2 records, and nothing is dropped. - For both, a later save naming Q makes Q the owner. The data plane, the by-name read and the 2 kept records agree. After a 3rd insert, a boot agrees too (owner Q, 3 records). - CONTROL: a code package's object (registered via `registerApp`, copied with `_provenance: 'package'`) reaching the same delete keeps its baseline. Tier 2 re-registers it, and it stays owned, served and dispatched. ## Negative control (a one-time ablation, not a permanent case) `packages/objectql` reads `@objectstack/metadata-protocol` through `dist` (a `KNOWN_UNALIASED_TEST_IMPORTS` pair), so every leg rebuilt and ran the preflight. The fix blob at HEAD is `5cde7e0e3ed8`. - **Leg A**: deleted the read's guard (`ablation-replace`: anchor `1 -> 0`, blob `5cde7e0e3ed8 -> c175bc008d16`). Rebuild, then preflight `--absent`: marker absent from all 24 built files. Result: `Tests 4 failed | 1 passed`, each failing on the owner assertion. The CONTROL stayed green by design. Restore: blob back to `5cde7e0e3ed8` and `git diff HEAD` empty. Rebuild, then preflight present (1 built file, tree clean). Result: `Tests 5 passed`. - **Leg B**: deleted the list filter (anchor `1 -> 0`, blob `5cde7e0e3ed8 -> f3d07653e65b`). Rebuild, then preflight `--absent`. Result: `Tests 4 failed | 1 passed`, failing on `not.toContain(NAME)` in the list. Restore proven the same way, then `Tests 5 passed`. The predicted direction was red for both legs, and both were observed red. ## Local verification All results below are on HEAD `b215fdeea`, which merges `origin/main` `c7dd48ab4` before opening (AGENTS.md §10). Every run went through `scripts/pm/os-verify-lock.sh`, and every one printed `VERDICT command-exit 0`. - Build of the `@objectstack/objectql...` closure: exit 0. - `@objectstack/metadata-protocol` full suite (`vitest run --maxWorkers=2`): `Test Files 236 passed | 3 skipped (239)`, `Tests 20550 passed | 19 skipped`. Its `typecheck` exited 0. - `@objectstack/objectql` full suite (`vitest run --project local --maxWorkers=2`): `Test Files 423 passed (423)`, `Tests 8123 passed (8123)`. Its `typecheck` (`tsc --noEmit` plus `check:test-typecheck`) exited 0, with the test-layer debt unchanged at 39 files / 233 errors / 64 pinned signatures. - `@objectstack/plugin-security`, `src/security-catalog-echo.test.ts`, the control for the `object` scope: `Tests 18 passed`. - New pin file: `Tests 5 passed (5)`. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b215fdeea` derives 67 families. - 66 exited 0. - 1 is NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3, `PREREQUISITE NOT MET`. It reads every workspace package's `dist`, and only the `objectql` closure was built here. CI runs it in the required `Build Core` job. - `--ran` reconciliation: `67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN`. - Two gates first refused on the shallow clone: `check-engine-split-ratio.mjs --days 90` and `check-plugin-teardown-shape.mjs --self-test`. Both exited 0 after `git fetch --shallow-since=2026-07-10 origin main`. ## Acceptance notes - The `metadata` service's own boot copy stays stale on these kernels after a delete and after a save. Consumers of `IMetadataService` read it, for example MCP's stdio data bridge (`listObjects`, `describeObject`, and the `enforceApiExposure` gate, which reads `enable.apiEnabled` off it). This PR does not change that service. It is reported to the seat as a finding. - Reach: the defect needs `ObjectQLPlugin` with no `environmentId`. That covers the CLI's host-config assembler (`new ObjectQLPlugin()`), `kernel-factory.ts` and a control plane. `createStandaloneStack` (default `env_local`) never had it, as measured. - After a delete, `getMetaItem` answers its usual no-item envelope, and the REST door's 404 mapping is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 94924e0 commit cad343e

3 files changed

Lines changed: 413 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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.
12+
13+
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:
14+
15+
- 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.
16+
- A save afterwards that names a package makes that package the object's owner at once, over the kept table.
17+
- 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.
18+
19+
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.

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9463,6 +9463,37 @@ export class ObjectStackProtocolImplementation implements
94639463
* `degraded` must be the verdict of the WHOLE lookup: a first read that
94649464
* failed is not made trustworthy by an alternate spelling that cleanly
94659465
* missed.
9466+
*
9467+
* ## [#23645] A tenant-authored OBJECT body is not a code layer
9468+
*
9469+
* Every caller reads this as the CODE layer: the delete heal's baseline
9470+
* ({@link restoreArtifactRegistryView}, tier 2), the by-name read's step 2,
9471+
* the layered read's `code` layer and the carry-forward's code layer. For
9472+
* an `object` the service can also hold a copy of a stored row. On a kernel
9473+
* with no `environmentId`, ObjectQL's boot bridge
9474+
* (`bridgeObjectsToMetadataService`) copies every object the registry holds
9475+
* into the service, so every runtime object hydrated from `sys_metadata`
9476+
* lands there too, stamped `_provenance: 'org'`. Nothing removes that copy
9477+
* when the row is deleted. Measured on a real `ObjectKernel` with
9478+
* `MetadataPlugin` and `ObjectQLPlugin`: after `DELETE` of a boot-hydrated
9479+
* runtime object answered "it no longer exists", tier 2 found the copy,
9480+
* re-registered it and returned before tier 3's `unregisterObject`. The
9481+
* owner, the data plane and `getMetaItem` (step 2, the same copy) all kept
9482+
* serving the deleted definition until a restart. An object saved in the
9483+
* same process was never copied, so its delete worked.
9484+
*
9485+
* So an `object` body stamped tenant-authored answers here as a clean miss.
9486+
* This is the discriminator {@link getMetaItemLayered} already applies to
9487+
* the registry's plain key ([#21059]): the ADR-0010 marker the hydrator
9488+
* writes, read through `isTenantAuthored`. ⛔ No new marker. A stored row is
9489+
* read from the store, never from this code-layer read. A miss here is not
9490+
* degraded, because the service answered.
9491+
*
9492+
* ⛔ `object` only. The security catalog's echo (`plugin-security`)
9493+
* registers stored `permission` and `position` rows in the service on
9494+
* purpose, stamped the same way. It keeps them current on every mutation,
9495+
* and this read serves them as the code layer by design: its own pins read
9496+
* the layered `code` layer as the echo.
94669497
*/
94679498
private async readItemFromMetadataService(
94689499
type: string,
@@ -9479,15 +9510,21 @@ export class ObjectStackProtocolImplementation implements
94799510
// occupant of the `metadata` slot makes of a third argument today is
94809511
// unchanged by which of the two methods answers.
94819512
const read = async (t: string): Promise<{ data: unknown; degraded: boolean; errors: string[] }> => {
9513+
let answer: { data: unknown; degraded: boolean; errors: string[] };
94829514
if (typeof metadataService.getDiagnosed === 'function') {
94839515
const diagnosed = await metadataService.getDiagnosed(t, name, packageId);
9484-
return {
9516+
answer = {
94859517
data: diagnosed?.data,
94869518
degraded: diagnosed?.degraded === true,
94879519
errors: Array.isArray(diagnosed?.errors) ? diagnosed.errors : [],
94889520
};
9521+
} else {
9522+
answer = { data: await metadataService.get(t, name, packageId), degraded: false, errors: [] };
94899523
}
9490-
return { data: await metadataService.get(t, name, packageId), degraded: false, errors: [] };
9524+
// [#23645] A stored object row's copy is not a code layer. See the header.
9525+
return canonicalMetaType(t) === 'object' && isTenantAuthored(answer.data)
9526+
? { ...answer, data: undefined }
9527+
: answer;
94919528
};
94929529

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

0 commit comments

Comments
 (0)