From bf4896f905c3cc05262c5f94d0febdedb61cee77 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 8 Aug 2026 10:24:39 +0000 Subject: [PATCH] fix(objectql,metadata-protocol): retire an overlay's registry entry on delete (#5079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a runtime-CREATED metadata item removed its `sys_metadata` row and reported `reset: true`, while `GET /meta/`, `GET /meta//` and the ADR-0110 D3 declaration gate all kept serving it for the life of the process. #4521's write-through registers such an item under the SchemaRegistry's PLAIN key; the delete's heal (`removeRuntimeShadow`) only un-shadows a packaged artifact, and a runtime-created item has none — so nothing ever removed it. `restoreArtifactRegistryView` now walks the layers under the deleted overlay and stops at the first that can serve the name: composite-key artifact, then a MetadataService baseline, then — new — retire the plain-key entry through `SchemaRegistry.removeOverlayEntry`. The layer-2 read is diagnosed, so a degraded metadata plane stops the walk instead of retiring on an unread answer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw --- ...a-delete-retires-overlay-registry-entry.md | 53 +++++ packages/metadata-protocol/src/protocol.ts | 78 +++++-- .../src/protocol-registry-shadow.test.ts | 64 ++++++ packages/objectql/src/registry.ts | 49 +++++ .../src/meta-overlay-read-your-writes.test.ts | 190 ++++++++++++++++++ 5 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 .changeset/meta-delete-retires-overlay-registry-entry.md diff --git a/.changeset/meta-delete-retires-overlay-registry-entry.md b/.changeset/meta-delete-retires-overlay-registry-entry.md new file mode 100644 index 0000000000..99b8927797 --- /dev/null +++ b/.changeset/meta-delete-retires-overlay-registry-entry.md @@ -0,0 +1,53 @@ +--- +"@objectstack/objectql": patch +"@objectstack/metadata-protocol": patch +--- + +fix(objectql,metadata-protocol): deleting a runtime-created overlay retires its registry entry, so list/get/dispatch agree (#5079) + +Deleting a metadata item an admin had **created** at runtime (`DELETE +/api/v1/meta//` for a name no code package ships) removed the +`sys_metadata` row and reported `reset: true`, while every read surface kept +serving the deleted item for the life of the process: `GET /meta/` still +enumerated it, `GET /meta//` still returned its body, and the +ADR-0110 D3 declaration gate still resolved a declaration for it. No TTL was +involved — only a restart cleared it. This is the residual branch of #4432 +("every surface in agreement"), the mirror image of the write direction #4521 +fixed. + +**Cause.** #4521 made `saveMetaItem` write an overlay through into the engine's +`SchemaRegistry` under the PLAIN key, so a saved item is dispatchable and not +merely listable. The delete side's registry heal +(`restoreArtifactRegistryView`) only knew how to *un-shadow a packaged +artifact*: `SchemaRegistry.removeRuntimeShadow` deletes the plain key **only** +when a composite `:` artifact remains underneath, so that the +name stays resolvable. For a runtime-created item there is no artifact — +the row *was* the item — so the heal declined and nothing else ever removed the +entry. + +**Fix — at the producer, not the readers.** `restoreArtifactRegistryView` now +walks the layers under the deleted overlay and stops at the first one that can +serve the name: (1) a composite-key artifact, (2) a MetadataService baseline, +and (3) — new — nothing, in which case the plain-key entry is retired via the +new `SchemaRegistry.removeOverlayEntry(type, name)`. The registry now makes the +same distinction the delete receipt already makes (#5927): "reset to artifact +default" vs "it no longer exists". + +Two boundaries are preserved deliberately: + +- **A packaged artifact is never unregistered.** `removeOverlayEntry` refuses a + plain-key entry that is itself an artifact (`_packageId` set, not the + `sys_metadata` rehydration sentinel, not tenant-authored) — the same + predicate `getArtifactItem` applies to its own bare-key fallback — and never + touches composite keys. Resetting a customization of a shipped item still + reveals the shipped value. +- **An outage is not an absence (ADR-0110 D3).** The layer-2 baseline read now + decides whether an entry is retired, so it goes through the diagnosed read: a + metadata plane that could not answer stops the walk instead of retiring an + entry on the strength of a read that never happened. + +Measured on the showcase app: before, `POST /api/v1/actions//` +after the delete answered 404 with the *handler-miss* wording ("… not found"), +because the declaration was still resolvable from the stale entry; it now +answers the ADR-0110 "has no declaration" 404 — byte-identical to the state +before the item was ever created. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3219039c0f..506bbeb385 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7595,7 +7595,8 @@ export class ObjectStackProtocolImplementation implements /** * Heal the in-memory registry after a metadata reset (overlay-row - * delete) on control-plane kernels. Two layers: + * delete). Walks the layers UNDER the deleted overlay, in order, and + * stops at the first one that can serve the name: * * 1. Drop the plain-key runtime shadow so the packaged artifact * (registered under `:`) becomes the visible @@ -7608,6 +7609,44 @@ 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. + * 3. [#5079] When NEITHER layer has anything, the deleted row was the + * whole item — so the plain-key entry is retired too + * ({@link SchemaRegistry.removeOverlayEntry}). + * + * ## Why step 3 exists (#5079, the #4432 residual) + * + * Step 1 declines for a runtime-CREATED item: `removeRuntimeShadow` only + * un-shadows a packaged artifact, and there is none. Step 2 then found + * nothing either — and the method returned, leaving the plain-key entry + * that #4521's write-through had put there. Nothing else ever removed it, + * so for the life of the process `GET /meta/` kept enumerating a + * deleted item, `GET /meta//` kept serving its body, and the + * ADR-0110 D3 declaration gate kept resolving it — while the row was gone + * from `sys_metadata` and the handler registry had already dropped it. + * The measured symptom: after `DELETE /meta/action/x`, `POST + * /actions//x` 404s with the *handler-miss* wording ("not found") + * instead of ADR-0110's "has no declaration", because the declaration was + * still resolvable from this stale entry. The delete's own receipt already + * tells the truth here — #5927 splits it into "reset to artifact default" + * (artifact-backed) vs "it no longer exists" (runtime-only); step 3 is the + * registry making the same distinction the receipt makes. + * + * ## Why the layer-2 read is now diagnosed, and runs on every kernel + * + * [#5840] left this read on plain `get` because it "decides nothing" — + * true then, false now: its `undefined` is what licenses step 3 to retire + * an entry. So it goes through {@link readItemFromMetadataService}, which + * carries the ADR-0110 D3 verdict, and a DEGRADED read stops the walk + * without retiring anything. Retiring on an outage would answer "this + * item exists in no layer" from a read that never reached one — the exact + * miss-vs-outage confusion #5532/#5840 closed on the sibling paths. The + * same helper also folds in the singular/plural retry, so a baseline + * stored under the twin spelling is found rather than retired. + * + * RE-REGISTRATION stays control-plane-only (`environmentId === undefined`) + * — the historical refresh semantics of the original call sites, unchanged. + * Only the READ is now unconditional, because a project kernel needs the + * same evidence before retiring an entry. * * Best-effort: a failure must never block the delete that already * succeeded; the next full reload fixes the registry anyway. @@ -7615,35 +7654,32 @@ export class ObjectStackProtocolImplementation implements private async restoreArtifactRegistryView(type: string, name: string): Promise { try { const registry: any = this.engine.registry; + const singular = PLURAL_TO_SINGULAR[type] ?? type; let healed = false; if (typeof registry.removeRuntimeShadow === 'function') { - const singular = PLURAL_TO_SINGULAR[type] ?? type; healed = registry.removeRuntimeShadow(singular, name); if (type !== singular) { healed = registry.removeRuntimeShadow(type, name) || healed; } } if (healed) return; - // MetadataService re-registration is control-plane-only — it - // preserves the historical refresh semantics gated on - // `environmentId === undefined` at the original call sites. - if (this.environmentId !== undefined) return; - const services = this.getServicesRegistry?.(); - const metadataService = services?.get('metadata'); - if (metadataService && typeof metadataService.get === 'function') { - // [#5840] Measured and deliberately left on plain `get`. This - // read decides nothing and asserts nothing: it returns void, - // its `undefined` produces no answer to any caller, and the - // method's own contract above is "best-effort, the next full - // reload fixes the registry anyway". Routing it through - // `getDiagnosed` could only add a log line to a path that is - // already documented as silent — over-applying the rule, which - // is how `error`/`warn` become unreadable (AGENTS.md - // "Degradation log levels", the do-not-over-apply half). - const artifactItem = await metadataService.get(type, name); - if (artifactItem !== undefined) { - this.engine.registry.registerItem(type, artifactItem, 'name'); + + const baseline = await this.readItemFromMetadataService(type, name); + if (baseline.data !== undefined && baseline.data !== null) { + if (this.environmentId === undefined) { + this.engine.registry.registerItem(type, baseline.data, 'name'); } + return; + } + // ADR-0110 D3 — an outage is not an absence. Leave the entry: it + // is stale, which is exactly where this method already was, and a + // later delete or reload heals it. + if (baseline.degraded) return; + + // [#5079] No artifact, no baseline: the row WAS the item. + if (typeof registry.removeOverlayEntry === 'function') { + registry.removeOverlayEntry(singular, name); + if (type !== singular) registry.removeOverlayEntry(type, name); } } catch { // Best-effort registry refresh; next read fixes it anyway diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index 22515d1bf2..93aa256957 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -322,3 +322,67 @@ describe('SchemaRegistry.getArtifactItem / removeRuntimeShadow', () => { expect((registry.getItem('app', 'mine') as any)?.label).toBe('Mine'); }); }); + +/** + * [#5079] The other half of the reset heal — the case + * {@link SchemaRegistry.removeRuntimeShadow} above deliberately declines. + * + * A runtime-CREATED item has no packaged artifact under a composite key, so + * `removeRuntimeShadow` leaves its plain-key entry standing (pinned directly + * above, and correct: that method's job is un-shadowing an artifact). Since + * #4521's write-through puts such an item in the registry, nothing else ever + * removed it — `getMetaItems` kept enumerating a deleted item for the life of + * the process. `removeOverlayEntry` is what `deleteMetaItem` calls once both + * lower layers have answered "nothing". + */ +describe('SchemaRegistry.removeOverlayEntry', () => { + it('removes the plain-key entry of a runtime-only item', () => { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.registerItem('app', { name: 'mine', label: 'Mine' }, 'name'); + + expect(registry.removeOverlayEntry('app', 'mine')).toBe(true); + expect(registry.getItem('app', 'mine')).toBeUndefined(); + expect(registry.listItems('app')).toEqual([]); + }); + + it('removes a `sys_metadata`-sentinel rehydration entry', () => { + // `loadMetaFromDb` stamps the sentinel on package-less overlay rows; + // it marks the entry as a rehydration, not a shipped artifact. + const registry = new SchemaRegistry({ multiTenant: false }); + registry.registerItem('app', { name: 'hydrated', label: 'Hydrated', _packageId: 'sys_metadata' }, 'name'); + + expect(registry.removeOverlayEntry('app', 'hydrated')).toBe(true); + expect(registry.getItem('app', 'hydrated')).toBeUndefined(); + }); + + it('removes a tenant-authored entry even when it carries a real package id', () => { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.registerItem('app', { name: 'org_authored', label: 'Org', _packageId: PKG, _provenance: 'org' }, 'name'); + + expect(registry.removeOverlayEntry('app', 'org_authored')).toBe(true); + expect(registry.getItem('app', 'org_authored')).toBeUndefined(); + }); + + it('REFUSES a plain-key entry that is itself a packaged artifact', () => { + // `loadMetadataFromService` passes the item's own `_packageId` through, + // so a package-shipped item can be registered under the plain key. + // Unregistering it would delete shipped code an overlay delete never + // touched — strictly worse than the staleness this method removes. + const registry = new SchemaRegistry({ multiTenant: false }); + registry.registerItem('app', { name: 'shipped', label: 'Shipped', _packageId: PKG }, 'name'); + + expect(registry.removeOverlayEntry('app', 'shipped')).toBe(false); + expect((registry.getItem('app', 'shipped') as any)?.label).toBe('Shipped'); + }); + + it('never touches composite keys, and reports nothing to remove', () => { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.registerItem('app', artifactApp(), 'name', PKG); + + // No plain-key entry at all: the artifact must survive untouched. + expect(registry.removeOverlayEntry('app', 'setup')).toBe(false); + expect((registry.getArtifactItem('app', 'setup') as any)?.label).toBe('Setup'); + // An unknown type is a no-op, not a throw. + expect(registry.removeOverlayEntry('nope', 'setup')).toBe(false); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5b3ce386ff..a098346621 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1653,6 +1653,55 @@ export class SchemaRegistry { return false; } + /** + * [#5079] Remove the PLAIN-KEY entry for `(type, name)` — the slot an + * ADR-0005 overlay row hydrates into — and nothing else. The composite + * (`:`) entries are never touched. + * + * The other half of {@link removeRuntimeShadow}, for the case that method + * deliberately declines. `removeRuntimeShadow` drops the plain key only + * when a packaged artifact remains underneath, so the name stays + * resolvable; that was the whole story while the plain key could only ever + * be an artifact's shadow. #4521 changed it: `saveMetaItem` now writes an + * overlay through into the registry, so a runtime-CREATED item (nothing + * shipped under that name) lives under the plain key too — and its DELETE + * had nothing that would ever remove it. `GET /meta/` kept + * enumerating a deleted item and `GET /meta//` kept serving its + * body for the life of the process (#4432's "every surface in agreement" + * clause, residual). plugin-security's `permission` projection carries a + * consumer-side work-around for the same lingering entry + * (`readDeclaredBody` skipping shadows so a deleted set is not undeletable); + * this is the producer-side removal it was compensating for. + * + * Whether the item really is gone from every OTHER layer is not a fact this + * registry holds — the MetadataService may still serve a baseline for the + * name — so the caller decides. `deleteMetaItem`'s + * `restoreArtifactRegistryView` calls this only after both lower layers + * (composite artifact, MetadataService baseline) have answered "nothing". + * + * Refuses exactly one entry: a plain-key registration that IS a packaged + * artifact — `_packageId` set, not the `'sys_metadata'` rehydration + * sentinel, not tenant-authored — which is the same predicate + * {@link getArtifactItem} applies to its own bare-key fallback. Artifact + * loaders normally register under a composite key, but + * `loadMetadataFromService` passes the item's own `_packageId` through, so a + * package-stamped item can land here; unregistering shipped code that the + * overlay delete never touched would be a worse bug than the one this fixes. + * + * @returns whether an entry was removed. + */ + removeOverlayEntry(type: string, name: string): boolean { + const collection = this.metadata.get(type); + if (!collection || !collection.has(name)) return false; + const plain = collection.get(name) as any; + if (plain && plain._packageId && plain._packageId !== 'sys_metadata' && !isTenantAuthored(plain)) { + return false; + } + collection.delete(name); + this.log(`[Registry] Removed overlay entry ${type}: ${name} (no layer serves it any more)`); + return true; + } + /** * Universal List Method */ diff --git a/packages/runtime/src/meta-overlay-read-your-writes.test.ts b/packages/runtime/src/meta-overlay-read-your-writes.test.ts index be56c34644..af24afae19 100644 --- a/packages/runtime/src/meta-overlay-read-your-writes.test.ts +++ b/packages/runtime/src/meta-overlay-read-your-writes.test.ts @@ -23,6 +23,24 @@ * boundaries the fix must not cross: a genuinely absent declaration still * resolves to nothing (ADR-0110's 404 stands), and a `draft` save is still * not live. + * + * ── #5079 — the same invariant in the DELETE direction ── + * + * The second describe block below is the mirror image, and it lives in this + * file on purpose: the two directions are one invariant ("every surface in + * agreement", #4432), and splitting them across two suites is how they drift. + * #5079 measured the residual on `origin/main`: after `DELETE + * /api/v1/meta/action/` of a runtime-CREATED overlay, the row was gone + * from `sys_metadata` and yet `GET /meta/action` kept listing it, `GET + * /meta/action/` kept serving its body, and `resolveRouteActionDeclaration` + * kept resolving it — for the life of the process, no TTL involved. + * + * The lagging cache is the same `SchemaRegistry`, from the other end: #4521's + * write-through puts a runtime-created overlay under the PLAIN key, and the + * delete's registry heal (`restoreArtifactRegistryView`) only knew how to + * un-shadow a packaged artifact — `removeRuntimeShadow` declines when there + * is none, which for a runtime-created item is always. Nothing else ever + * removed the entry. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -203,3 +221,175 @@ describe('#4521 — read-your-writes between saveMeta and the dispatch path', () expect(registry.getItem('action', 'draft_probe')).toBeUndefined(); }); }); + +describe('#5079 — list / get / dispatch agree immediately after deleteMeta', () => { + let registry: SchemaRegistry; + let engine: any; + let protocol: ObjectStackProtocolImplementation; + let ql: any; + let deps: ActionExecutionDeps; + let requestContext: any; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.registerObject(OBJECT_DEF as any, 'showcase'); + engine = makeEngine(registry); + protocol = new ObjectStackProtocolImplementation(engine); + ql = { + registry, + getSchema: (name: string) => (name === OBJECT_DEF.name ? OBJECT_DEF : undefined), + }; + deps = { + resolveService: (async () => undefined) as any, + getObjectQL: async () => ql, + } as ActionExecutionDeps; + requestContext = { request: {} } as any; + }); + + const saveAction = (item: any, mode?: 'draft' | 'publish') => + protocol.saveMetaItem({ + type: 'action', + name: item.name, + item, + ...(mode ? { mode } : {}), + }); + + /** The three surfaces #4432 requires to agree, read in one go. */ + const surfaces = async (actionName: string) => { + const listed = await protocol.getMetaItems({ type: 'action' }); + const fetched = await protocol.getMetaItem({ type: 'action', name: actionName }); + const dispatch = await resolveRouteActionDeclaration(deps, requestContext, { + ql, + objectName: OBJECT_DEF.name, + actionName, + }); + return { + listedNames: (listed.items as any[]).map((i) => i?.name), + item: fetched.item as any, + declaration: dispatch.action as any, + degraded: dispatch.degraded, + }; + }; + + it('a runtime-created overlay vanishes from list AND get AND dispatch in the same step', async () => { + // The #5079 repro, protocol-level: PUT a name nothing ships, confirm + // all three surfaces agree it EXISTS, delete it, confirm all three + // agree it does not — with no listing call, no reload and no TTL wait + // in between. + await saveAction({ + name: 'rg_clean', + label: 'RG Clean', + objectName: 'showcase_task', + type: 'script', + target: 'showcase.probe', + }); + + const before = await surfaces('rg_clean'); + expect(before.listedNames).toContain('rg_clean'); + expect(before.item?.label).toBe('RG Clean'); + expect(before.declaration?.name).toBe('rg_clean'); + + const deleted = await protocol.deleteMetaItem({ type: 'action', name: 'rg_clean' }); + expect(deleted.success).toBe(true); + expect(deleted.reset).toBe(true); + // #5927 — nothing is shipped under this name, so the receipt says the + // item is gone rather than "reset to artifact default". The registry + // must now make the same statement. + expect(deleted.message).toContain('no longer exists'); + + const after = await surfaces('rg_clean'); + // Pre-fix ALL THREE of these still carried the deleted overlay: the + // listing enumerated it, the single-item read served its body, and the + // ADR-0110 D3 gate resolved a declaration for it — so `POST + // /actions/showcase_task/rg_clean` 404ed with the handler-miss wording + // ("… not found") instead of "has no declaration". + expect(after.listedNames).not.toContain('rg_clean'); + expect(after.item).toBeUndefined(); + expect(after.declaration).toBeUndefined(); + // A miss, not an outage (ADR-0110 D3) — the store answered. + expect(after.degraded).toBeFalsy(); + // And the row really is gone, so the surfaces are not merely agreeing + // with each other about a stale copy. (`state` is a `sys_metadata` + // column the append-only history rows do not carry, so this counts + // live overlays only.) + const overlayRows = await engine.find('sys_metadata', { + where: { type: 'action', name: 'rg_clean', state: 'active' }, + }); + expect(overlayRows).toHaveLength(0); + }); + + it('deleting the LAST overlay of a type leaves an empty listing, not a ghost', async () => { + await saveAction({ name: 'only_one', label: 'Only', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }); + expect((await surfaces('only_one')).listedNames).toEqual(['only_one']); + + await protocol.deleteMetaItem({ type: 'action', name: 'only_one' }); + expect((await surfaces('only_one')).listedNames).toEqual([]); + }); + + it('an ARTIFACT-backed delete resets to the shipped value — it does not retire the name', async () => { + // The boundary the #5079 fix must not cross. `removeRuntimeShadow` + // still owns this case; `removeOverlayEntry` must never reach it, or a + // "reset to artifact default" would delete the artifact instead of + // revealing it. + registry.registerItem( + 'action', + { name: 'shipped_probe', label: 'Shipped', type: 'script', target: 'showcase.shipped' }, + 'name', + 'showcase', + ); + await saveAction({ name: 'shipped_probe', label: 'Customized', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }); + expect((await surfaces('shipped_probe')).item?.label).toBe('Customized'); + + const deleted = await protocol.deleteMetaItem({ type: 'action', name: 'shipped_probe' }); + expect(deleted.message).toContain('reset to artifact default'); + + const after = await surfaces('shipped_probe'); + expect(after.listedNames).toContain('shipped_probe'); + expect(after.item?.label).toBe('Shipped'); + expect(after.declaration?.label).toBe('Shipped'); + }); + + it('discarding a DRAFT leaves the live overlay listed and dispatchable', async () => { + // A draft discard is not a retirement. `restoreArtifactRegistryView` + // is skipped for `state: 'draft'`, so the active entry must survive — + // pinned because the #5079 step 3 is the first thing in this method + // that can make an entry disappear entirely. + await saveAction({ name: 'live_probe', label: 'Live', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }); + await saveAction({ name: 'live_probe', label: 'Pending', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }, 'draft'); + + await protocol.deleteMetaItem({ type: 'action', name: 'live_probe', state: 'draft' }); + + const after = await surfaces('live_probe'); + expect(after.listedNames).toContain('live_probe'); + expect(after.item?.label).toBe('Live'); + expect(after.declaration?.label).toBe('Live'); + }); + + it('deleting one overlay does not retire its namesake in another type', async () => { + // `removeOverlayEntry` is addressed by (type, name); a `flow` named + // `twin` must survive the delete of the `action` named `twin`. + await saveAction({ name: 'twin', label: 'Action Twin', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }); + await protocol.saveMetaItem({ + type: 'flow', + name: 'twin', + item: { name: 'twin', label: 'Flow Twin', type: 'autolaunched', nodes: [], edges: [] }, + }); + + await protocol.deleteMetaItem({ type: 'action', name: 'twin' }); + + expect((await protocol.getMetaItems({ type: 'action' })).items).toHaveLength(0); + expect(((await protocol.getMetaItems({ type: 'flow' })).items as any[]).map((i) => i?.name)).toContain('twin'); + }); + + it('deleting a name that was never written removes nothing it did not own', async () => { + // The `!current` self-heal branch calls the same registry heal. A + // no-op delete must stay a no-op for every other item. + await saveAction({ name: 'keeper', label: 'Keeper', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }); + + const deleted = await protocol.deleteMetaItem({ type: 'action', name: 'never_written' }); + expect(deleted.success).toBe(true); + expect(deleted.reset).toBe(false); + + expect((await surfaces('keeper')).listedNames).toEqual(['keeper']); + }); +});