Skip to content
Merged
31 changes: 31 additions & 0 deletions .changeset/publish-drafts-outcome-discriminant.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
160 changes: 160 additions & 0 deletions packages/metadata-protocol/src/protocol-publish-drafts-outcome.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) => {
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);
});
});
52 changes: 52 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading