From 292239ca61d9cfca2c29e3c1d5308ef37a5ffe96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 23:11:33 +0000 Subject: [PATCH] feat(metadata-protocol): batch publish door returns per-draft advisories on each published[] element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishPackageDrafts (Studio's "publish whole app") destructured only { singularType, result } from promoteDraftForPublish, which since #9176 RETURNS the #4463 gate's advisory findings — so they were computed and discarded, per draft, for every draft in the batch. Per the maintainer's ruling on #9343: advisories ride EACH published[] element, same optional omitted-when-empty shape as PublishMetaItemResponseSchema.advisories on the single-item door; no parallel top-level map; failed[] elements unaffected (an error finding still aborts the batch, ADR-0067 D2). The objectql doubles of the promoteDraftForPublish seam gain the advisories key the real helper has returned since #9176. Part of #9343 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/batch-publish-advisories.md | 5 + ...protocol-publish-drafts-advisories.test.ts | 331 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 57 ++- packages/objectql/src/build-probes.test.ts | 3 + .../src/protocol-commit-history.test.ts | 3 + .../protocol-publish-package-drafts.test.ts | 6 + 6 files changed, 395 insertions(+), 10 deletions(-) create mode 100644 .changeset/batch-publish-advisories.md create mode 100644 packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts diff --git a/.changeset/batch-publish-advisories.md b/.changeset/batch-publish-advisories.md new file mode 100644 index 0000000000..9a6e9d9138 --- /dev/null +++ b/.changeset/batch-publish-advisories.md @@ -0,0 +1,5 @@ +--- +"@objectstack/metadata-protocol": minor +--- + +`publishPackageDrafts` (Studio's "publish whole app", `POST /packages/:id/publish-drafts`) now reports the #4463 runtime authoring gate's non-blocking findings on each `published[]` element as an optional `advisories` key — the same element shape and omitted-when-empty discipline as the single-item publish door (#9176). An advisory-free batch's response bytes are unchanged, and `failed[]` elements are unaffected (an `error` finding still aborts the batch). Previously the batch door computed these per-draft findings and discarded them. diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts new file mode 100644 index 0000000000..cadcabecc1 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9343 — the batch publish door (`publishPackageDrafts`, Studio's "publish + * whole app", `POST /packages/:id/publish-drafts`) reports the #4463 runtime + * authoring gate's per-draft advisories on each `published[]` element. + * + * Ruled shape (maintainer, 2026-08-17, recorded on the card): advisories ride + * EACH `published[]` element, with the same optional, omitted-when-empty shape + * as `PublishMetaItemResponseSchema.advisories` on the single-item door + * (#9176) — no parallel top-level map. `failed[]` elements are unaffected: an + * `error` finding refuses the promotion, and the batch being all-or-nothing + * (ADR-0067 D2) that refusal aborts the whole batch. + * + * Before #9343 the batch caller destructured only `{ singularType, result }` + * from `promoteDraftForPublish` — which since #9176 RETURNS the findings — so + * the gate's advisory half was computed and dropped on the floor, per draft, + * for every draft in the batch: the same shape #9176 closed one door over, + * on the one door bulk/AI authoring actually takes. + * + * The advisory fixture is the #4717 / #9176 measurement verbatim: a flow + * whose ONLY defect is a `delete_record` node declaring `multi: true` with no + * `filter` — `lintFlowPatterns` raises `flow-multi-write-unfiltered` at + * `severity: 'warning'`, so the promotion succeeds and the finding is exactly + * what the advisory channel exists to carry. `runAs: 'system'` is + * load-bearing: without it `flow-runas-unscoped` fires at `severity: 'error'` + * and the publish becomes a refusal wearing an advisory's clothes. + * + * Harness: the same faithful stub engine as + * `protocol-publish-drafts-org-scope.test.ts` (kept local — self-contained + * harnesses are the established shape here, so two tripwires can fail + * independently). Flows are env-wide (`flow` is `allowOrgOverride: false`), + * saved as package-bound drafts, published through the REAL + * `publishPackageDrafts` — nothing on the gate path is stubbed. + */ + +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } 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; + } + 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 → publishPackageDrafts skips the + // ADR-0028 prefix check (legacy-grandfathered path). + getPackage: () => undefined, + }, + }; + return { engine, rows, historyRows }; +} + +const PKG = 'app.ops'; + +/** + * The reachable success-with-advisories fixture (#4717 / #9176, verbatim in + * structure): the only defect is the unbounded bulk delete, which + * `lintFlowPatterns` reports at `severity: 'warning'`. + */ +const advisoryFlow = (name: string) => ({ + name, + label: 'Nightly Purge', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'purge', + type: 'delete_record', + label: 'Purge', + config: { objectName: 'audit_logs', multi: true }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'purge' }], +}); + +/** The same flow with the bulk write bounded — no finding of any severity. */ +const cleanFlow = (name: string) => { + const flow = advisoryFlow(name); + (flow.nodes[1] as any).config.filter = [{ field: 'created_at', operator: 'lt', value: '2020-01-01' }]; + return flow; +}; + +/** A flow whose approval expression is broken — `severity: 'error'`, the gating half. */ +const gatedFlow = (name: string) => ({ + name, + label: 'Leave Approval', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'approve', + type: 'approval', + label: 'Approve', + config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'approve' }], +}); + +/** Stage one env-wide, package-bound flow draft (Studio's "Save Draft" shape). */ +async function stageFlowDraft( + protocol: ObjectStackProtocolImplementation, + name: string, + item: unknown, +): Promise { + await (protocol as any).saveMetaItem({ + type: 'flow', name, item, packageId: PKG, mode: 'draft', + }); +} + +describe('publishPackageDrafts carries per-draft advisories on published[] elements (#9343)', () => { + it('a batch whose one draft raises an advisory succeeds AND reports it on that element', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await stageFlowDraft(protocol, 'nightly_purge', advisoryFlow('nightly_purge')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + // The batch succeeded — advisories ride the 2xx, never a refusal. + expect(res.failed).toEqual([]); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + + // The finding reached the caller ON THE ELEMENT, with the id and + // severity the rule emits — asserting the RULE ID rather than a bare + // non-empty array: an array of the wrong findings is a different + // defect from an empty one. + const el = res.published[0]!; + expect(el).toMatchObject({ type: 'flow', name: 'nightly_purge' }); + expect(el.advisories).toHaveLength(1); + expect(el.advisories![0]!.rule).toBe('flow-multi-write-unfiltered'); + expect(el.advisories![0]!.severity).toBe('warning'); + expect(el.advisories![0]!.where).toContain('nightly_purge'); + + // The element shape mirrors the single-item door's + // `RuntimeAuthoringIssueSchema` element keys (#9176) — the "same + // shape, both doors" half of the ruling. + expect(Object.keys(el.advisories![0]!).sort()) + .toEqual(['hint', 'message', 'path', 'rule', 'severity', 'where']); + }); + + it('a mixed batch attaches advisories to exactly the raising element — the clean sibling carries no key', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await stageFlowDraft(protocol, 'nightly_purge', advisoryFlow('nightly_purge')); + await stageFlowDraft(protocol, 'bounded_purge', cleanFlow('bounded_purge')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 }); + const byName = new Map(res.published.map((p) => [p.name, p])); + const raising = byName.get('nightly_purge')!; + const clean = byName.get('bounded_purge')!; + + // Exactly the raising element reports; per-draft mapping, not batch-level. + expect(raising.advisories).toHaveLength(1); + expect(raising.advisories![0]!.rule).toBe('flow-multi-write-unfiltered'); + + // The clean element's KEY SET is untouched — `advisories: []` would + // satisfy a `toHaveLength(0)` while changing the element's bytes, + // which is exactly what the omitted-when-empty rule forbids. + expect('advisories' in clean).toBe(false); + expect(Object.keys(clean).sort()).toEqual(['name', 'type', 'version']); + }); + + it('an advisory-free batch changes nothing: no element carries the key, byte-identical response', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await stageFlowDraft(protocol, 'bounded_purge', cleanFlow('bounded_purge')); + await stageFlowDraft(protocol, 'second_purge', cleanFlow('second_purge')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 }); + for (const el of res.published) { + expect('advisories' in el).toBe(false); + expect(Object.keys(el).sort()).toEqual(['name', 'type', 'version']); + } + // Byte-for-byte: the serialized response of a clean batch carries no + // trace of the field. `JSON.stringify` is the wire (the route hands + // this object to `res.json()` verbatim), and the wire is the promise + // being made to existing callers. + expect(JSON.stringify(res)).not.toContain('advisories'); + }); + + it('the gating half is unchanged: an `error` finding aborts the batch, and failed[] elements carry no advisories key', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + // Draft saves are never gated (D1) — both stage fine. + await stageFlowDraft(protocol, 'leave_approval', gatedFlow('leave_approval')); + await stageFlowDraft(protocol, 'nightly_purge', advisoryFlow('nightly_purge')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + // ADR-0067 D2 — all-or-nothing: the error finding refuses the causal + // item and rolls back the sibling whose own finding was only advisory. + expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 2 }); + expect(res.published).toEqual([]); + const causal = res.failed.find((f) => f.name === 'leave_approval')!; + expect(causal.code).toBe('INVALID_METADATA'); + const aborted = res.failed.find((f) => f.name === 'nightly_purge')!; + expect(aborted.code).toBe('BATCH_ABORTED'); + // `failed[]` elements are unaffected by #9343 — the ruling's explicit + // boundary: no advisories key appears anywhere on a refused batch. + expect(JSON.stringify(res.failed)).not.toContain('advisories'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c0ab869993..aedc09e1fd 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3804,9 +3804,9 @@ export class ObjectStackProtocolImplementation implements * write earns — `saveMetaItem` attaches them to its response, and since * #9176 the draft→active promotion does the same: `promoteDraftForPublish` * hands the findings out and `publishMetaItem` attaches them, so both - * write doors report what D1 makes both of them measure. (The batch route - * `publishPackageDrafts` still discards its per-draft findings — its - * response face is a different contract.) + * write doors report what D1 makes both of them measure. [#9343] The batch + * route `publishPackageDrafts` reports them too, per `published[]` element + * — one gate, two doors, one receipt vocabulary. * Returns an empty array on every early return: no rules ran, so there is * nothing to report, and "clean" is told apart from "nothing ran" by the * gate's own `rulesRun`, not by this. @@ -13880,9 +13880,10 @@ export class ObjectStackProtocolImplementation implements * gating half throws before this method resolves). Empty when the * gate raised nothing or did not run (no draft, package-author * channel); `publishMetaItem` attaches it to its response only when - * non-empty, exactly as `saveMetaItem` does one door over. The batch - * caller (`publishPackageDrafts`) deliberately does not read it — - * its response face is a different contract. + * non-empty, exactly as `saveMetaItem` does one door over. [#9343] + * The batch caller (`publishPackageDrafts`) reads it too and attaches + * it to the matching `published[]` element, same omitted-when-empty + * discipline — per the maintainer's ruling, no parallel top-level map. */ advisories: RuntimeAuthoringIssue[]; result: { version: string; seq: number; item: MetadataItem; packageId: string | null }; @@ -14281,7 +14282,27 @@ export class ObjectStackProtocolImplementation implements success: boolean; publishedCount: number; failedCount: number; - published: Array<{ type: string; name: string; version: string }>; + published: Array<{ + type: string; + name: string; + version: string; + /** + * [#9343] The #4463 runtime authoring gate's non-blocking findings + * for THIS draft's promotion — the same element shape and the same + * omitted-when-empty discipline as + * `PublishMetaItemResponseSchema.advisories` on the single-item + * door (#9176), riding each `published[]` element per the + * maintainer's ruling (no parallel top-level map). Present ONLY + * when the gate raised at least one finding against this draft — + * an empty array is never emitted, so an advisory-free batch's + * response bytes are unchanged. Advisory by construction: every + * entry is `warning`/`info`, because an `error` finding refuses + * the promotion and — the batch being all-or-nothing (ADR-0067 + * D2) — aborts the whole batch as `failed[]` instead; + * `failed[]` elements never carry this key. + */ + advisories?: RuntimeAuthoringIssue[]; + }>; failed: Array<{ type: string; name: string; error: string; code?: string }>; /** Aggregate result of materializing every published `seed` (absent when no seeds). */ seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; @@ -14563,7 +14584,7 @@ export class ObjectStackProtocolImplementation implements }; } - const published: Array<{ type: string; name: string; version: string }> = []; + const published: Array<{ type: string; name: string; version: string; advisories?: RuntimeAuthoringIssue[] }> = []; const failed: Array<{ type: string; name: string; error: string; code?: string; issues?: Array<{ path: string; message: string; code?: string }> }> = []; // Structure first, seeds LAST — a seed's rows can only land after its @@ -14672,6 +14693,14 @@ export class ObjectStackProtocolImplementation implements packageId: string | null; version: string; seq: number; + /** + * [#9343] The #4463 gate's advisory half for this promotion, + * captured from `promoteDraftForPublish`'s return inside Phase 1 + * so Phase 2 can attach it to this draft's `published[]` element. + * Empty = the gate raised nothing (or did not run) — the element + * then carries no `advisories` key at all. + */ + advisories: RuntimeAuthoringIssue[]; /** * [#8400] The scope the draft was PROMOTED IN — `d.organizationId`, * not the request's active org. `listDrafts` surfaces env-wide @@ -14718,7 +14747,7 @@ export class ObjectStackProtocolImplementation implements const draft = await seedRepo.get(ref, { state: 'draft' }); if (draft?.body) seedBodies.push(draft.body); } - const { singularType, result } = await this.promoteDraftForPublish({ + const { singularType, advisories, result } = await this.promoteDraftForPublish({ // [#8908] The stored spelling, FOLDED — this route's // boundary, the analogue of `canonicalizeMetaRequestType` // on the six `/meta` entry points. Every row that @@ -14764,6 +14793,7 @@ export class ObjectStackProtocolImplementation implements packageId: result.packageId, version: result.version, seq: result.seq, + advisories, draftOrgId, }); if (typeof result.seq === 'number') publishedSeqs.push(result.seq); @@ -14991,7 +15021,14 @@ export class ObjectStackProtocolImplementation implements } for (const p of promoted) { - published.push({ type: p.d.type, name: p.d.name, version: p.version }); + // [#9343] Omitted-when-empty, never `advisories: []` — the #4717 + // discipline every advisory-carrying door follows: an advisory-free + // element's bytes are unchanged, and absence means "nothing to + // report", never "the gate did not run". + published.push({ + type: p.d.type, name: p.d.name, version: p.version, + ...(p.advisories.length > 0 ? { advisories: p.advisories } : {}), + }); try { const eff = await this.runPublishSideEffects({ singularType: p.singularType, diff --git a/packages/objectql/src/build-probes.test.ts b/packages/objectql/src/build-probes.test.ts index 3591425b56..62f4c9fc58 100644 --- a/packages/objectql/src/build-probes.test.ts +++ b/packages/objectql/src/build-probes.test.ts @@ -187,6 +187,9 @@ describe('publishPackageDrafts — probes ride the response (ADR-0038 L3)', () = vi.spyOn(protocol as any, 'promoteDraftForPublish').mockImplementation(async (req: any) => ({ singularType: req.type, orgId: null, + // [#9176/#9343] The real helper always returns the advisory half; the + // batch door reads it per published[] element. + advisories: [], result: { version: 'h', seq: 1, item: { body: ITEMS[`${req.type} ${req.name}`] ?? { name: req.name } }, packageId: null }, })); vi.spyOn(protocol as any, 'runPublishSideEffects').mockResolvedValue({}); diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index 230d07bcfb..147a71c250 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -201,6 +201,9 @@ describe('ADR-0067 — publishPackageDrafts records a commit', () => { vi.spyOn(protocol as any, 'promoteDraftForPublish').mockImplementation(async (req: any) => ({ singularType: req.type, orgId: null, + // [#9176/#9343] The real helper always returns the advisory half; the + // batch door reads it per published[] element. + advisories: [], result: { version: 'h', seq: 7, item: { body: { name: req.name } }, packageId: null }, })); vi.spyOn(protocol as any, 'runPublishSideEffects').mockResolvedValue({}); diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index 181c904a33..7fb3ed808f 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -99,6 +99,10 @@ function makeProtocol( const promoteOk = (req: any) => ({ singularType: req.type, orgId: null, + // [#9176/#9343] The real helper ALWAYS returns the gate's advisory half + // (empty = nothing raised); the batch door reads it per element now, so a + // double that omits the key is a drifted seam, not a minimal one. + advisories: [], result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null }, }); return { @@ -406,6 +410,8 @@ describe('protocol.publishPackageDrafts (ADR-0033 / ADR-0067 D2)', () => { .mockImplementation(async (req: any) => ({ singularType: req.type, orgId: null, + // [#9176/#9343] The real helper always returns the advisory half. + advisories: [], result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null }, })); const sideEffects = vi.spyOn(protocol as any, 'runPublishSideEffects').mockResolvedValue({});