diff --git a/.changeset/publish-drafts-outcome-discriminant.md b/.changeset/publish-drafts-outcome-discriminant.md new file mode 100644 index 0000000000..482250dbae --- /dev/null +++ b/.changeset/publish-drafts-outcome-discriminant.md @@ -0,0 +1,31 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +Declare `outcome: 'published' | 'refused' | 'nothing_to_publish'` as a required +key on the `publishPackageDrafts` response (#10462) — the first-class +discriminant for WHICH exit answered, the fact `success` compresses into one +boolean. Before this field, a publish with nothing to promote and a genuine +refusal (pre-flight violation or ADR-0067 D2 rollback) were indistinguishable: +both answer `success: false` with `publishedCount: 0` on a 200, and the no-op +left no trace at all — an AI consumer graded the no-op as "refused and rolled +back" and burned two repair rounds on artifacts that were already correct +(cloud#1488; cloud#1492's patch discriminates on `failed.length > 0`, an +invariant the producer never stated). + +The producer invariants, now stated and pinned in the conformance suites, both +directions of each: `outcome === 'refused'` ⟺ `failed.length > 0`; +`outcome === 'nothing_to_publish'` ⟺ +`published.length === 0 && failed.length === 0`; +`success === (outcome === 'published')`. `success` keeps its exact pre-#10462 +value on every exit — a no-op still answers `success: false` — so consumers +reading only `success` see no change, and cloud#1492's `failed.length` +discrimination stays valid during its convergence onto `outcome`. The no-op +exit additionally logs one `info` line naming the package and both facts +(nothing pending, nothing refused), so that exit is no longer traceless. + +Additive for response consumers. A custom protocol implementation that serves +`publishPackageDrafts` must now emit `outcome` on every return — +`PublishPackageDraftsResponseSchema` declares it required, and the conformance +suites treat a producer return without it as a drifted seam. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ee9bc0eced..7279a72733 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1243,7 +1243,8 @@ List packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | True only when every pending draft promoted (`failed` empty) AND at least one item published. A pre-flight refusal or an ADR-0067 D2 rollback answers false on a 200 — read `failed[]`, not the HTTP status. It does NOT cover the best-effort receipts below, each of which reports its own `success`. | +| **success** | `boolean` | ✅ | True only when every pending draft promoted (`failed` empty) AND at least one item published. A pre-flight refusal or an ADR-0067 D2 rollback answers false on a 200 — but so does a publish with nothing to promote, so false alone is NOT a refusal: read `outcome` (#10462), not this boolean or the HTTP status. Always equal to `outcome === 'published'` (pinned). It does NOT cover the best-effort receipts below, each of which reports its own `success`. | +| **outcome** | `Enum<'published' \| 'refused' \| 'nothing_to_publish'>` | ✅ | First-class discriminant for WHICH exit answered (#10462) — the fact `success` compresses into one boolean. `published`: at least one draft promoted and none refused. `refused`: the batch was refused — a pre-flight violation or the ADR-0067 D2 all-or-nothing rollback; the per-item story is in `failed[]`, which is non-empty exactly on this outcome (the invariant consumers previously had to reverse-engineer, now stated by the producer). `nothing_to_publish`: the package had no pending drafts — nothing landed AND nothing was refused; `success` stays false (a no-op is not a successful publish), which before this field made that answer indistinguishable from a refusal. Producer invariants, pinned in the conformance suites: `success === (outcome === 'published')`; `refused` if and only if `failed.length > 0`; `nothing_to_publish` if and only if `published.length === 0 && failed.length === 0`. Values are lowercase snake, matching the `sys_metadata_audit` outcome vocabulary. | | **publishedCount** | `integer` | ✅ | Number of drafts promoted to active — `published.length`. 0 on every refusal path (the batch is all-or-nothing, ADR-0067 D2). | | **failedCount** | `integer` | ✅ | Number of items that did not publish — `failed.length`. On a rollback this counts the WHOLE batch: the causal item plus every sibling marked BATCH_ABORTED. | | **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` | ✅ | Every draft promoted to active, in publish order. Empty on every refusal path. | diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-outcome.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-outcome.test.ts new file mode 100644 index 0000000000..c0e54f32b9 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-publish-drafts-outcome.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10462] `publishPackageDrafts` return-site pins the objectql-side + * conformance suite cannot stage against the real engine: + * + * - the Phase-1 UNWIND's `outcome` — staging a real mid-transaction promotion + * failure through the real engine rides the undeclared `failed[].issues` + * key on the causal element (#10524's surface, deliberately not this + * card's), so the unwind is pinned here with the promotion seam mocked and + * a plain Error (no `issues`); + * - the no-op TRACE — the `console.info` line is the no-op exit's only + * record: the audit ledger stays silent on purpose (`sys_metadata_audit` + * rows are keyed on `(type, name)`, and a batch with zero items has no + * honest identity to mint — the limiting case of the rule both refusal + * sites already follow). This file goes red if the line is dropped, and + * pins that it does NOT fire on the exits that leave their own records; + * - the zero-draft machinery edge — the one return whose `outcome` is not + * fixed by the site it sits on: a transaction-machinery failure over an + * EMPTY batch answers `nothing_to_publish` (nothing was pending, nothing + * was refused; the unwind's `console.warn` stays the record of the + * failure), which is what keeps the producer invariant + * `outcome === 'refused'` iff `failed.length > 0` true on every return. + * + * Harness copied from + * `packages/objectql/src/protocol-publish-package-drafts.test.ts` (the #8896 + * capture double) — copied, NOT imported: metadata-protocol cannot depend on + * objectql, and each pin must be able to fail independently. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * [#8896] A real double for the two engine calls `publishPackageDrafts` makes + * on its own — the ADR-0067 pre-publish CAPTURE read (`findOne`, answering an + * explicit `null` for "no active row"), and the commit write (`insert`). + */ +function makeCaptureEngine() { + const engine = { + findOne: async (table: string, opts?: { where?: Record }) => { + void table; void opts; + // Every artifact is new here: `null` is the truthful capture answer. + return null; + }, + insert: async (table: string) => ({ id: `${table}_1` }), + }; + return engine; +} + +function makeProtocol(drafts: Array<{ type: string; name: string }>) { + const protocol = new ObjectStackProtocolImplementation({} as never); + (protocol as any).ensureOverlayIndex = async () => {}; + (protocol as any).getOverlayRepo = () => ({ listDrafts: async () => drafts }); + (protocol as any).engine = makeCaptureEngine(); + const promote = vi.spyOn(protocol as any, 'promoteDraftForPublish'); + const sideEffects = vi + .spyOn(protocol as any, 'runPublishSideEffects') + .mockResolvedValue({}); + const promoteOk = (req: any) => ({ + singularType: req.type, + orgId: null, + advisories: [], + result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null }, + }); + return { protocol, promote, sideEffects, promoteOk }; +} + +const NOOP_LINE = /\[Protocol\] publishPackageDrafts: nothing to publish/; + +afterEach(() => vi.restoreAllMocks()); + +describe('[#10462] the Phase-1 unwind names its outcome', () => { + it("a mid-batch promotion failure answers outcome 'refused' with the whole batch in failed[]", async () => { + const { protocol, promote } = makeProtocol([ + { type: 'view', name: 'cases' }, + { type: 'view', name: 'leads' }, + ]); + // A plain Error, deliberately without `issues` — see the header. + promote.mockImplementation(async (req: any) => { + if (req.name === 'cases') throw new Error('promotion refused'); + return { + singularType: req.type, orgId: null, advisories: [], + result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null }, + }; + }); + + const res: any = await protocol.publishPackageDrafts({ packageId: 'app.edu' }); + + expect(res.success).toBe(false); + expect(res.outcome).toBe('refused'); + expect(res.publishedCount).toBe(0); + expect(res.published).toEqual([]); + // ADR-0067 D2: the causal item plus its BATCH_ABORTED sibling. + expect(res.failedCount).toBe(2); + expect(res.failed.map((f: any) => f.name).sort()).toEqual(['cases', 'leads']); + // The invariants hold at this site too (both directions). + expect(res.outcome === 'refused').toBe(res.failed.length > 0); + expect(res.success).toBe(res.outcome === 'published'); + }); + + it("a transaction-machinery failure over an EMPTY batch answers 'nothing_to_publish', keeping the invariant universal", async () => { + const { protocol } = makeProtocol([]); + (protocol as any).engine = { + ...makeCaptureEngine(), + transaction: async () => { throw new Error('connection lost'); }, + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const res: any = await protocol.publishPackageDrafts({ packageId: 'app.empty' }); + + // Truthful on both axes: nothing was pending, nothing was refused — + // and `refused` with `failed: []` would break invariant (i). + expect(res).toMatchObject({ + success: false, outcome: 'nothing_to_publish', + publishedCount: 0, failedCount: 0, published: [], failed: [], + }); + // The machinery failure is NOT silent: the unwind's warn is its record. + expect(warn.mock.calls.some((c) => String(c[0]).includes('rolled back'))).toBe(true); + }); +}); + +describe('[#10462] the no-op exit leaves a trace', () => { + it('TRACE CONTROL: the no-op logs one info line naming the package and BOTH facts', async () => { + const { protocol } = makeProtocol([]); + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + + const res: any = await protocol.publishPackageDrafts({ packageId: 'app.empty' }); + + expect(res.outcome).toBe('nothing_to_publish'); + const line = info.mock.calls.map((c) => String(c[0])).find((m) => NOOP_LINE.test(m)); + // Fails if the log line is dropped — the exit would be traceless again. + expect(line).toBeDefined(); + // Names the packageId… + expect(line).toContain("'app.empty'"); + // …and states the two facts `success: false` cannot carry alone: + // nothing was pending, and nothing was refused. + expect(line).toMatch(/no pending drafts/); + expect(line).toMatch(/nothing was refused/); + }); + + it('the trace is SPECIFIC to the no-op: a successful publish and a refusal do not emit it', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + void warn; + + const ok = makeProtocol([{ type: 'view', name: 'cases' }]); + ok.promote.mockImplementation(async (req: any) => ok.promoteOk(req)); + const okRes: any = await ok.protocol.publishPackageDrafts({ packageId: 'app.edu' }); + expect(okRes.outcome).toBe('published'); + + const refused = makeProtocol([{ type: 'view', name: 'cases' }]); + refused.promote.mockImplementation(async () => { throw new Error('promotion refused'); }); + const refusedRes: any = await refused.protocol.publishPackageDrafts({ packageId: 'app.edu' }); + expect(refusedRes.outcome).toBe('refused'); + + // Those two exits leave their own records (audit rows / warn) — the + // info line belongs to the no-op alone. + expect(info.mock.calls.map((c) => String(c[0])).some((m) => NOOP_LINE.test(m))).toBe(false); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8869b9c8e7..b1800a55cc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -15070,6 +15070,20 @@ export class ObjectStackProtocolImplementation implements aiModel?: string; }): Promise<{ success: boolean; + /** + * [#10462] First-class discriminant for WHICH exit answered — the fact + * `success` alone cannot carry: a publish with nothing to promote and + * a genuine refusal both answer `success: false` (cloud#1488 graded + * the former as a rollback and burned two repair rounds on artifacts + * that were already correct). Producer invariants, pinned in the + * conformance suites: `outcome === 'refused'` if and only if + * `failed.length > 0`; `outcome === 'nothing_to_publish'` if and only + * if `published.length === 0 && failed.length === 0`; and + * `success === (outcome === 'published')` — `success` keeps its exact + * pre-#10462 value and is now derivable. Values are lowercase snake, + * matching the `sys_metadata_audit` outcome vocabulary. + */ + outcome: 'published' | 'refused' | 'nothing_to_publish'; publishedCount: number; failedCount: number; published: Array<{ @@ -15363,6 +15377,10 @@ export class ObjectStackProtocolImplementation implements } return { success: false, + // [#10462] Guarded by `preflightViolations.length > 0`, so + // `failed[]` is non-empty by construction — this exit is + // always a refusal. + outcome: 'refused', publishedCount: 0, failedCount: preflightViolations.length, published: [], @@ -15767,6 +15785,16 @@ export class ObjectStackProtocolImplementation implements }); return { success: false, + // [#10462] `failedOut` mirrors `ordered` one-to-one, so it is + // empty ONLY when the batch had zero drafts — a failure in the + // transaction machinery itself over nothing pending. Deriving + // the outcome (rather than hard-coding 'refused') keeps the + // producer invariant `outcome === 'refused' iff + // failed.length > 0` true on every return site; on that edge + // "nothing to publish" stays the truthful answer (nothing was + // pending, nothing was refused) and the `console.warn` above + // remains the record of the machinery failure. + outcome: failedOut.length > 0 ? 'refused' : 'nothing_to_publish', publishedCount: 0, failedCount: failedOut.length, published: [], @@ -15918,8 +15946,32 @@ export class ObjectStackProtocolImplementation implements // ADR-0067 D2 — the commit record was written INSIDE the Phase-1 // transaction above, together with the promotions it describes. + // [#10462] The no-op exit — a publish with nothing to promote — used + // to be the ONLY exit that left no trace at all: no audit row (right: + // `sys_metadata_audit` rows are keyed on `(type, name)`, and a batch + // with zero items has no honest identity to mint — the limiting case + // of the rule both refusal sites already follow) and no log line + // (wrong: an operator who reads `success: false` as a refusal goes + // looking for a rollback that never happened). `info`, not `warn`: + // nothing was claimed persisted and nothing was lost, so this is + // neither a durability nor a functional degradation. + if (published.length === 0 && failed.length === 0) { + console.info( + `[Protocol] publishPackageDrafts: nothing to publish for package ` + + `'${request.packageId}' — no pending drafts were found, and nothing was refused.`, + ); + } + return { success: failed.length === 0 && published.length > 0, + // [#10462] Derived, never stored: 'refused' the moment anything is + // in `failed[]` (defensive — every failure on this route unwinds + // through the Phase-1 catch above today), else 'published' iff + // something landed, else the no-op. Exactly the three-way fact + // `success` compresses into one boolean. + outcome: failed.length > 0 + ? 'refused' + : published.length > 0 ? 'published' : 'nothing_to_publish', publishedCount: published.length, failedCount: failed.length, published, diff --git a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts index 4dbd739aa4..e5a1464ae7 100644 --- a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts +++ b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts @@ -208,6 +208,7 @@ describe('publishPackageDrafts response conforms to PublishPackageDraftsResponse expect(strippedKeys(raw)).toEqual([]); const parsed = PublishPackageDraftsResponseSchema.parse(raw); expect(parsed.success).toBe(true); + expect(parsed.outcome).toBe('published'); expect(parsed.publishedCount).toBe(2); expect(parsed.failedCount).toBe(0); expect(parsed.failed).toEqual([]); @@ -219,12 +220,12 @@ describe('publishPackageDrafts response conforms to PublishPackageDraftsResponse expect(parsed.published.map((e) => e.name).sort()).toEqual(['cases', 'leads']); }); - it('the five always-emitted keys are required — the producer sets them on every return site', async () => { + it('the six always-emitted keys are required — the producer sets them on every return site', async () => { const p = await makeProtocol(); await stageDrafts(p, [{ type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }]); const raw: any = await p.publishPackageDrafts({ packageId: PKG }); - for (const key of ['success', 'publishedCount', 'failedCount', 'published', 'failed'] as const) { + for (const key of ['success', 'outcome', 'publishedCount', 'failedCount', 'published', 'failed'] as const) { expect(raw[key], `producer must emit '${key}'`).toBeDefined(); const body: Record = { ...raw }; delete body[key]; @@ -365,6 +366,7 @@ describe('publishPackageDrafts response conforms to PublishPackageDraftsResponse const raw: any = await p.publishPackageDrafts({ packageId: PKG }); expect(raw.success).toBe(false); + expect(raw.outcome).toBe('refused'); expect(raw.publishedCount).toBe(0); expect(raw.published).toEqual([]); expect(raw.failedCount).toBeGreaterThan(0); @@ -375,3 +377,132 @@ describe('publishPackageDrafts response conforms to PublishPackageDraftsResponse expect(typeof parsed.failed[0]!.code).toBe('string'); }); }); + +/** + * #10462 — `outcome`, the first-class discriminant for WHICH exit answered. + * + * The defect this pins shut, measured on cloud#1488: a publish with nothing + * to promote and a genuine refusal both answer `success: false` with + * `publishedCount: 0`, and the no-op leaves no trace — so an AI consumer + * graded the no-op as "refused and rolled back" and burned two repair rounds + * re-authoring artifacts that were already correct. cloud#1492's patch + * discriminates on `failed.length > 0`, an invariant the producer never + * stated and nothing pinned. `outcome` states it, typed, and these cases pin + * it in both directions. + * + * The card's own requested pin — "every non-success return carries at least + * one `failed[]` entry" — is FALSE today and the no-op is its counterexample + * (`success: false`, `failed: []`). The three invariants below are the honest + * restatement (PM ruling on the card). + */ +describe('outcome discriminates the three publish exits (#10462)', () => { + /** Stage the measured pre-flight refusal class (ADR-0028 prefix rule). */ + async function makeRefusal() { + const p = await makeProtocol(); + (p as any).engine.registry.registerPackage?.({ + id: PKG, manifest: { namespace: 'edu' }, + }); + await stageDrafts(p, [ + { type: 'object', name: 'ticket', item: { name: 'ticket', label: 'Ticket', fields: {} } }, + ]); + return p; + } + + /** + * DEFECT CONTROL — the two shapes cloud#1488 could not tell apart, + * side by side (a case that exercised only the no-op would prove nothing + * about discrimination). Red on origin/main by construction: there the + * two responses differ on no key but `failed[]`'s contents. + */ + it('a no-op publish and a genuine refusal are distinguishable through outcome', async () => { + // The no-op: a package with NO pending drafts. + const pNoop = await makeProtocol(); + const noop: any = await pNoop.publishPackageDrafts({ packageId: 'app.empty' }); + + // The refusal: the same fixture class the refused-batch case stages. + const refused: any = await (await makeRefusal()).publishPackageDrafts({ packageId: PKG }); + + // Identical on every axis the consumer previously had… + expect(noop.success).toBe(false); + expect(refused.success).toBe(false); + expect(noop.publishedCount).toBe(0); + expect(refused.publishedCount).toBe(0); + expect(noop.published).toEqual([]); + expect(refused.published).toEqual([]); + // …and now distinguishable through the declared discriminant. + expect(noop.outcome).toBe('nothing_to_publish'); + expect(refused.outcome).toBe('refused'); + // Both shapes stay inside the declared face. + expect(strippedKeys(noop)).toEqual([]); + expect(strippedKeys(refused)).toEqual([]); + }); + + /** + * The three producer invariants — BOTH directions of each biconditional + * (asserted as boolean equality, which is the biconditional), on every + * response class this suite can stage against the real engine. (i) is the + * guarantee cloud#1492's `failed.length > 0` discrimination needs, stated + * by the producer at last. (iii) doubles as the pin that existing + * `success` semantics did not move. + */ + const expectOutcomeInvariants = (res: any) => { + // (i) refused ⟺ failed[] non-empty. + expect(res.outcome === 'refused').toBe(res.failed.length > 0); + // (ii) nothing_to_publish ⟺ nothing landed AND nothing refused. + expect(res.outcome === 'nothing_to_publish') + .toBe(res.published.length === 0 && res.failed.length === 0); + // (iii) success === (outcome === 'published'). + expect(res.success).toBe(res.outcome === 'published'); + }; + + it('the invariants hold on a published batch, a refused batch, and a no-op', async () => { + const pOk = await makeProtocol(); + await stageDrafts(pOk, [{ type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }]); + const ok: any = await pOk.publishPackageDrafts({ packageId: PKG }); + expect(ok.outcome).toBe('published'); + expectOutcomeInvariants(ok); + + const refused: any = await (await makeRefusal()).publishPackageDrafts({ packageId: PKG }); + expect(refused.outcome).toBe('refused'); + expectOutcomeInvariants(refused); + + const pNoop = await makeProtocol(); + const noop: any = await pNoop.publishPackageDrafts({ packageId: 'app.empty' }); + expect(noop.outcome).toBe('nothing_to_publish'); + expectOutcomeInvariants(noop); + }); + + /** + * PRESERVED-BEHAVIOUR CONTROL — `outcome` is additive: every pre-existing + * field answers exactly as before on all three exits, `success` included. + * Green on origin/main by construction (minus the `outcome` key), so its + * power to fail is proven by mutation, not by running main: rewiring + * `success` from `outcome` (the "a no-op is not a failure" refactor this + * card explicitly does NOT make — that call is the maintainer's) goes red + * on the no-op leg below. The mutation run is reported in the PR. + */ + it('every pre-existing field answers exactly as before on all three exits', async () => { + const pOk = await makeProtocol(); + await stageDrafts(pOk, [{ type: 'view', name: 'cases', item: viewBody('cases', 'Cases') }]); + const { outcome: _okOutcome, ...ok } = (await pOk.publishPackageDrafts({ packageId: PKG })) as any; + expect(ok).toMatchObject({ success: true, publishedCount: 1, failedCount: 0, failed: [] }); + expect(ok.published.map((e: any) => e.name)).toEqual(['cases']); + expect(typeof ok.commitId).toBe('string'); + + const { outcome: _refusedOutcome, ...refused } = + (await (await makeRefusal()).publishPackageDrafts({ packageId: PKG })) as any; + expect(refused).toMatchObject({ success: false, publishedCount: 0, published: [] }); + expect(refused.failedCount).toBeGreaterThan(0); + expect(refused.failed[0]).toMatchObject({ type: 'object', name: 'ticket' }); + expect(typeof refused.failed[0].error).toBe('string'); + expect(typeof refused.failed[0].code).toBe('string'); + + const pNoop = await makeProtocol(); + const { outcome: _noopOutcome, ...noop } = + (await pNoop.publishPackageDrafts({ packageId: 'app.empty' })) as any; + // The EXACT pre-#10462 no-op shape, strictly — the issue's own quote. + expect(noop).toEqual({ + success: false, publishedCount: 0, failedCount: 0, published: [], failed: [], + }); + }); +}); diff --git a/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts b/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts index c018ce7143..f621c335a7 100644 --- a/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts +++ b/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts @@ -66,7 +66,9 @@ function makeDoor(opts: { apps?: Array>; } = {}) { const result = opts.result ?? { - success: true, publishedCount: 1, failedCount: 0, + // `outcome` (#10462) is REQUIRED on every producer return — a double + // without it is a drifted seam, not a minimal one. + success: true, outcome: 'published', publishedCount: 1, failedCount: 0, published: [{ type: 'flow', name: 'nightly_rollup', version: 'sha256:aa11' }], failed: [], probes: { issues: [], checked: { seeds: 0, views: 0, widgets: 0 } }, commitId: 'cmt_01', @@ -136,6 +138,10 @@ describe('publish-drafts wire payload conforms to PublishPackageDraftsResponseSc expect(strippedKeys(data)).toEqual([]); const parsed = PublishPackageDraftsResponseSchema.parse(data); expect(parsed.success).toBe(true); + // #10462 — the discriminant crosses the route untouched: the door + // mutates the object (seed back-fill, ADR-0045 receipts) but never + // this key, and the declared parse does not strip it. + expect(parsed.outcome).toBe('published'); expect(parsed.published[0]!.version).toBe('sha256:aa11'); // The route-attached ADR-0045 receipt is ON the wire and declared. expect(parsed.unhiddenApps).toEqual(['crm', 'ops']); @@ -168,7 +174,7 @@ describe('publish-drafts wire payload conforms to PublishPackageDraftsResponseSc spyLogs(); const { data } = await publishDrafts({ result: { - success: true, publishedCount: 1, failedCount: 0, + success: true, outcome: 'published', publishedCount: 1, failedCount: 0, published: [{ type: 'seed', name: 'demo_rows', version: 'sha256:bb22' }], failed: [], // NO seedApplied: a custom protocol that does not self-apply. // The door back-fills it via `applyPublishedSeeds`, which — the diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index faf3c4257e..f6233734f2 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1283,6 +1283,7 @@ "api/PublishPackageDraftsResponse:failed", "api/PublishPackageDraftsResponse:failedCount", "api/PublishPackageDraftsResponse:materializeApplied", + "api/PublishPackageDraftsResponse:outcome", "api/PublishPackageDraftsResponse:probes", "api/PublishPackageDraftsResponse:published", "api/PublishPackageDraftsResponse:publishedCount", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 4f89a5f4be..98ab962b55 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1058,6 +1058,7 @@ describe('PublishPackageDraftsResponseSchema (#9406 — declares the batch publi /** A verbatim-shaped capture of a real `publishPackageDrafts` return (happy path). */ const realResponse = { success: true, + outcome: 'published', publishedCount: 2, failedCount: 0, published: [ @@ -1074,8 +1075,8 @@ describe('PublishPackageDraftsResponseSchema (#9406 — declares the batch publi expect(parsed).toEqual(realResponse); }); - it('requires the five always-emitted keys — every producer return site sets them', () => { - for (const missing of ['success', 'publishedCount', 'failedCount', 'published', 'failed'] as const) { + it('requires the six always-emitted keys — every producer return site sets them', () => { + for (const missing of ['success', 'outcome', 'publishedCount', 'failedCount', 'published', 'failed'] as const) { const body: Record = { ...realResponse }; delete body[missing]; expect( @@ -1103,6 +1104,7 @@ describe('PublishPackageDraftsResponseSchema (#9406 — declares the batch publi it('failed[] carries the refusal shape, code optional — BATCH_ABORTED marks non-causal items', () => { const rolledBack = { success: false, + outcome: 'refused', publishedCount: 0, failedCount: 2, published: [], @@ -1201,6 +1203,9 @@ describe('PublishPackageDraftsResponseSchema (#9406 — declares the batch publi it('leaves every conditional key optional — absent means "did not apply", never "failed"', () => { const minimal = { success: false, + // The exact no-op shape (#10462) — the response class where every + // conditional key is naturally absent. + outcome: 'nothing_to_publish', publishedCount: 0, failedCount: 0, published: [], @@ -1234,6 +1239,9 @@ describe('PublishPackageDraftsResponseSchema (#9406 — declares the batch publi describe('PublishPackageDraftsResponseSchema published[].advisories (#9343 — #4463 D3 per element)', () => { const base = { success: true, + // #10462 — `outcome` is required on every producer return; this fixture is + // a successful publish, so 'published' is the invariant-consistent value. + outcome: 'published', publishedCount: 1, failedCount: 0, failed: [], diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 70518513e6..4f3d9022ef 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -791,8 +791,9 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ * measured on the producer pair, not assumed: * * - `ObjectStackProtocolImplementation.publishPackageDrafts` builds the base - * object. Its three return sites always set `success` / `publishedCount` / - * `failedCount` / `published` / `failed` (so those five are REQUIRED), and + * object. Its three return sites always set `success` / `outcome` / + * `publishedCount` / `failedCount` / `published` / `failed` (so those six + * are REQUIRED), and * attach `seedApplied` / `materializeApplied` / `probes` / `commitId` only * on the happy path when the corresponding fact exists (so each is * optional — absence means "did not apply", never "failed"). @@ -808,6 +809,10 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ * (pre-flight violations; the ADR-0067 D2 all-or-nothing rollback) answer * `success: false` on a 200 with `publishedCount: 0`, `published: []` and the * whole batch in `failed[]` (`BATCH_ABORTED` marks the non-causal items). + * `success: false` alone does NOT mean a refusal — a publish with no pending + * drafts answers it too, with `failed: []`; `outcome` (#10462) names which of + * the three exits answered, so no consumer has to reverse-engineer that from + * `failed.length`. * * **`probes` is deliberately OPAQUE** — see its own note below. Do not model * it here without a consumer-driven card (#9406 ruling). @@ -816,9 +821,28 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ success: z.boolean().describe( 'True only when every pending draft promoted (`failed` empty) AND at ' + 'least one item published. A pre-flight refusal or an ADR-0067 D2 ' - + 'rollback answers false on a 200 — read `failed[]`, not the HTTP ' - + 'status. It does NOT cover the best-effort receipts below, each of ' - + 'which reports its own `success`.', + + 'rollback answers false on a 200 — but so does a publish with nothing ' + + 'to promote, so false alone is NOT a refusal: read `outcome` (#10462), ' + + 'not this boolean or the HTTP status. Always equal to ' + + '`outcome === \'published\'` (pinned). It does NOT cover the ' + + 'best-effort receipts below, each of which reports its own `success`.', + ), + outcome: z.enum(['published', 'refused', 'nothing_to_publish']).describe( + 'First-class discriminant for WHICH exit answered (#10462) — the fact ' + + '`success` compresses into one boolean. `published`: at least one draft ' + + 'promoted and none refused. `refused`: the batch was refused — a ' + + 'pre-flight violation or the ADR-0067 D2 all-or-nothing rollback; the ' + + 'per-item story is in `failed[]`, which is non-empty exactly on this ' + + 'outcome (the invariant consumers previously had to reverse-engineer, ' + + 'now stated by the producer). `nothing_to_publish`: the package had no ' + + 'pending drafts — nothing landed AND nothing was refused; `success` ' + + 'stays false (a no-op is not a successful publish), which before this ' + + 'field made that answer indistinguishable from a refusal. Producer ' + + 'invariants, pinned in the conformance suites: ' + + '`success === (outcome === \'published\')`; `refused` if and only if ' + + '`failed.length > 0`; `nothing_to_publish` if and only if ' + + '`published.length === 0 && failed.length === 0`. Values are lowercase ' + + 'snake, matching the `sys_metadata_audit` outcome vocabulary.', ), publishedCount: z.number().int().describe( 'Number of drafts promoted to active — `published.length`. 0 on every '