From bed089a0d68d8bc614e08b2a8fe4fe5689574aae Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:56:41 +0800 Subject: [PATCH 1/3] fix(metadata-protocol): announce metadata:reloaded after a per-item publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/v1/meta/:type/:name/publish` promoted a draft to `active` and told nobody. The lifecycle event that makes boot-cached consumers re-read had two announcers — the metadata plugin's dev-artifact watcher, and the runtime dispatcher after `POST /packages/:id/publish-drafts` (#2576) — so publishing item by item, which is what AI authoring and the item-level Studio doors do, fired neither. Measured on a cloud rig: a record-change flow published as `state='active'` produced no bind log and never executed until the kernel was rebuilt and `kernel:ready` re-bound it (#2560). `publishMetaItem` now notifies through a new `onMetaItemPublished` seam, and `ObjectQLPlugin.subscribeMetadataRebind` — armed for both protocol assembly modes — turns that into `ctx.trigger('metadata:reloaded', { changed })`. The producer notifies and the host announces because the protocol holds no kernel hook bus, which is the same division `HttpDispatcher.announceKernelEvent` makes for the batch door. `changed` carries the batch door's `{type}/{name}` spelling, so a subscriber cannot tell the two doors apart. The notification is deliberately NOT the existing `onMetadataMutation`: that one is emitted from `runPublishSideEffects`, which the batch door runs once per promoted draft, so announcing on it would fan a full kernel re-sync (schema DDL, connector re-materialization, flow re-bind) out once per item of a "publish whole app". A CONTROL case pins that the batch door emits none. Awaited, so the publish's own 2xx means the re-bind was attempted rather than queued; best-effort, so a throwing subscriber is logged at `warn` and never turns a landed publish into an error. No wire key is added — the batch door's `rebindError` exists because its response is a batch receipt, and adding a zero-reader diagnostic key to `PublishMetaItemResponseSchema` buys no capability. Part of #10219 --- packages/metadata-protocol/src/index.ts | 4 + ...tocol.publish-item-rebind-announce.test.ts | 289 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 133 ++++++++ .../src/plugin-publish-announce.test.ts | 115 +++++++ packages/objectql/src/plugin.ts | 113 +++++-- 5 files changed, 628 insertions(+), 26 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts create mode 100644 packages/objectql/src/plugin-publish-announce.test.ts diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index cb1f59e9f6..0baccf563e 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -110,6 +110,10 @@ export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; // against the producer's contract instead of restating it locally. export type { DeletePackageRequest, DeletePackageResponse } from './protocol.js'; export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js'; +// [#10219] The per-item publish notification the host bridges to the +// kernel-wide `metadata:reloaded` announce. Exported for the same reason its +// mutation sibling is: the subscriber lives in another package. +export type { MetaItemPublishedEvent } from './protocol.js'; export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js'; export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; diff --git a/packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts b/packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts new file mode 100644 index 0000000000..75d13c5250 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10219 A — the PER-ITEM publish door (`publishMetaItem`, i.e. + * `POST /api/v1/meta/:type/:name/publish`) had no re-bind signal. + * + * Measured on a cloud rig against AI-authored metadata published item by item: + * two flows published as `state='active'` produced no bind log and never fired. + * Only a forced kernel rebuild (`kernel:ready` -> `syncFlowsFromProtocol`, + * #2560) picked them up. `service-automation` re-binds on the + * `metadata:reloaded` lifecycle event, which had exactly two announcers — the + * metadata plugin's dev-artifact watcher, and the runtime dispatcher after + * `publishPackageDrafts` (#2576). The per-item door announced nothing. + * + * The fix notifies from the PRODUCER + * ({@link ObjectStackProtocolImplementation.onMetaItemPublished}); the host + * plugin turns that into the kernel announce, because the protocol holds no + * hook bus. The bridge half is pinned in + * `objectql/src/plugin-publish-announce.test.ts`, and the consumer half in + * `service-automation/src/flow-publish-rebind.test.ts`. + * + * Harness: the faithful multi-table stub engine used by + * `protocol-publish-drafts-org-scope.test.ts` / `-advisories.test.ts` (kept + * local — self-contained harnesses are the established shape here, so two + * tripwires fail independently). Nothing on the publish path is stubbed: the + * REAL `publishMetaItem` / `publishPackageDrafts` run. + */ + +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import type { MetaItemPublishedEvent } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; + updated_at?: string; + created_at?: string; +} + +interface HistoryRow { + id: string; + event_seq: number; + name: string; + type: string; + version: number; + operation_type: string; + metadata: string | null; + checksum: string | null; + previous_checksum: string | null; + change_note?: string | null; + source?: string | null; + organization_id: string | null; + recorded_by?: string | null; + recorded_at: string; +} + +// Overlay rows are keyed by (type, name, org, state, package_id) — the ADR-0048 key. +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +function matchesMetadataWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesMetadataWhere(r, c))) return false; + continue; + } + // `undefined` = "dimension not constrained"; `null` = "must be NULL". + if (v === undefined) continue; + if ((r as any)[k] !== v) return false; + } + return true; +} + +function makeStubEngine() { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + let nextId = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + } + for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const matchesHistory = (h: HistoryRow, w: Record): boolean => { + if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; + if (w.type !== undefined && h.type !== w.type) return false; + if (w.name !== undefined && h.name !== w.name) return false; + if (w.version !== undefined && h.version !== w.version) return false; + if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false; + return true; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; + } + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.filter((h) => matchesHistory(h, opts.where)); + } + return Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; + if (table === 'sys_metadata_history') { + nextId += 1; + const h: HistoryRow = { id: `h_${nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + // No declared package namespace → the ADR-0028 prefix check is + // skipped (legacy-grandfathered path). + getPackage: () => undefined, + }, + }; + return { engine, rows, historyRows }; +} + +const PKG = 'app.ops'; + +/** + * A clean record-triggered flow — the shape of the automation the rig measured + * as inert. `runAs: 'system'` is load-bearing: without it `flow-runas-unscoped` + * fires at `severity: 'error'` and the publish becomes a refusal. + */ +const recordTriggeredFlow = (name: string) => ({ + name, + label: 'Ticket Closed', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { objectName: 'ticket', triggerType: 'record-after-update' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +describe('publishMetaItem notifies the host that ONE item went live (#10219 A)', () => { + it('emits the canonical type/name/scope after a per-item publish', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const seen: MetaItemPublishedEvent[] = []; + protocol.onMetaItemPublished((evt) => { seen.push(evt); }); + + await protocol.saveMetaItem({ + type: 'flow', name: 'ticket_closed', item: recordTriggeredFlow('ticket_closed'), + packageId: PKG, mode: 'draft', + }); + // Before the fix this published the row and told nobody, so the flow + // stayed `active` and completely unbound until the kernel was rebuilt. + const res = await protocol.publishMetaItem({ + type: 'flow', name: 'ticket_closed', actor: 'admin', + }); + + expect(res.success).toBe(true); + expect(seen).toEqual([{ type: 'flow', name: 'ticket_closed', organizationId: null }]); + }); + + it('AWAITS the listener before answering, so a publish-then-write caller cannot race the bind', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const order: string[] = []; + protocol.onMetaItemPublished(async () => { + await new Promise((r) => setTimeout(r, 5)); + order.push('rebound'); + }); + + await protocol.saveMetaItem({ + type: 'flow', name: 'ticket_closed', item: recordTriggeredFlow('ticket_closed'), + packageId: PKG, mode: 'draft', + }); + await protocol.publishMetaItem({ type: 'flow', name: 'ticket_closed', actor: 'admin' }); + order.push('publish-returned'); + + expect(order).toEqual(['rebound', 'publish-returned']); + }); + + it('a THROWING listener never fails the publish — the row is already durable', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + protocol.onMetaItemPublished(() => { throw new Error('subscriber exploded'); }); + + await protocol.saveMetaItem({ + type: 'flow', name: 'ticket_closed', item: recordTriggeredFlow('ticket_closed'), + packageId: PKG, mode: 'draft', + }); + const res = await protocol.publishMetaItem({ + type: 'flow', name: 'ticket_closed', actor: 'admin', + }); + + expect(res.success).toBe(true); + const live = Array.from(rows.values()).filter((r) => r.state === 'active'); + expect(live.map((r) => r.name)).toEqual(['ticket_closed']); + }); + + it('unsubscribing stops the notification', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const seen: MetaItemPublishedEvent[] = []; + const off = protocol.onMetaItemPublished((evt) => { seen.push(evt); }); + off(); + + await protocol.saveMetaItem({ + type: 'flow', name: 'ticket_closed', item: recordTriggeredFlow('ticket_closed'), + packageId: PKG, mode: 'draft', + }); + await protocol.publishMetaItem({ type: 'flow', name: 'ticket_closed', actor: 'admin' }); + + expect(seen).toEqual([]); + }); + + it('CONTROL — the BATCH door does NOT emit it per item (its announce is one per publish, at the route)', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const seen: MetaItemPublishedEvent[] = []; + protocol.onMetaItemPublished((evt) => { seen.push(evt); }); + + await protocol.saveMetaItem({ + type: 'flow', name: 'ticket_closed', item: recordTriggeredFlow('ticket_closed'), + packageId: PKG, mode: 'draft', + }); + await protocol.saveMetaItem({ + type: 'flow', name: 'ticket_reopened', item: recordTriggeredFlow('ticket_reopened'), + packageId: PKG, mode: 'draft', + }); + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 }); + // Emitting here would fan a FULL kernel re-sync (schema DDL, connector + // re-materialization, flow re-bind) out once per promoted draft, on top + // of the one announce `POST /packages/:id/publish-drafts` already makes. + expect(seen).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 17eaa9f748..0d3165a5ea 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3299,6 +3299,34 @@ export interface MetadataMutationEvent { organizationId?: string | null; } +/** + * [#10219] A single item reached `active` through the per-item publish door + * (`publishMetaItem`, i.e. `POST /api/v1/meta/:type/:name/publish`). `type` is + * the CANONICAL singular metadata type name — the same spelling the promoted + * row carries — so a subscriber can build the `'{type}/{name}'` entry the + * `metadata:reloaded` payload's `changed` list is made of. + * + * Subscribe via {@link ObjectStackProtocolImplementation.onMetaItemPublished}. + * + * ## Why this is NOT the existing {@link MetadataMutationEvent} + * + * The per-item publish emits BOTH. The mutation event says "a row changed" and + * is emitted from `runPublishSideEffects`, the phase-2 helper the BATCH door + * (`publishPackageDrafts`) runs once per promoted draft as well — so a + * subscriber that announced a full metadata reload on it would fire N times for + * one "publish whole app", each announce driving a complete re-sync (schema DDL, + * connector re-materialization, flow re-bind) for every other item in the same + * batch. This event is emitted ONLY by the single-item door, which is the one + * the batch route's own announce does not cover. + */ +export interface MetaItemPublishedEvent { + /** Canonical singular metadata type of the promoted item. */ + type: string; + name: string; + /** Scope the promotion landed in — `null` is env-wide. */ + organizationId: string | null; +} + /** * Awaited per-type mutation projector (ADR-0094). Invoked AFTER a metadata * mutation persists — `saveMetaItem` (draft AND active saves), @@ -4182,6 +4210,98 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#10219] Per-item publish listeners — the producer-side half of the + * publish→re-bind signal. + * + * ## The gap this closes + * + * A metadata publish at RUNTIME leaves every boot-cached consumer holding + * the pre-publish view; the platform's declared signal for "re-read what + * you cached" is the `metadata:reloaded` lifecycle event. Two announcers + * existed — the metadata plugin's dev-artifact watcher, and the runtime + * dispatcher AFTER `POST /packages/:id/publish-drafts`. The per-item door + * (`POST /api/v1/meta/:type/:name/publish`) announced nothing, so a flow + * published one item at a time never bound its trigger until the kernel was + * rebuilt: measured on a cloud rig, a record-change flow published as + * `state='active'` produced no bind log and no execution, and only the + * `kernel:ready` cold-boot bind (#2560) ever picked it up. + * + * ## Why the seam is here and not at the HTTP route + * + * `publishMetaItem` is the ONE producer behind every per-item publish + * transport (REST today; anything else that reaches the protocol next), and + * the batch door's announce already lives outside the protocol. Announcing + * from the producer means a new transport inherits the signal instead of + * having to remember it — the same argument {@link onMetadataMutation} + * makes one method over. + * + * The protocol itself holds no kernel hook bus, so it does not announce: + * it NOTIFIES, and the plugin that owns this protocol instance + * (`ObjectQLPlugin.subscribeMetadataRebind`, which is armed for both + * assembly modes) translates the notification into `ctx.trigger( + * 'metadata:reloaded', { changed })`. That keeps the lifecycle event + * emitted by something that actually has the kernel, exactly as the + * dispatcher does for the batch door. + * + * Server-side extension only — NOT part of the ObjectStackProtocol wire + * contract (same status as {@link onMetadataMutation}). + */ + private metaItemPublishedListeners: Array<(evt: MetaItemPublishedEvent) => void | Promise> = []; + + /** + * Subscribe to per-item publishes. Returns an unsubscribe fn. + * + * Listeners are AWAITED (unlike {@link onMetadataMutation}'s fire-and-forget + * fan-out) so the publish's own 2xx means "the re-bind was attempted", + * matching the batch door — which awaits its `metadata:reloaded` announce + * before answering. A caller that publishes a flow and immediately writes a + * record must not race the bind. + */ + onMetaItemPublished(listener: (evt: MetaItemPublishedEvent) => void | Promise): () => void { + this.metaItemPublishedListeners.push(listener); + return () => { + const i = this.metaItemPublishedListeners.indexOf(listener); + if (i >= 0) this.metaItemPublishedListeners.splice(i, 1); + }; + } + + /** + * Notify per-item publish listeners, awaited and isolated. + * + * Best-effort by contract: the draft is already promoted and durable when + * this runs, so a subscriber failure must never turn a landed publish into + * an error response. It IS logged — losing an in-memory re-sync is the + * functional degradation AGENTS.md uses as its worked example ("a trigger + * is not armed"), and the batch door's equivalent catch logs at `warn` for + * the same reason. The sentence names the consequence and the recovery + * rather than the internal failure alone. + * + * ⛔ The caught text is NOT copied onto the publish response. The batch door + * carries a `rebindError` key because its response is a batch receipt; this + * door's response is declared by `PublishMetaItemResponseSchema` and adding + * a zero-reader diagnostic key to a wire contract buys no capability + * (the #6955 ruling, one payload over) — the operator-facing channel is + * this log. + */ + private async emitMetaItemPublished(evt: MetaItemPublishedEvent): Promise { + for (const listener of this.metaItemPublishedListeners) { + try { + await listener(evt); + } catch (e) { + console.warn( + `[Protocol] the post-publish re-bind announce FAILED for ${evt.type}/${evt.name} — the item IS ` + + `published and stored, but boot-cached consumers keep the PRE-publish view until this process ` + + `restarts: a newly published record-triggered flow does not bind its trigger (it will not fire), ` + + `an edited schedule-triggered flow keeps running its old definition, and authored hooks, actions ` + + `and translations are not re-synced. Nothing retries this announce. Re-publish the item once the ` + + `cause below is resolved (it is idempotent), or restart the process to rebuild every subscriber ` + + `from storage. Cause: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + /** * Lazily obtain a SysMetadataRepository for the given organization. * Env-wide overlays (organizationId == null) share a singleton under @@ -14131,6 +14251,19 @@ export class ObjectStackProtocolImplementation implements if (effects.seedApplied) response.seedApplied = effects.seedApplied; if (effects.materializeApplied) response.materializeApplied = effects.materializeApplied; if (effects.projectionApplied) response.projectionApplied = effects.projectionApplied; + // [#10219] LAST, and awaited: tell the host that ONE item went live, so + // it can announce `metadata:reloaded` and boot-cached consumers re-sync + // without a restart — the parity the batch door has had since #2576. + // After the side effects because a subscriber re-reads the protocol + // (`resyncFlowsFromProtocol` pulls `getMetaItems({type:'flow'})`), and + // it must see the finished state: the active row, its table, and any + // materialized rows. Before the return so the caller's 2xx means the + // re-bind was attempted, not merely queued. + await this.emitMetaItemPublished({ + type: singularType, + name: request.name, + organizationId: orgId, + }); return response; } diff --git a/packages/objectql/src/plugin-publish-announce.test.ts b/packages/objectql/src/plugin-publish-announce.test.ts new file mode 100644 index 0000000000..367ba0b9ef --- /dev/null +++ b/packages/objectql/src/plugin-publish-announce.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10219 — the per-item publish door announces `metadata:reloaded`. + * + * `POST /api/v1/meta/:type/:name/publish` had no re-bind signal at all. The + * event that makes boot-cached consumers re-read had two announcers — the + * metadata plugin's dev-artifact watcher, and the runtime dispatcher after + * `POST /packages/:id/publish-drafts` (#2576) — so publishing item by item, the + * shape AI authoring and the item-level Studio doors take, told nobody. + * Measured on a cloud rig: a record-change flow published as `state='active'` + * produced no bind log and never fired until the kernel was rebuilt. + * + * The split of responsibility under test: the protocol NOTIFIES (it holds no + * kernel hook bus) and this plugin, which holds `ctx`, TRIGGERS — the same + * division `HttpDispatcher.announceKernelEvent` makes for the batch door. The + * consumer half is already pinned one package over + * (`service-automation/src/flow-publish-rebind.test.ts`: a `metadata:reloaded` + * carrying `changed: ['flow/']` binds the flow with no restart), so these + * cases close the remaining link in that chain. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; + +type AnyRecord = Record; + +/** Boot the plugin's in-house protocol assembly and hand back both halves. */ +async function armedPlugin() { + const ql: AnyRecord = { + registerAction: vi.fn(), + removeActionsByPackage: vi.fn(), + registerApp: vi.fn(), + setDatasourceMapping: vi.fn(), + find: vi.fn(async () => []), + registry: { getArtifactItem: vi.fn(() => undefined) }, + getDefaultActionRunner: () => undefined, + }; + const plugin = new ObjectQLPlugin({ ql: ql as unknown as ObjectQL, environmentId: 'env_t' }); + const registered = new Map(); + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const ctx = { + logger, + trigger: vi.fn(async () => {}), + registerService: vi.fn((name: string, svc: any) => registered.set(name, svc)), + getService: vi.fn(() => { throw new Error('none'); }), + } as AnyRecord; + + await (plugin as any).init(ctx); + const protocol = registered.get('protocol'); + expect(protocol, 'the in-house assembly registered a protocol').toBeDefined(); + return { plugin, ctx, logger, protocol }; +} + +describe('ObjectQLPlugin bridges a per-item publish to `metadata:reloaded` (#10219)', () => { + it('triggers the lifecycle event with the batch door\'s `{type}/{name}` spelling', async () => { + const { ctx, protocol } = await armedPlugin(); + + await (protocol as any).emitMetaItemPublished({ + type: 'flow', name: 'ticket_closed', organizationId: null, + }); + + expect(ctx.trigger).toHaveBeenCalledWith( + 'metadata:reloaded', + { changed: ['flow/ticket_closed'] }, + ); + }); + + it('is AWAITED by the publish, so the announce completes before the caller is answered', async () => { + const { ctx, protocol } = await armedPlugin(); + const order: string[] = []; + ctx.trigger.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 5)); + order.push('subscribers-resynced'); + }); + + await (protocol as any).emitMetaItemPublished({ + type: 'flow', name: 'ticket_closed', organizationId: null, + }); + order.push('publish-returned'); + + expect(order).toEqual(['subscribers-resynced', 'publish-returned']); + }); + + it('a throwing subscriber is reported, never rethrown — the item is already published', async () => { + const { ctx, logger, protocol } = await armedPlugin(); + ctx.trigger.mockRejectedValue(new Error('resync exploded')); + + await expect( + (protocol as any).emitMetaItemPublished({ + type: 'flow', name: 'ticket_closed', organizationId: null, + }), + ).resolves.toBeUndefined(); + + // Degradation log level: `warn`, matching the sibling announcers. Nothing + // claimed to persist and did not — what is lost is an in-memory re-sync. + expect(logger.warn).toHaveBeenCalledTimes(1); + const [message, detail] = logger.warn.mock.calls[0]!; + expect(message).toContain('metadata:reloaded'); + expect(detail).toMatchObject({ item: 'flow/ticket_closed', error: 'resync exploded' }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('a metadata MUTATION does not announce — only the per-item publish door does', async () => { + const { ctx, protocol } = await armedPlugin(); + + // The #2588 seam fires on every save/delete too. Announcing a full reload + // there would fan a kernel-wide re-sync out of every draft keystroke. + (protocol as any).emitMetadataMutation({ type: 'flow', name: 'ticket_closed', state: 'active' }); + await new Promise((r) => setTimeout(r, 0)); + + expect(ctx.trigger).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 741ff477c7..d82bf85c0a 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -268,36 +268,97 @@ export class ObjectQLPlugin implements Plugin { } /** - * Arm the authored hook/action rebind on protocol metadata mutations - * (#2588, #2605). Shared by both assembly modes: called with the in-house - * shim when `registerProtocol` is on, and lazily from `start()` against - * whatever registered `protocol` (MetadataProtocolPlugin) otherwise. + * Arm the protocol-driven rebinds. Shared by both assembly modes: called with + * the in-house shim when `registerProtocol` is on, and lazily from `start()` + * against whatever registered `protocol` (MetadataProtocolPlugin) otherwise. + * + * Two independent subscriptions, each guarded on its own — a protocol + * implementation that offers one seam and not the other must still get the + * one it has: + * + * 1. `onMetadataMutation` (#2588, #2605) — this plugin's OWN authored + * hook/action rebind. + * 2. `onMetaItemPublished` (#10219) — the kernel-wide `metadata:reloaded` + * announce for the per-item publish door. See + * {@link announcePerItemPublish}. */ private subscribeMetadataRebind(ctx: PluginContext, protocol: unknown): void { - if (typeof (protocol as any)?.onMetadataMutation !== 'function') return; - const unsubscribe = (protocol as any).onMetadataMutation( - (evt: { type: string; name: string; state: string }) => { - if (evt?.state === 'draft') return; - if (evt?.type === 'hook') { - void this.resyncAuthoredHooks(ctx).catch((e: any) => { - ctx.logger.warn('[ObjectQLPlugin] authored-hook rebind after mutation failed', { - hook: evt.name, - error: e?.message, + if (typeof (protocol as any)?.onMetadataMutation === 'function') { + const unsubscribe = (protocol as any).onMetadataMutation( + (evt: { type: string; name: string; state: string }) => { + if (evt?.state === 'draft') return; + if (evt?.type === 'hook') { + void this.resyncAuthoredHooks(ctx).catch((e: any) => { + ctx.logger.warn('[ObjectQLPlugin] authored-hook rebind after mutation failed', { + hook: evt.name, + error: e?.message, + }); }); - }); - } else if (evt?.type === 'action' || evt?.type === 'object') { - // `object` rows carry embedded `actions[]`, so an object edit can - // add/remove an authored action too — re-sync on both. - void this.resyncAuthoredActions(ctx).catch((e: any) => { - ctx.logger.warn('[ObjectQLPlugin] authored-action rebind after mutation failed', { - item: evt.name, - error: e?.message, + } else if (evt?.type === 'action' || evt?.type === 'object') { + // `object` rows carry embedded `actions[]`, so an object edit can + // add/remove an authored action too — re-sync on both. + void this.resyncAuthoredActions(ctx).catch((e: any) => { + ctx.logger.warn('[ObjectQLPlugin] authored-action rebind after mutation failed', { + item: evt.name, + error: e?.message, + }); }); - }); - } - }, - ); - this.metadataUnsubscribes.push(unsubscribe); + } + }, + ); + this.metadataUnsubscribes.push(unsubscribe); + } + if (typeof (protocol as any)?.onMetaItemPublished === 'function') { + const unsubscribe = (protocol as any).onMetaItemPublished( + (evt: { type: string; name: string }) => this.announcePerItemPublish(ctx, evt), + ); + this.metadataUnsubscribes.push(unsubscribe); + } + } + + /** + * [#10219] Announce `metadata:reloaded` after a PER-ITEM publish + * (`POST /api/v1/meta/:type/:name/publish`), so every boot-cached consumer + * re-syncs without a restart. + * + * This is the missing third announcer of an event that had two: the metadata + * plugin's dev-artifact watcher, and the runtime dispatcher after + * `POST /packages/:id/publish-drafts` (#2576). Publishing one item at a time + * — what an AI author and the item-level Studio doors do — fired neither, so + * the load-bearing subscriber never ran: `service-automation`'s + * `resyncFlowsFromProtocol` binds record- and schedule-triggered flows off + * this event, and a flow published per item stayed `state='active'` and + * completely inert until the kernel was rebuilt. Same silence for authored + * hooks/actions of other packages, declared connectors and translations. + * + * The plugin is where the announce belongs because the protocol holds no + * kernel: it notifies, and this — holding `ctx` — triggers. That is the same + * division the dispatcher's `announceKernelEvent` makes for the batch door. + * + * `changed` uses the `'{type}/{name}'` spelling the batch announce emits, so + * a subscriber reading the payload cannot tell the two doors apart. + * + * Best-effort and never rethrown: the item is already published and durable, + * so a subscriber failure must not turn a landed publish into an error. It is + * reported at `warn` — the loss is an in-memory re-sync, AGENTS.md's own + * worked example of a FUNCTIONAL degradation, and the sibling announcers log + * it at exactly this level. + */ + private async announcePerItemPublish( + ctx: PluginContext, + evt: { type: string; name: string }, + ): Promise { + try { + await ctx.trigger('metadata:reloaded', { changed: [`${evt.type}/${evt.name}`] }); + } catch (e: any) { + ctx.logger.warn( + '[ObjectQLPlugin] the post-publish `metadata:reloaded` announce failed — the item IS published, but ' + + 'boot-cached consumers keep the pre-publish view until this process restarts (a newly published ' + + 'record-triggered flow does not bind its trigger and will not fire). Nothing retries this announce; ' + + 're-publishing the item is idempotent.', + { item: `${evt.type}/${evt.name}`, error: e?.message ?? String(e) }, + ); + } } init = async (ctx: PluginContext) => { From d8564569216144ae3f138aa631517818a189c934 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:57:19 +0800 Subject: [PATCH 2/3] fix(metadata-protocol): resolve the draft's own org scope on a per-item publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-item publish door could not SEE a draft the batch door published fine. Measured on a cloud rig: four AI-authored `view` drafts sitting at `state='draft'` in `sys_metadata` — listed by the console's pending-changes banner, promoted by its one-click "publish 4 changes" button — were each refused per item with `404 [no_draft] No pending draft exists for view/ — nothing to publish.` `view` is one of the types the registry declares `allowOrgOverride: true`, so `organizationIdForMetaWrite` threads the session's active organization into the publish. The drafts were authored env-wide (`organization_id = NULL`), which is what package / AI authoring writes, and a strict `organization_id = ` lookup can never match them. `object` and `flow` are not org-overridable, which is exactly why per-item publish worked for them and failed for views — one symptom, split by a registry flag. This is the single-item twin of #3115. The batch door fixed it by DISCOVERING each draft's scope (`listDrafts` surfaces a non-null-org caller's own rows and the env-wide ones through its `$or`, and the promote targets `d.organizationId`); the per-item door DEDUCED one instead. It now discovers, with the ADR-0005 precedence — an org holding its own draft publishes that one, and only an org with no draft of its own falls through to the env-wide row it was already authoring into. Resolved before every gate below it, so the ADR-0010 lock check, the #6190 org-scoped-write refusal and the promote all judge the one scope the row is actually in. When neither scope holds a draft the caller's own scope is returned unchanged, so a genuinely absent draft still raises the same `NO_DRAFT` refusal from the scope it asked about. No `catch`: a driver failure must fail the publish rather than resolve to a scope nobody verified. Fixes #10219 --- ...per-item-publish-rebind-and-draft-scope.md | 31 ++ ...tocol.publish-item-draft-org-scope.test.ts | 270 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 82 ++++++ 3 files changed, 383 insertions(+) create mode 100644 .changeset/per-item-publish-rebind-and-draft-scope.md create mode 100644 packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts diff --git a/.changeset/per-item-publish-rebind-and-draft-scope.md b/.changeset/per-item-publish-rebind-and-draft-scope.md new file mode 100644 index 0000000000..e9d886a4e9 --- /dev/null +++ b/.changeset/per-item-publish-rebind-and-draft-scope.md @@ -0,0 +1,31 @@ +--- +"@objectstack/metadata-protocol": patch +"@objectstack/objectql": patch +--- + +Per-item publish (`POST /api/v1/meta/:type/:name/publish`) now re-binds runtime consumers +and finds drafts authored env-wide — the two things the package-scoped publish door +already did. + +**A metadata publish now announces `metadata:reloaded` on BOTH doors.** The event that +tells boot-cached consumers to re-read had two announcers: the dev-artifact watcher and +the runtime dispatcher after `POST /packages/:id/publish-drafts`. Publishing item by item +— what AI authoring and the item-level Studio doors do — announced nothing, so a flow +published while the server ran stayed `state='active'` and completely inert (no trigger +bound, no execution) until the kernel was rebuilt. `publishMetaItem` now notifies its host +through a new `onMetaItemPublished` seam and `ObjectQLPlugin` turns that into the kernel +announce, so `service-automation`'s flow re-bind, the authored hook/action re-sync, +declarative connectors and authored translations all catch up without a restart. The +announce is awaited, so the publish's own 2xx means the re-bind was attempted; a +subscriber failure is logged and never fails the publish. The batch door is unchanged — +it keeps its single per-publish announce rather than gaining one per promoted draft. + +**A per-item publish now resolves the draft's own org scope.** For the types the registry +declares `allowOrgOverride: true` (`view`, `dashboard`, `report`, `translation`, +`email_template`) the REST seam threads the session's active organization into the +publish, while package/AI authoring writes the draft env-wide — so the strict org lookup +matched nothing and answered `404 [no_draft] … nothing to publish` over a draft the +console's pending-changes banner was listing and the batch button published fine. The +per-item door now discovers the draft's scope the way `publishPackageDrafts` has since +#3115, with the ADR-0005 precedence (an org holding its own draft publishes that one) and +the same `NO_DRAFT` refusal when no scope holds a draft. diff --git a/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts b/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts new file mode 100644 index 0000000000..3a2c4d1025 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10219 B — the PER-ITEM publish door could not SEE a draft the batch door + * published fine. + * + * Measured on a cloud rig: four `view` drafts sitting at `state='draft'` in + * `sys_metadata`, listed by the console's pending-changes banner and promoted + * by its one-click "publish 4 changes" button, were each refused per item with + * `404 [no_draft] No pending draft exists for view/... — nothing to publish.` + * + * `view` is `allowOrgOverride: true`, so the REST seam threads the session's + * active organization into the publish (`organizationIdForMetaWrite`); the + * drafts were authored env-wide (`organization_id = NULL`), which is what + * package/AI authoring writes, and a strict `organization_id = ` lookup can + * never match them. `object` and `flow` are NOT org-overridable, which is why + * per-item publish worked for them and failed for views. + * + * That is the single-item twin of #3115, which the batch door fixed by promoting + * each draft in the scope `listDrafts` surfaced it FROM. The per-item door now + * DISCOVERS the draft's scope the same way, with the ADR-0005 precedence. + * + * Harness: the faithful multi-table stub engine used by + * `protocol-publish-drafts-org-scope.test.ts` / `-advisories.test.ts` (kept + * local — self-contained harnesses are the established shape here, so two + * tripwires fail independently). Nothing on the publish path is stubbed: the + * REAL `publishMetaItem` / `publishPackageDrafts` run. + */ + +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import type { MetaItemPublishedEvent } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; + updated_at?: string; + created_at?: string; +} + +interface HistoryRow { + id: string; + event_seq: number; + name: string; + type: string; + version: number; + operation_type: string; + metadata: string | null; + checksum: string | null; + previous_checksum: string | null; + change_note?: string | null; + source?: string | null; + organization_id: string | null; + recorded_by?: string | null; + recorded_at: string; +} + +// Overlay rows are keyed by (type, name, org, state, package_id) — the ADR-0048 key. +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +function matchesMetadataWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesMetadataWhere(r, c))) return false; + continue; + } + // `undefined` = "dimension not constrained"; `null` = "must be NULL". + if (v === undefined) continue; + if ((r as any)[k] !== v) return false; + } + return true; +} + +function makeStubEngine() { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + let nextId = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + } + for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const matchesHistory = (h: HistoryRow, w: Record): boolean => { + if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; + if (w.type !== undefined && h.type !== w.type) return false; + if (w.name !== undefined && h.name !== w.name) return false; + if (w.version !== undefined && h.version !== w.version) return false; + if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false; + return true; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; + } + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.filter((h) => matchesHistory(h, opts.where)); + } + return Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; + if (table === 'sys_metadata_history') { + nextId += 1; + const h: HistoryRow = { id: `h_${nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + // No declared package namespace → the ADR-0028 prefix check is + // skipped (legacy-grandfathered path). + getPackage: () => undefined, + }, + }; + return { engine, rows, historyRows }; +} + +const PKG = 'app.ops'; + +/** `view` is `allowOrgOverride: true` — the org-overridable specimen of case B. */ +const viewBody = (name: string) => ({ + name, + label: 'Customers', + object: 'customer', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair + columns: [{ field: 'name', label: 'Name' }], +}); + +describe('publishMetaItem resolves the draft\'s OWN org scope (#10219 B, the single-item #3115)', () => { + it('publishes an env-wide `view` draft although the session carries an active org', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + // Authored env-wide — what package/AI authoring writes, and what + // `PUT ?mode=draft` writes with no active org threaded. + await protocol.saveMetaItem({ + type: 'view', name: 'customer_list', item: viewBody('customer_list'), + packageId: PKG, mode: 'draft', + }); + + // `POST /meta/view/customer_list/publish` from a session with an active + // org: `view` is org-overridable, so the REST seam threads it. Before the + // fix this answered 404 `[no_draft]`. + const res = await protocol.publishMetaItem({ + type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + }); + + expect(res.success).toBe(true); + const remaining = Array.from(rows.values()); + expect(remaining.filter((r) => r.state === 'draft')).toHaveLength(0); + const active = remaining.filter((r) => r.state === 'active'); + expect(active).toHaveLength(1); + // Promoted in the scope it was authored in — NOT copied into the org. + expect(active[0]!.organization_id).toBeNull(); + }); + + it('reports the promotion under the scope it landed in, so the notification is not a lie', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const seen: MetaItemPublishedEvent[] = []; + protocol.onMetaItemPublished((evt) => { seen.push(evt); }); + + await protocol.saveMetaItem({ + type: 'view', name: 'customer_list', item: viewBody('customer_list'), + packageId: PKG, mode: 'draft', + }); + await protocol.publishMetaItem({ + type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + }); + + expect(seen).toEqual([ + { type: 'view', name: 'customer_list', organizationId: null }, + ]); + }); + + it('PRECEDENCE — an org that has its own draft publishes THAT one, not the env-wide row', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'view', name: 'customer_list', item: { ...viewBody('customer_list'), label: 'Env wide' }, + packageId: PKG, mode: 'draft', + }); + await protocol.saveMetaItem({ + type: 'view', name: 'customer_list', item: { ...viewBody('customer_list'), label: 'Org alpha' }, + organizationId: 'org_alpha', packageId: PKG, mode: 'draft', + }); + + await protocol.publishMetaItem({ + type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + }); + + // The ADR-0005 overlay order: the caller's own org shadows env-wide. + const active = Array.from(rows.values()).filter((r) => r.state === 'active'); + expect(active).toHaveLength(1); + expect(active[0]!.organization_id).toBe('org_alpha'); + expect(JSON.parse(active[0]!.metadata).label).toBe('Org alpha'); + // The env-wide draft is untouched — it was never this publish's subject. + const drafts = Array.from(rows.values()).filter((r) => r.state === 'draft'); + expect(drafts).toHaveLength(1); + expect(drafts[0]!.organization_id).toBeNull(); + }); + + it('CONTROL — a genuinely absent draft still refuses with NO_DRAFT / 404', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + await protocol.saveMetaItem({ + type: 'view', name: 'customer_list', item: viewBody('customer_list'), + packageId: PKG, + }); + + await expect( + protocol.publishMetaItem({ + type: 'view', name: 'customer_list', organizationId: 'org_alpha', actor: 'admin', + }), + ).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0d3165a5ea..4ad5b87ac8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4376,6 +4376,68 @@ export class ObjectStackProtocolImplementation implements return inEnv ? null : requestOrgId; } + /** + * [#10219] ADR-0005 / #3115 — resolve the org scope the PENDING DRAFT of an + * item actually lives in, for a per-item publish whose caller may not be in + * that scope. + * + * This is the single-item twin of the rule `publishPackageDrafts` already + * follows. The batch door DISCOVERS each draft's scope (`listDrafts` + * surfaces a non-null-org caller's own rows AND the env-wide ones via its + * `$or`, and the promote targets `d.organizationId`); the per-item door + * DEDUCED one instead, from `organizationIdForMetaWrite(type, activeOrg)` at + * the REST seam. The two answers differ for exactly the types the registry + * declares `allowOrgOverride: true` (`view`, `dashboard`, `report`, + * `translation`, `email_template`): a draft authored env-wide — which is + * what package/AI authoring writes, and what `PUT ?mode=draft` writes when no + * active org is threaded — is looked up under `organization_id = `, + * matches nothing, and answers `404 [no_draft] … nothing to publish` over a + * row the console's own pending-changes list is showing. Measured on a cloud + * rig: four AI-authored `view` drafts, visible in `sys_metadata` at + * `state='draft'`, all four refused by the per-item door while the batch + * "publish 4 changes" button promoted them. + * + * Non-org-overridable types (`object`, `flow`, …) never reached this at all: + * `organizationIdForMetaWrite` already answers `undefined` for them, which is + * why per-item publish worked for objects and flows and failed for views. + * + * Precedence is the ADR-0005 overlay order — the caller's own org shadows + * env-wide — so an org holding its own draft publishes THAT draft, and only + * an org with no draft of its own falls through to the env-wide row it was + * already authoring into. When NEITHER scope holds a draft the caller's own + * scope is returned unchanged, so a genuinely absent draft still raises the + * same `NO_DRAFT` refusal, from the scope the caller asked about. + * + * ⛔ This is discovery, not a tolerant fallback: it names the one row the + * promote will then address, and it reads DRAFT rows in `sys_metadata` (the + * thing being promoted) rather than the history lineage + * {@link resolveMetaItemOrgScope} reads — a first-ever draft of a + * never-published item has no lineage to resolve. + * + * Deliberately NO `catch`, for the same reason as its read-side sibling: a + * driver failure must fail the publish, not resolve to a scope nobody + * verified. + */ + private async resolveDraftOrgScopeForPublish( + singularType: string, + name: string, + requestOrgId: string | null, + ): Promise { + if (requestOrgId === null) return null; + // The package dimension is deliberately absent from both probes: the + // per-item door names no package, so `promoteDraft` resolves the draft + // with "match any package" and these reads must ask the same question + // it will (see `SysMetadataRepository.whereFor`). + const inOrg = await this.engine.findOne('sys_metadata', { + where: { organization_id: requestOrgId, type: singularType, name, state: 'draft' }, + }); + if (inOrg) return requestOrgId; + const inEnv = await this.engine.findOne('sys_metadata', { + where: { organization_id: null, type: singularType, name, state: 'draft' }, + }); + return inEnv ? null : requestOrgId; + } + /** * One-time guard for ensuring the overlay-uniqueness UNIQUE INDEXes exist * on `sys_metadata`. ADR-0005 (revised 2026-05) + ADR-0048: per-env DBs @@ -14179,6 +14241,26 @@ export class ObjectStackProtocolImplementation implements // rewrites it on upgrade). Different input class, different map; see // {@link canonicalMetaType}'s header for why the two are not one fold. request = canonicalizeMetaRequestType(request); + // [#10219] Then resolve WHICH SCOPE's draft this publish means. The + // caller states the scope it is IN; the draft may live env-wide. See + // {@link resolveDraftOrgScopeForPublish} — the single-item twin of the + // #3115 rule `publishPackageDrafts` already follows. + // + // Placed after the type fold (the probe must name the canonical stored + // `type`) and before every gate below, so the ADR-0010 lock check, the + // #6190 org-scoped-write refusal and the promote all judge ONE scope — + // the one the row is actually in. Resolving it later would gate against + // a partition the promotion never touches. + { + const singular = PLURAL_TO_SINGULAR[request.type] ?? request.type; + const resolvedOrgId = await this.resolveDraftOrgScopeForPublish( + singular, request.name, request.organizationId ?? null, + ); + if (resolvedOrgId !== (request.organizationId ?? null)) { + const { organizationId: _requested, ...rest } = request; + request = resolvedOrgId === null ? rest : { ...rest, organizationId: resolvedOrgId }; + } + } // [#8594] The refusal's own row is written HERE, by the route that owns // the (absent) transaction — see `promoteDraftForPublish`'s header. This // site has no transaction of its own, so recording it in the `catch` is From 713a784526390764cffda72d4f4653d106316caa Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:04:57 +0800 Subject: [PATCH 3/3] chore: record the two new fake engines in the engine-double ledger `check:engine-double-contract` retains a per-file pin for every test double that routes its `delete()` / `update()` through `assertEngineDeleteDispatch` / `assertEngineUpdateDispatch`. The two stub engines added with this card's regression tests are pinned coverage the ledger did not know about, so it was not protecting those files. Regenerated with `--write`: 4 rows added or grown, 0 lost. Part of #10219 --- scripts/engine-double-contract.pinned.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 30f010c403..d20b168049 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -446,6 +446,26 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.publish-item-draft-org-scope.test.ts", + "verb": "update", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.publish-side-effects-canonical-type.test.ts", "verb": "delete",