Skip to content

Commit 40d5b2d

Browse files
os-steveclaude
andauthored
feat(metadata-protocol): batch publish door returns per-draft advisories on each published[] element (#9405)
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 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1d026e commit 40d5b2d

6 files changed

Lines changed: 395 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
`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.
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #9343 — the batch publish door (`publishPackageDrafts`, Studio's "publish
5+
* whole app", `POST /packages/:id/publish-drafts`) reports the #4463 runtime
6+
* authoring gate's per-draft advisories on each `published[]` element.
7+
*
8+
* Ruled shape (maintainer, 2026-08-17, recorded on the card): advisories ride
9+
* EACH `published[]` element, with the same optional, omitted-when-empty shape
10+
* as `PublishMetaItemResponseSchema.advisories` on the single-item door
11+
* (#9176) — no parallel top-level map. `failed[]` elements are unaffected: an
12+
* `error` finding refuses the promotion, and the batch being all-or-nothing
13+
* (ADR-0067 D2) that refusal aborts the whole batch.
14+
*
15+
* Before #9343 the batch caller destructured only `{ singularType, result }`
16+
* from `promoteDraftForPublish` — which since #9176 RETURNS the findings — so
17+
* the gate's advisory half was computed and dropped on the floor, per draft,
18+
* for every draft in the batch: the same shape #9176 closed one door over,
19+
* on the one door bulk/AI authoring actually takes.
20+
*
21+
* The advisory fixture is the #4717 / #9176 measurement verbatim: a flow
22+
* whose ONLY defect is a `delete_record` node declaring `multi: true` with no
23+
* `filter` — `lintFlowPatterns` raises `flow-multi-write-unfiltered` at
24+
* `severity: 'warning'`, so the promotion succeeds and the finding is exactly
25+
* what the advisory channel exists to carry. `runAs: 'system'` is
26+
* load-bearing: without it `flow-runas-unscoped` fires at `severity: 'error'`
27+
* and the publish becomes a refusal wearing an advisory's clothes.
28+
*
29+
* Harness: the same faithful stub engine as
30+
* `protocol-publish-drafts-org-scope.test.ts` (kept local — self-contained
31+
* harnesses are the established shape here, so two tripwires can fail
32+
* independently). Flows are env-wide (`flow` is `allowOrgOverride: false`),
33+
* saved as package-bound drafts, published through the REAL
34+
* `publishPackageDrafts` — nothing on the gate path is stubbed.
35+
*/
36+
37+
import { describe, expect, it } from 'vitest';
38+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
39+
import { ObjectStackProtocolImplementation } from './protocol.js';
40+
41+
interface Row {
42+
id: string;
43+
type: string;
44+
name: string;
45+
organization_id: string | null;
46+
package_id: string | null;
47+
state: string;
48+
metadata: string;
49+
checksum?: string;
50+
version?: number;
51+
updated_at?: string;
52+
created_at?: string;
53+
}
54+
55+
interface HistoryRow {
56+
id: string;
57+
event_seq: number;
58+
name: string;
59+
type: string;
60+
version: number;
61+
operation_type: string;
62+
metadata: string | null;
63+
checksum: string | null;
64+
previous_checksum: string | null;
65+
change_note?: string | null;
66+
source?: string | null;
67+
organization_id: string | null;
68+
recorded_by?: string | null;
69+
recorded_at: string;
70+
}
71+
72+
// Overlay rows are keyed by (type, name, org, state, package_id) — the ADR-0048 key.
73+
function keyOf(w: Record<string, unknown>) {
74+
return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`;
75+
}
76+
77+
function matchesMetadataWhere(r: Row, where: Record<string, unknown>): boolean {
78+
for (const [k, v] of Object.entries(where)) {
79+
if (k === '$or') {
80+
const clauses = v as Array<Record<string, unknown>>;
81+
if (!clauses.some((c) => matchesMetadataWhere(r, c))) return false;
82+
continue;
83+
}
84+
if (v === undefined) continue;
85+
if ((r as any)[k] !== v) return false;
86+
}
87+
return true;
88+
}
89+
90+
function makeStubEngine() {
91+
const rows = new Map<string, Row>();
92+
const historyRows: HistoryRow[] = [];
93+
let nextId = 0;
94+
95+
const findRow = (w: Record<string, unknown>): { key: string; row: Row } | null => {
96+
if (w.id !== undefined) {
97+
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
98+
return null;
99+
}
100+
if (w.package_id !== undefined) {
101+
const k = keyOf(w);
102+
const r = rows.get(k);
103+
return r ? { key: k, row: r } : null;
104+
}
105+
for (const [k, r] of rows) if (matchesMetadataWhere(r, w)) return { key: k, row: r };
106+
return null;
107+
};
108+
109+
const matchesHistory = (h: HistoryRow, w: Record<string, unknown>): boolean => {
110+
if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false;
111+
if (w.type !== undefined && h.type !== w.type) return false;
112+
if (w.name !== undefined && h.name !== w.name) return false;
113+
if (w.version !== undefined && h.version !== w.version) return false;
114+
if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false;
115+
return true;
116+
};
117+
118+
const engine: any = {
119+
async findOne(table: string, opts: { where: Record<string, unknown> }) {
120+
if (table === 'sys_metadata_history') {
121+
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
122+
}
123+
return findRow(opts.where)?.row ?? null;
124+
},
125+
async find(table: string, opts: { where: Record<string, unknown> }) {
126+
if (table === 'sys_metadata_history') {
127+
return historyRows.filter((h) => matchesHistory(h, opts.where));
128+
}
129+
return Array.from(rows.values()).filter((r) => matchesMetadataWhere(r, opts.where));
130+
},
131+
async insert(table: string, data: Record<string, unknown>) {
132+
if (table === 'sys_metadata_audit') return { id: 'audit_skip' };
133+
if (table === 'sys_metadata_history') {
134+
nextId += 1;
135+
const h: HistoryRow = { id: `h_${nextId}`, ...(data as any) };
136+
historyRows.push(h);
137+
return { id: h.id };
138+
}
139+
nextId += 1;
140+
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
141+
rows.set(keyOf(data), row);
142+
return { id: row.id };
143+
},
144+
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
145+
assertEngineUpdateDispatch(data, opts);
146+
const found = findRow(opts.where);
147+
if (!found) return { id: null };
148+
const merged = { ...found.row, ...(data as any) };
149+
rows.delete(found.key);
150+
rows.set(keyOf(merged), merged);
151+
return { id: found.row.id };
152+
},
153+
async delete(_t: string, opts: { where: Record<string, unknown> }) {
154+
assertEngineDeleteDispatch(opts);
155+
const found = findRow(opts.where);
156+
if (!found) return { deleted: 0 };
157+
rows.delete(found.key);
158+
return { deleted: 1 };
159+
},
160+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
161+
return cb(undefined, { owned: true });
162+
},
163+
registry: {
164+
registerItem: () => {},
165+
registerObject: () => {},
166+
// No declared package namespace → publishPackageDrafts skips the
167+
// ADR-0028 prefix check (legacy-grandfathered path).
168+
getPackage: () => undefined,
169+
},
170+
};
171+
return { engine, rows, historyRows };
172+
}
173+
174+
const PKG = 'app.ops';
175+
176+
/**
177+
* The reachable success-with-advisories fixture (#4717 / #9176, verbatim in
178+
* structure): the only defect is the unbounded bulk delete, which
179+
* `lintFlowPatterns` reports at `severity: 'warning'`.
180+
*/
181+
const advisoryFlow = (name: string) => ({
182+
name,
183+
label: 'Nightly Purge',
184+
type: 'autolaunched',
185+
status: 'active',
186+
runAs: 'system',
187+
nodes: [
188+
{ id: 'start', type: 'start', label: 'Start' },
189+
{
190+
id: 'purge',
191+
type: 'delete_record',
192+
label: 'Purge',
193+
config: { objectName: 'audit_logs', multi: true },
194+
},
195+
],
196+
edges: [{ id: 'e1', source: 'start', target: 'purge' }],
197+
});
198+
199+
/** The same flow with the bulk write bounded — no finding of any severity. */
200+
const cleanFlow = (name: string) => {
201+
const flow = advisoryFlow(name);
202+
(flow.nodes[1] as any).config.filter = [{ field: 'created_at', operator: 'lt', value: '2020-01-01' }];
203+
return flow;
204+
};
205+
206+
/** A flow whose approval expression is broken — `severity: 'error'`, the gating half. */
207+
const gatedFlow = (name: string) => ({
208+
name,
209+
label: 'Leave Approval',
210+
type: 'autolaunched',
211+
status: 'active',
212+
runAs: 'system',
213+
nodes: [
214+
{ id: 'start', type: 'start', label: 'Start' },
215+
{
216+
id: 'approve',
217+
type: 'approval',
218+
label: 'Approve',
219+
config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] },
220+
},
221+
],
222+
edges: [{ id: 'e1', source: 'start', target: 'approve' }],
223+
});
224+
225+
/** Stage one env-wide, package-bound flow draft (Studio's "Save Draft" shape). */
226+
async function stageFlowDraft(
227+
protocol: ObjectStackProtocolImplementation,
228+
name: string,
229+
item: unknown,
230+
): Promise<void> {
231+
await (protocol as any).saveMetaItem({
232+
type: 'flow', name, item, packageId: PKG, mode: 'draft',
233+
});
234+
}
235+
236+
describe('publishPackageDrafts carries per-draft advisories on published[] elements (#9343)', () => {
237+
it('a batch whose one draft raises an advisory succeeds AND reports it on that element', async () => {
238+
const { engine } = makeStubEngine();
239+
const protocol = new ObjectStackProtocolImplementation(engine);
240+
await stageFlowDraft(protocol, 'nightly_purge', advisoryFlow('nightly_purge'));
241+
242+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
243+
244+
// The batch succeeded — advisories ride the 2xx, never a refusal.
245+
expect(res.failed).toEqual([]);
246+
expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 });
247+
248+
// The finding reached the caller ON THE ELEMENT, with the id and
249+
// severity the rule emits — asserting the RULE ID rather than a bare
250+
// non-empty array: an array of the wrong findings is a different
251+
// defect from an empty one.
252+
const el = res.published[0]!;
253+
expect(el).toMatchObject({ type: 'flow', name: 'nightly_purge' });
254+
expect(el.advisories).toHaveLength(1);
255+
expect(el.advisories![0]!.rule).toBe('flow-multi-write-unfiltered');
256+
expect(el.advisories![0]!.severity).toBe('warning');
257+
expect(el.advisories![0]!.where).toContain('nightly_purge');
258+
259+
// The element shape mirrors the single-item door's
260+
// `RuntimeAuthoringIssueSchema` element keys (#9176) — the "same
261+
// shape, both doors" half of the ruling.
262+
expect(Object.keys(el.advisories![0]!).sort())
263+
.toEqual(['hint', 'message', 'path', 'rule', 'severity', 'where']);
264+
});
265+
266+
it('a mixed batch attaches advisories to exactly the raising element — the clean sibling carries no key', async () => {
267+
const { engine } = makeStubEngine();
268+
const protocol = new ObjectStackProtocolImplementation(engine);
269+
await stageFlowDraft(protocol, 'nightly_purge', advisoryFlow('nightly_purge'));
270+
await stageFlowDraft(protocol, 'bounded_purge', cleanFlow('bounded_purge'));
271+
272+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
273+
274+
expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 });
275+
const byName = new Map(res.published.map((p) => [p.name, p]));
276+
const raising = byName.get('nightly_purge')!;
277+
const clean = byName.get('bounded_purge')!;
278+
279+
// Exactly the raising element reports; per-draft mapping, not batch-level.
280+
expect(raising.advisories).toHaveLength(1);
281+
expect(raising.advisories![0]!.rule).toBe('flow-multi-write-unfiltered');
282+
283+
// The clean element's KEY SET is untouched — `advisories: []` would
284+
// satisfy a `toHaveLength(0)` while changing the element's bytes,
285+
// which is exactly what the omitted-when-empty rule forbids.
286+
expect('advisories' in clean).toBe(false);
287+
expect(Object.keys(clean).sort()).toEqual(['name', 'type', 'version']);
288+
});
289+
290+
it('an advisory-free batch changes nothing: no element carries the key, byte-identical response', async () => {
291+
const { engine } = makeStubEngine();
292+
const protocol = new ObjectStackProtocolImplementation(engine);
293+
await stageFlowDraft(protocol, 'bounded_purge', cleanFlow('bounded_purge'));
294+
await stageFlowDraft(protocol, 'second_purge', cleanFlow('second_purge'));
295+
296+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
297+
298+
expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 });
299+
for (const el of res.published) {
300+
expect('advisories' in el).toBe(false);
301+
expect(Object.keys(el).sort()).toEqual(['name', 'type', 'version']);
302+
}
303+
// Byte-for-byte: the serialized response of a clean batch carries no
304+
// trace of the field. `JSON.stringify` is the wire (the route hands
305+
// this object to `res.json()` verbatim), and the wire is the promise
306+
// being made to existing callers.
307+
expect(JSON.stringify(res)).not.toContain('advisories');
308+
});
309+
310+
it('the gating half is unchanged: an `error` finding aborts the batch, and failed[] elements carry no advisories key', async () => {
311+
const { engine } = makeStubEngine();
312+
const protocol = new ObjectStackProtocolImplementation(engine);
313+
// Draft saves are never gated (D1) — both stage fine.
314+
await stageFlowDraft(protocol, 'leave_approval', gatedFlow('leave_approval'));
315+
await stageFlowDraft(protocol, 'nightly_purge', advisoryFlow('nightly_purge'));
316+
317+
const res = await protocol.publishPackageDrafts({ packageId: PKG });
318+
319+
// ADR-0067 D2 — all-or-nothing: the error finding refuses the causal
320+
// item and rolls back the sibling whose own finding was only advisory.
321+
expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 2 });
322+
expect(res.published).toEqual([]);
323+
const causal = res.failed.find((f) => f.name === 'leave_approval')!;
324+
expect(causal.code).toBe('INVALID_METADATA');
325+
const aborted = res.failed.find((f) => f.name === 'nightly_purge')!;
326+
expect(aborted.code).toBe('BATCH_ABORTED');
327+
// `failed[]` elements are unaffected by #9343 — the ruling's explicit
328+
// boundary: no advisories key appears anywhere on a refused batch.
329+
expect(JSON.stringify(res.failed)).not.toContain('advisories');
330+
});
331+
});

0 commit comments

Comments
 (0)