Repository navigation
Commit cad343e
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
- .changeset
- packages
- metadata-protocol/src
- objectql/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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9463 | 9463 | | |
9464 | 9464 | | |
9465 | 9465 | | |
| 9466 | + | |
| 9467 | + | |
| 9468 | + | |
| 9469 | + | |
| 9470 | + | |
| 9471 | + | |
| 9472 | + | |
| 9473 | + | |
| 9474 | + | |
| 9475 | + | |
| 9476 | + | |
| 9477 | + | |
| 9478 | + | |
| 9479 | + | |
| 9480 | + | |
| 9481 | + | |
| 9482 | + | |
| 9483 | + | |
| 9484 | + | |
| 9485 | + | |
| 9486 | + | |
| 9487 | + | |
| 9488 | + | |
| 9489 | + | |
| 9490 | + | |
| 9491 | + | |
| 9492 | + | |
| 9493 | + | |
| 9494 | + | |
| 9495 | + | |
| 9496 | + | |
9466 | 9497 | | |
9467 | 9498 | | |
9468 | 9499 | | |
| |||
9479 | 9510 | | |
9480 | 9511 | | |
9481 | 9512 | | |
| 9513 | + | |
9482 | 9514 | | |
9483 | 9515 | | |
9484 | | - | |
| 9516 | + | |
9485 | 9517 | | |
9486 | 9518 | | |
9487 | 9519 | | |
9488 | 9520 | | |
| 9521 | + | |
| 9522 | + | |
9489 | 9523 | | |
9490 | | - | |
| 9524 | + | |
| 9525 | + | |
| 9526 | + | |
| 9527 | + | |
9491 | 9528 | | |
9492 | 9529 | | |
9493 | 9530 | | |
| |||
10013 | 10050 | | |
10014 | 10051 | | |
10015 | 10052 | | |
| 10053 | + | |
| 10054 | + | |
| 10055 | + | |
| 10056 | + | |
| 10057 | + | |
| 10058 | + | |
| 10059 | + | |
| 10060 | + | |
10016 | 10061 | | |
10017 | 10062 | | |
10018 | 10063 | | |
| |||
20574 | 20619 | | |
20575 | 20620 | | |
20576 | 20621 | | |
| 20622 | + | |
| 20623 | + | |
| 20624 | + | |
| 20625 | + | |
| 20626 | + | |
| 20627 | + | |
20577 | 20628 | | |
20578 | 20629 | | |
20579 | 20630 | | |
| |||
0 commit comments