From bfa703f75dfa848481c578af487c83d0238f359c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:57:20 +0000 Subject: [PATCH] =?UTF-8?q?fix(metadata-protocol):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E6=8C=89=20isArtifactBacked=20=E5=88=86?= =?UTF-8?q?=E5=8F=A5,=E4=B8=8D=E5=86=8D=E5=AF=B9=20runtime-only=20?= =?UTF-8?q?=E9=A1=B9=E8=B0=8E=E7=A7=B0=E5=9B=9E=E9=80=80=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteMetaItem 的四句成功回执(repository 路径两句 + legacy raw-engine 路径两句) 原本无条件把每一次删除叙述成"摘掉一层 overlay、回落到 artifact 默认值"。对一个 runtime-only 项 —— 管理员新建的 object / flow / hook,没有任何 code package 提供 同名 artifact —— 底下没有默认值可回落:那一行就是这个项的全部,删完它不复存在。 判据沿用 #5265 / PR #5926 在 save 侧的同一个事实:isArtifactBacked,也就是 intent: 'override-artifact' | 'runtime-only' 的来源。新增的方法级绑定替换了 intent 原来那次 inline 调用,分句后 registry 读取次数不增反减。 artifact-backed 两句逐字不变;草稿两句本就没声称过 overlay 或 reset,对两类项都 为真,同样逐字保留。success / reset / seq 一字未动。 反向验证(方向先于运行预测):还原无条件模板 → 预测 6 红 / 9 绿,实测 6 红 / 9 绿。 Fixes #5927 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .changeset/olive-hounds-repeat.md | 30 ++ .../protocol.delete-receipt-wording.test.ts | 369 ++++++++++++++++++ .../protocol.lock-gate-fail-closed.test.ts | 7 + packages/metadata-protocol/src/protocol.ts | 67 +++- 4 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 .changeset/olive-hounds-repeat.md create mode 100644 packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts diff --git a/.changeset/olive-hounds-repeat.md b/.changeset/olive-hounds-repeat.md new file mode 100644 index 0000000000..c2daf536c1 --- /dev/null +++ b/.changeset/olive-hounds-repeat.md @@ -0,0 +1,30 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): 删除回执不再对 runtime-only 项谎称"已重置为 artifact 默认值" + +`deleteMetaItem` 的四句成功回执(repository 路径两句 + legacy raw-engine 路径两 +句)原本无条件把每一次删除都叙述成"摘掉一层 overlay、回落到 artifact 默认值"。 +但对一个 **runtime-only** 项 —— 管理员在 Studio 里新建的 `object` / `flow` / +`hook`,没有任何 code package 提供同名 artifact —— 底下根本没有默认值可回落:那 +一行就是这个项的全部,删掉之后它在任何层都不复存在。回执却把管理员指向一个从未 +存在过的基线。 + +判据与 #5265 / PR #5926 在 save 侧用的是同一个:`isArtifactBacked` —— 也就是 +`intent: 'override-artifact' | 'runtime-only'` 的来源,本方法内早已算出。新增的 +方法级绑定**替换**了 `intent` 原来的那次 inline 调用,所以分句后 registry 读取次 +数不增反减。 + +| | FROM | TO | +|:---|:---|:---| +| 覆盖了 artifact,删除即回落 | `Customization overlay deleted — / reset to artifact default. [seq=N]` | 逐字不变 | +| runtime-only,删除即消失 | 同上 | `Deleted '' — it no longer exists. [seq=N]` | +| 覆盖了 artifact,本就没有 overlay 行 | `No customization overlay found for / — already at artifact default.` | 逐字不变 | +| runtime-only,本就不存在 | 同上 | `No '' found — nothing to delete.` | + +`success` / `reset` / `seq` 三个字段一字未动 —— `message` 没有任何消费方解析,仅 +作展示。草稿两句(`Draft discarded — …` / `No pending draft for …`)本来就没有声 +称过 overlay 或 reset,对两类项都为真,故逐字保留。legacy raw-engine 路径不写 +history、不发 watch 事件,两句因此本就不带 `[seq=…]`,该差异为既有设计,分句未 +触碰。 diff --git a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts new file mode 100644 index 0000000000..1a5222abaf --- /dev/null +++ b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5927 — the delete receipt says only what the reset path already knows. + * + * `deleteMetaItem` had exactly four success sentences and every one of them + * narrated the delete as "a customization overlay came off, the artifact + * default is now in force": + * + * No customization overlay found for / — already at artifact default. + * Customization overlay deleted — / reset to artifact default. [seq=N] + * No customization overlay found for / — already at artifact default. + * Customization overlay deleted — / reset to artifact default. + * + * (The first pair on the `SysMetadataRepository` path, the second on the + * legacy raw-engine path.) For a RUNTIME-ONLY item — an `object` / `flow` / + * `hook` an admin authored in Studio, with no code package shipping that name + * — there is no artifact underneath to fall back to. The row IS the item, and + * once it is deleted the item does not exist in any layer. "Reset to artifact + * default" points the admin at a baseline that has never existed. + * + * This is #5265 / PR #5926's split (save side) applied to the reset path, on + * the same discriminator: `isArtifactBacked`, the fact `intent: + * 'override-artifact' | 'runtime-only'` is already derived from inside this + * very method. No new read path — the receipt now reads a binding that + * replaced the `intent` expression's own inline call. + * + * --------------------------------------------------------------------------- + * Reverse verification, direction predicted BEFORE running + * --------------------------------------------------------------------------- + * Ordinary red, with a deliberately green half — the same shape #5926 + * recorded. Restoring the unconditional templates (dropping the + * `artifactBacked ? … : …` on all four) turns every `runtime-only` case in + * this file red and leaves every `override-artifact` case green, because + * those sentences are unchanged byte for byte. The green half is the point of + * the split, not slack: a "fix" that merely stopped saying "overlay" + * everywhere would pass the red half and fail here. + * + * Harness: the real delete path over a stub engine, the same shape as + * `protocol.save-receipt-wording.test.ts` and + * `protocol.lock-gate-fail-closed.test.ts`. The receipt is built INSIDE + * `deleteMetaItem`, so a harness that mocks `deleteMetaItem` cannot see it. + */ +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / +// #5480 update). Imported from `@objectstack/metadata-core`, never from +// `@objectstack/objectql`: objectql DEPENDS ON this package, so that import +// would close a dependency cycle turbo rejects outright. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + checksum: string; + metadata: string; +} + +/** An overlay row as `deleteMetaItem` will find it: a real `checksum` is */ +/** required or the OCC parent-version check 409s before any receipt is built. */ +function overlayRow(type: string, name: string, state: 'active' | 'draft' = 'active'): Row { + return { + id: `row_${type}_${name}_${state}`, + type, + name, + organization_id: null, + state, + checksum: 'sha256:stored-head', + metadata: JSON.stringify({ name, label: 'Stored' }), + }; +} + +function makeStubEngine(opts: { + rows?: Row[]; + artifacts?: Array<{ type: string; name: string }>; +}) { + const rows = [...(opts.rows ?? [])]; + const artifactKeys = new Set((opts.artifacts ?? []).map((a) => `${a.type}|${a.name}`)); + /** Match only on the keys the caller actually constrained (`whereFor` */ + /** omits `state` on the legacy path and `package_id` on delete). */ + const matches = (row: Row, where: Record = {}) => + Object.entries(where).every(([k, v]) => (row as any)[k] === v); + + const engine: any = { + async findOne(table: string, query: { where?: Record } = {}) { + if (table !== 'sys_metadata') return null; + return rows.find((r) => matches(r, query.where)) ?? null; + }, + // History counters (`nextItemVersion` / `nextEventSeq`) read here. + async find() { return []; }, + async insert(_table: string, data: Record) { + return { id: 'inserted', ...data }; + }, + async update(_table: string, data: Record, options?: Record) { + assertEngineUpdateDispatch(data, options); + return { id: null }; + }, + async delete(_table: string, options?: Record) { + assertEngineDeleteDispatch(options); + const id = (options as any)?.where?.id; + const at = rows.findIndex((r) => r.id === id); + if (at >= 0) rows.splice(at, 1); + return { deleted: at >= 0 ? 1 : 0 }; + }, + async count() { return 0; }, + async transaction(fn: (ctx: unknown) => Promise) { return fn(undefined); }, + async execute() { return {}; }, + async getObjectSchema() { return undefined; }, + registry: { + getObject: () => undefined, + getItem: () => undefined, + listItems: () => [], + registerItem: () => {}, + registerObject: () => {}, + applyNavContributions: (x: unknown) => x, + isPackageDisabled: () => false, + getObjectOwner: () => undefined, + // `isArtifactBacked` prefers this lookup — a hit here means the + // name is shipped by a code package (`_packageId` provenance). + getArtifactItem: (type: string, name: string) => + artifactKeys.has(`${type}|${name}`) ? { name, _packageId: 'showcase' } : undefined, + }, + }; + return { engine, rows }; +} + +/** + * TENANT scope (`environmentId` set) — the ordinary runtime kernel, where the + * two-tier delete authorization runs and overlay-allowed types route through + * `SysMetadataRepository`. + */ +function tenantProtocol(opts: Parameters[0]) { + const { engine, rows } = makeStubEngine(opts); + return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1') as any, rows }; +} + +/** + * CONTROL-PLANE scope (`environmentId` undefined) — the only scope from which + * a code-only type (`allowOrgOverride: false` AND `allowRuntimeCreate: false`) + * reaches the legacy raw-engine delete path that owns sentences 3 and 4. See + * the block comment on that path in `protocol.ts` (#5264): it is alive on + * purpose, and it is the only code that can serve such a delete. + */ +function controlPlaneProtocol(opts: Parameters[0]) { + const { engine, rows } = makeStubEngine(opts); + return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map()) as any, rows }; +} + +/** The registry facts every case below stands on, read rather than restated. */ +const entry = (type: string) => DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type); + +describe('#5927 — a delete receipt names what actually happened', () => { + it('the registry really does declare the two populations these cases need', () => { + // The premise, pinned. `object` is runtime-writable but NOT + // org-overridable, so it can only ever be a runtime-only delete; + // `view` is both, so it can be either; `job` is neither, which is what + // routes it to the legacy path. If any of this flips, these cases must + // be re-read rather than repaired. + expect(entry('object')).toMatchObject({ allowRuntimeCreate: true, allowOrgOverride: false }); + expect(entry('view')).toMatchObject({ allowRuntimeCreate: true, allowOrgOverride: true }); + expect(entry('job')).toMatchObject({ allowRuntimeCreate: false, allowOrgOverride: false }); + }); + + // ── repository path — sentence 2 (a row was deleted) ────────────────── + + it('deleting a runtime-only object is not reported as a reset to a default', async () => { + // The issue's own example: an admin deletes an `object` they created. + // Nothing is shipped under that name, so nothing survives the delete. + const { protocol } = tenantProtocol({ rows: [overlayRow('object', 'rc9_widget')] }); + + const result = await protocol.deleteMetaItem({ type: 'object', name: 'rc9_widget' }); + + expect(result.success).toBe(true); + expect(result.reset).toBe(true); + expect(result.message).not.toContain('artifact default'); + expect(result.message).not.toContain('Customization overlay'); + expect(result.message).toBe( + `Deleted object 'rc9_widget' — it no longer exists. [seq=${result.seq}]`, + ); + }); + + it('deleting a runtime-only view — overlay-capable type, still no artifact', async () => { + // `view` is `supportsOverlay: true`, but a view nobody shipped is + // still the admin's own creation. The receipt follows the fact, not + // the registry flag — this is the case that proves the rule is keyed + // on artifact backing rather than on the type's capabilities. + const { protocol } = tenantProtocol({ rows: [overlayRow('view', 'rc9_probe_view')] }); + + const result = await protocol.deleteMetaItem({ type: 'view', name: 'rc9_probe_view' }); + + expect(result.message).toBe( + `Deleted view 'rc9_probe_view' — it no longer exists. [seq=${result.seq}]`, + ); + }); + + it('deleting an overlay OF a packaged artifact keeps the original sentence', async () => { + const { protocol } = tenantProtocol({ + rows: [overlayRow('view', 'rc9_probe_view')], + artifacts: [{ type: 'view', name: 'rc9_probe_view' }], + }); + + const result = await protocol.deleteMetaItem({ type: 'view', name: 'rc9_probe_view' }); + + expect(result.reset).toBe(true); + expect(result.message).toBe( + `Customization overlay deleted — view/rc9_probe_view reset to artifact default. [seq=${result.seq}]`, + ); + }); + + it('an overlay of a packaged FLOW — supportsOverlay:false, and still a real reset', async () => { + // The mirror case, and the sharpest one. `flow` declares + // `supportsOverlay: false` yet is `allowOrgOverride: true`, so a + // packaged flow really can be overridden at runtime — and then lifting + // that overlay really does restore the packaged default. A receipt + // decided by `supportsOverlay` would get this exactly backwards; one + // decided by artifact backing gets it right. + const { protocol } = tenantProtocol({ + rows: [overlayRow('flow', 'rc9_escalate')], + artifacts: [{ type: 'flow', name: 'rc9_escalate' }], + }); + + const result = await protocol.deleteMetaItem({ type: 'flow', name: 'rc9_escalate' }); + + expect(result.message).toBe( + `Customization overlay deleted — flow/rc9_escalate reset to artifact default. [seq=${result.seq}]`, + ); + }); + + // ── repository path — sentence 1 (nothing was there) ────────────────── + + it('a miss on a runtime-only name does not claim an artifact default', async () => { + // Nothing shipped, nothing stored: the item simply is not there. The + // old sentence answered a question nobody asked ("you are already at + // the default") about a default that does not exist. + const { protocol } = tenantProtocol({}); + + const result = await protocol.deleteMetaItem({ type: 'object', name: 'rc9_widget' }); + + expect(result.success).toBe(true); + expect(result.reset).toBe(false); + expect(result.message).not.toContain('artifact default'); + expect(result.message).toBe(`No object 'rc9_widget' found — nothing to delete.`); + }); + + it('a miss on an artifact-backed name keeps the original sentence', async () => { + // Here it is TRUE: the packaged artifact exists, no overlay row does, + // so the caller really is already at the artifact default. + const { protocol } = tenantProtocol({ artifacts: [{ type: 'view', name: 'rc9_probe_view' }] }); + + const result = await protocol.deleteMetaItem({ type: 'view', name: 'rc9_probe_view' }); + + expect(result.reset).toBe(false); + expect(result.message).toBe( + 'No customization overlay found for view/rc9_probe_view — already at artifact default.', + ); + }); + + // ── legacy raw-engine path — sentences 3 and 4 ──────────────────────── + // + // Reachable only in control-plane bootstrap for a code-only type. It + // writes no history row and emits no watch event, so its receipts carry + // no `[seq=…]` — that asymmetry is pre-existing and the split leaves it + // exactly as it was. + + it('deleting a runtime-only code-only row is not reported as a reset', async () => { + const { protocol } = controlPlaneProtocol({ rows: [overlayRow('job', 'rc9_nightly')] }); + + const result = await protocol.deleteMetaItem({ type: 'job', name: 'rc9_nightly' }); + + expect(result.success).toBe(true); + expect(result.reset).toBe(true); + expect(result.message).not.toContain('artifact default'); + expect(result.message).toBe(`Deleted job 'rc9_nightly' — it no longer exists.`); + expect(result.seq).toBeUndefined(); + }); + + it('deleting an artifact-backed code-only row keeps the original sentence', async () => { + const { protocol } = controlPlaneProtocol({ + rows: [overlayRow('job', 'rc9_nightly')], + artifacts: [{ type: 'job', name: 'rc9_nightly' }], + }); + + const result = await protocol.deleteMetaItem({ type: 'job', name: 'rc9_nightly' }); + + expect(result.message).toBe( + 'Customization overlay deleted — job/rc9_nightly reset to artifact default.', + ); + }); + + it('a legacy-path miss on a runtime-only name does not claim an artifact default', async () => { + const { protocol } = controlPlaneProtocol({}); + + const result = await protocol.deleteMetaItem({ type: 'job', name: 'rc9_nightly' }); + + expect(result.reset).toBe(false); + expect(result.message).toBe(`No job 'rc9_nightly' found — nothing to delete.`); + }); + + it('a legacy-path miss on an artifact-backed name keeps the original sentence', async () => { + const { protocol } = controlPlaneProtocol({ artifacts: [{ type: 'job', name: 'rc9_nightly' }] }); + + const result = await protocol.deleteMetaItem({ type: 'job', name: 'rc9_nightly' }); + + expect(result.message).toBe( + 'No customization overlay found for job/rc9_nightly — already at artifact default.', + ); + }); + + // ── what the split deliberately does NOT touch ──────────────────────── + + it('the draft sentences are unchanged on both sides of the split', async () => { + // A draft discard claims neither an overlay nor a reset, so it was + // already telling the truth for both populations and stays verbatim. + // Pinned so a future "make it symmetric" pass does not invent a + // distinction the draft path does not have. + const runtimeOnly = tenantProtocol({ rows: [overlayRow('object', 'rc9_widget', 'draft')] }); + const backed = tenantProtocol({ + rows: [overlayRow('view', 'rc9_probe_view', 'draft')], + artifacts: [{ type: 'view', name: 'rc9_probe_view' }], + }); + + const a = await runtimeOnly.protocol.deleteMetaItem({ type: 'object', name: 'rc9_widget', state: 'draft' }); + const b = await backed.protocol.deleteMetaItem({ type: 'view', name: 'rc9_probe_view', state: 'draft' }); + + expect(a.message).toBe(`Draft discarded — object/rc9_widget. [seq=${a.seq}]`); + expect(b.message).toBe(`Draft discarded — view/rc9_probe_view. [seq=${b.seq}]`); + }); + + it('a draft miss is unchanged for both populations too', async () => { + const runtimeOnly = tenantProtocol({}); + const backed = tenantProtocol({ artifacts: [{ type: 'view', name: 'rc9_probe_view' }] }); + + const a = await runtimeOnly.protocol.deleteMetaItem({ type: 'object', name: 'rc9_widget', state: 'draft' }); + const b = await backed.protocol.deleteMetaItem({ type: 'view', name: 'rc9_probe_view', state: 'draft' }); + + expect(a.message).toBe('No pending draft for object/rc9_widget.'); + expect(b.message).toBe('No pending draft for view/rc9_probe_view.'); + }); + + it('the new sentences spell the canonical singular type, not the caller plural', async () => { + // #4432 — `canonicalizeMetaRequestType` folds the request type at the + // top of the method, so a plural caller gets the singular noun back. + const { protocol } = tenantProtocol({ rows: [overlayRow('object', 'rc9_widget')] }); + + const result = await protocol.deleteMetaItem({ type: 'objects', name: 'rc9_widget' }); + + expect(result.message).toBe( + `Deleted object 'rc9_widget' — it no longer exists. [seq=${result.seq}]`, + ); + }); + + it('the split changes the sentence and nothing else about the envelope', async () => { + // `reset` and `seq` are what machine consumers read (HMR cursors and + // the "did anything change" flag); `message` has no parser. Pinned so + // a wording change can never be mistaken for a contract change. + const { protocol } = tenantProtocol({ rows: [overlayRow('object', 'rc9_widget')] }); + + const result = await protocol.deleteMetaItem({ type: 'object', name: 'rc9_widget' }); + + expect(result).toMatchObject({ success: true, reset: true }); + expect(typeof result.seq).toBe('number'); + // Well under #5423's 500-character client-facing bound, both shapes. + expect(result.message.length).toBeLessThan(200); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts index 40c1b18dd2..c816834b14 100644 --- a/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts @@ -63,6 +63,13 @@ // nothing and the receipt no longer claims it does. Neither the direction nor // the 5/7 split changes — only the noun in the resolved value.) // +// (#5927 — and now the SECOND line, for the same reason on the reset path. +// These fail-closed cases pass no `registryItems`, so `v1` is artifact-backed +// on neither side; re-run today the delete resolves +// `"Deleted view 'v1' — it no longer exists. …"`. Same treatment: the +// measurement stands as taken, and the 5/7 split is untouched — what these +// assertions read is the REJECTION, never the resolved sentence.) +// // Predicted 4 (the four in the first describe); the fifth is the last case of // the artifact describe, which is itself a fail-closed assertion and only lives // there for narrative reasons. Recorded as measured rather than rounded to the diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d65ce54100..19a2e97eae 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10457,6 +10457,20 @@ export class ObjectStackProtocolImplementation implements } const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type; + // #5927 — the fact the four delete receipts below have to tell the + // truth about, hoisted to method scope because it is read by BOTH + // delete paths (repository and legacy raw-engine) and by `intent`. + // + // It is the SAME fact the repo path already computed inline for + // `intent: 'override-artifact' | 'runtime-only'` — this binding + // replaces that call rather than adding one, so the receipt split + // costs zero new registry reads. (The two-tier authorization block + // above computes it a second time under `request.type`; that one is + // block-scoped to `environmentId !== undefined` and cannot be reused + // here. Both spellings agree: `canonicalizeMetaRequestType` already + // folded `request.type` to singular at the top of this method, which + // makes `singularTypeForRepo` a no-op re-fold — see #4432.) + const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name); const overlayAllowedForRepoDel = ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo); const runtimeCreateAllowedForRepoDel = ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularTypeForRepo); const useRepoPath = overlayAllowedForRepoDel || runtimeCreateAllowedForRepoDel; @@ -10493,9 +10507,18 @@ export class ObjectStackProtocolImplementation implements return { success: true, reset: false, + // #5927 — "already at artifact default" presumes an + // artifact default EXISTS to be at. When nothing is + // shipped under this (type, name), the absent overlay + // row is the absence of the whole item, and the miss + // says that instead of naming a baseline that was + // never there. The draft leg claimed neither and is + // unchanged, verbatim. message: targetState === 'draft' ? `No pending draft for ${request.type}/${request.name}.` - : `No customization overlay found for ${request.type}/${request.name} — already at artifact default.`, + : artifactBacked + ? `No customization overlay found for ${request.type}/${request.name} — already at artifact default.` + : `No ${singularTypeForRepo} '${request.name}' found — nothing to delete.`, }; } @@ -10511,7 +10534,10 @@ export class ObjectStackProtocolImplementation implements // #4556 — NULL, not 'system', for an actor-less delete. actor: request.actor ?? null, source: 'protocol.deleteMetaItem', - intent: this.isArtifactBacked(singularTypeForRepo, request.name) + // #5927 — was an inline `this.isArtifactBacked(...)` call + // with these exact arguments; now reads the method-scoped + // binding the receipts share. Same fact, one call fewer. + intent: artifactBacked ? 'override-artifact' : 'runtime-only', state: targetState, @@ -10566,9 +10592,30 @@ export class ObjectStackProtocolImplementation implements reset: true, seq: result.seq, ...(deleteProjection ? { projectionApplied: deleteProjection } : {}), + // #5927 — the same split #5265/PR #5926 made on the save + // side, on the reset path. `artifactBacked` is exactly the + // difference between the two things a delete can be: + // + // • override-artifact — a code-shipped artifact sits + // under this (type, name). Removing the row really + // does lift a customization layer and really does + // leave the packaged default in force; the sentence is + // literally true and is unchanged, byte for byte. + // • runtime-only — nothing is underneath. The row WAS + // the item, and after this delete it does not exist in + // any layer. Telling an admin who just deleted an + // `object`/`flow`/`hook` they created that it was + // "reset to artifact default" points them at a + // baseline that has never existed. + // + // The draft leg discards a pending draft and never claimed + // a reset, so it is unchanged. `[seq=…]` stays on every + // branch — HMR cursors read it. message: (request.state === 'draft') ? `Draft discarded — ${request.type}/${request.name}. [seq=${result.seq}]` - : `Customization overlay deleted — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`, + : artifactBacked + ? `Customization overlay deleted — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]` + : `Deleted ${singularTypeForRepo} '${request.name}' — it no longer exists. [seq=${result.seq}]`, }; } catch (err: any) { if (err instanceof ConflictError) { @@ -10625,7 +10672,10 @@ export class ObjectStackProtocolImplementation implements return { success: true, reset: false, - message: `No customization overlay found for ${request.type}/${request.name} — already at artifact default.`, + // #5927 — same split as the repository path's miss above. + message: artifactBacked + ? `No customization overlay found for ${request.type}/${request.name} — already at artifact default.` + : `No ${singularTypeForRepo} '${request.name}' found — nothing to delete.`, }; } await this.engine.delete('sys_metadata', { where: { id: existing.id } }); @@ -10645,7 +10695,14 @@ export class ObjectStackProtocolImplementation implements return { success: true, reset: true, - message: `Customization overlay deleted — ${request.type}/${request.name} reset to artifact default.`, + // #5927 — same split as the repository path's success above. + // This branch carries no `[seq=…]`: it writes no history row + // and emits no watch event (see the block comment opening this + // path), so there is no cursor to report. That asymmetry is + // pre-existing and deliberate — the split does not touch it. + message: artifactBacked + ? `Customization overlay deleted — ${request.type}/${request.name} reset to artifact default.` + : `Deleted ${singularTypeForRepo} '${request.name}' — it no longer exists.`, }; } catch (err: any) { const e = new Error(`Failed to delete customization overlay: ${err.message}`);