Skip to content

Commit 16cef97

Browse files
Elon Muskclaude
andauthored
[RESCUED — evidence complete] feat(metadata-protocol,spec): declare outcome discriminant on publishPackageDrafts response (#10462) (#10635)
* feat(metadata-protocol,spec): declare 'outcome' discriminant on publishPackageDrafts response (#10462) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * chore(#10462): changeset + regenerated docs reference for the outcome discriminant check:generated proved exactly one artifact stale (content/docs/references/**, from the new .describe() text) and --fix regenerated only it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * test(spec): give the #9343 advisories fixture the required 'outcome' key (#10462) Found by running the full spec suite — the rescued commit updated three fixtures in this file for the now-required key but missed the advisories block's shared 'base' (2 cases failed with ZodError invalid_value on path ['outcome']). The fixture is a successful publish; 'published' is the invariant-consistent value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * merge origin/main (os-regen artifacts taken from main; regeneration follows) * chore(#10462): regenerate os-regen artifacts on the merged base os-regen-merge.sh step 4: the merge took main's side of api.json and protocol.mdx; regenerating from the merged sources restores exactly the outcome rows (api.json +1, protocol.mdx +2/-1). check:generated 14/14 green; main added no api.json entries since merge-base, so no sibling rows were at stake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35ad101 commit 16cef97

9 files changed

Lines changed: 426 additions & 12 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
Declare `outcome: 'published' | 'refused' | 'nothing_to_publish'` as a required
7+
key on the `publishPackageDrafts` response (#10462) — the first-class
8+
discriminant for WHICH exit answered, the fact `success` compresses into one
9+
boolean. Before this field, a publish with nothing to promote and a genuine
10+
refusal (pre-flight violation or ADR-0067 D2 rollback) were indistinguishable:
11+
both answer `success: false` with `publishedCount: 0` on a 200, and the no-op
12+
left no trace at all — an AI consumer graded the no-op as "refused and rolled
13+
back" and burned two repair rounds on artifacts that were already correct
14+
(cloud#1488; cloud#1492's patch discriminates on `failed.length > 0`, an
15+
invariant the producer never stated).
16+
17+
The producer invariants, now stated and pinned in the conformance suites, both
18+
directions of each: `outcome === 'refused'` ⟺ `failed.length > 0`;
19+
`outcome === 'nothing_to_publish'` ⟺
20+
`published.length === 0 && failed.length === 0`;
21+
`success === (outcome === 'published')`. `success` keeps its exact pre-#10462
22+
value on every exit — a no-op still answers `success: false` — so consumers
23+
reading only `success` see no change, and cloud#1492's `failed.length`
24+
discrimination stays valid during its convergence onto `outcome`. The no-op
25+
exit additionally logs one `info` line naming the package and both facts
26+
(nothing pending, nothing refused), so that exit is no longer traceless.
27+
28+
Additive for response consumers. A custom protocol implementation that serves
29+
`publishPackageDrafts` must now emit `outcome` on every return —
30+
`PublishPackageDraftsResponseSchema` declares it required, and the conformance
31+
suites treat a producer return without it as a drifted seam.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1243,7 +1243,8 @@ List packages response
12431243

12441244
| Property | Type | Required | Description |
12451245
| :--- | :--- | :--- | :--- |
1246-
| **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`. |
1246+
| **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`. |
1247+
| **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. |
12471248
| **publishedCount** | `integer` | ✅ | Number of drafts promoted to active — `published.length`. 0 on every refusal path (the batch is all-or-nothing, ADR-0067 D2). |
12481249
| **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. |
12491250
| **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` | ✅ | Every draft promoted to active, in publish order. Empty on every refusal path. |
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#10462] `publishPackageDrafts` return-site pins the objectql-side
5+
* conformance suite cannot stage against the real engine:
6+
*
7+
* - the Phase-1 UNWIND's `outcome` — staging a real mid-transaction promotion
8+
* failure through the real engine rides the undeclared `failed[].issues`
9+
* key on the causal element (#10524's surface, deliberately not this
10+
* card's), so the unwind is pinned here with the promotion seam mocked and
11+
* a plain Error (no `issues`);
12+
* - the no-op TRACE — the `console.info` line is the no-op exit's only
13+
* record: the audit ledger stays silent on purpose (`sys_metadata_audit`
14+
* rows are keyed on `(type, name)`, and a batch with zero items has no
15+
* honest identity to mint — the limiting case of the rule both refusal
16+
* sites already follow). This file goes red if the line is dropped, and
17+
* pins that it does NOT fire on the exits that leave their own records;
18+
* - the zero-draft machinery edge — the one return whose `outcome` is not
19+
* fixed by the site it sits on: a transaction-machinery failure over an
20+
* EMPTY batch answers `nothing_to_publish` (nothing was pending, nothing
21+
* was refused; the unwind's `console.warn` stays the record of the
22+
* failure), which is what keeps the producer invariant
23+
* `outcome === 'refused'` iff `failed.length > 0` true on every return.
24+
*
25+
* Harness copied from
26+
* `packages/objectql/src/protocol-publish-package-drafts.test.ts` (the #8896
27+
* capture double) — copied, NOT imported: metadata-protocol cannot depend on
28+
* objectql, and each pin must be able to fail independently.
29+
*/
30+
import { describe, it, expect, vi, afterEach } from 'vitest';
31+
import { ObjectStackProtocolImplementation } from './protocol.js';
32+
33+
/**
34+
* [#8896] A real double for the two engine calls `publishPackageDrafts` makes
35+
* on its own — the ADR-0067 pre-publish CAPTURE read (`findOne`, answering an
36+
* explicit `null` for "no active row"), and the commit write (`insert`).
37+
*/
38+
function makeCaptureEngine() {
39+
const engine = {
40+
findOne: async (table: string, opts?: { where?: Record<string, unknown> }) => {
41+
void table; void opts;
42+
// Every artifact is new here: `null` is the truthful capture answer.
43+
return null;
44+
},
45+
insert: async (table: string) => ({ id: `${table}_1` }),
46+
};
47+
return engine;
48+
}
49+
50+
function makeProtocol(drafts: Array<{ type: string; name: string }>) {
51+
const protocol = new ObjectStackProtocolImplementation({} as never);
52+
(protocol as any).ensureOverlayIndex = async () => {};
53+
(protocol as any).getOverlayRepo = () => ({ listDrafts: async () => drafts });
54+
(protocol as any).engine = makeCaptureEngine();
55+
const promote = vi.spyOn(protocol as any, 'promoteDraftForPublish');
56+
const sideEffects = vi
57+
.spyOn(protocol as any, 'runPublishSideEffects')
58+
.mockResolvedValue({});
59+
const promoteOk = (req: any) => ({
60+
singularType: req.type,
61+
orgId: null,
62+
advisories: [],
63+
result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null },
64+
});
65+
return { protocol, promote, sideEffects, promoteOk };
66+
}
67+
68+
const NOOP_LINE = /\[Protocol\] publishPackageDrafts: nothing to publish/;
69+
70+
afterEach(() => vi.restoreAllMocks());
71+
72+
describe('[#10462] the Phase-1 unwind names its outcome', () => {
73+
it("a mid-batch promotion failure answers outcome 'refused' with the whole batch in failed[]", async () => {
74+
const { protocol, promote } = makeProtocol([
75+
{ type: 'view', name: 'cases' },
76+
{ type: 'view', name: 'leads' },
77+
]);
78+
// A plain Error, deliberately without `issues` — see the header.
79+
promote.mockImplementation(async (req: any) => {
80+
if (req.name === 'cases') throw new Error('promotion refused');
81+
return {
82+
singularType: req.type, orgId: null, advisories: [],
83+
result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null },
84+
};
85+
});
86+
87+
const res: any = await protocol.publishPackageDrafts({ packageId: 'app.edu' });
88+
89+
expect(res.success).toBe(false);
90+
expect(res.outcome).toBe('refused');
91+
expect(res.publishedCount).toBe(0);
92+
expect(res.published).toEqual([]);
93+
// ADR-0067 D2: the causal item plus its BATCH_ABORTED sibling.
94+
expect(res.failedCount).toBe(2);
95+
expect(res.failed.map((f: any) => f.name).sort()).toEqual(['cases', 'leads']);
96+
// The invariants hold at this site too (both directions).
97+
expect(res.outcome === 'refused').toBe(res.failed.length > 0);
98+
expect(res.success).toBe(res.outcome === 'published');
99+
});
100+
101+
it("a transaction-machinery failure over an EMPTY batch answers 'nothing_to_publish', keeping the invariant universal", async () => {
102+
const { protocol } = makeProtocol([]);
103+
(protocol as any).engine = {
104+
...makeCaptureEngine(),
105+
transaction: async () => { throw new Error('connection lost'); },
106+
};
107+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
108+
109+
const res: any = await protocol.publishPackageDrafts({ packageId: 'app.empty' });
110+
111+
// Truthful on both axes: nothing was pending, nothing was refused —
112+
// and `refused` with `failed: []` would break invariant (i).
113+
expect(res).toMatchObject({
114+
success: false, outcome: 'nothing_to_publish',
115+
publishedCount: 0, failedCount: 0, published: [], failed: [],
116+
});
117+
// The machinery failure is NOT silent: the unwind's warn is its record.
118+
expect(warn.mock.calls.some((c) => String(c[0]).includes('rolled back'))).toBe(true);
119+
});
120+
});
121+
122+
describe('[#10462] the no-op exit leaves a trace', () => {
123+
it('TRACE CONTROL: the no-op logs one info line naming the package and BOTH facts', async () => {
124+
const { protocol } = makeProtocol([]);
125+
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
126+
127+
const res: any = await protocol.publishPackageDrafts({ packageId: 'app.empty' });
128+
129+
expect(res.outcome).toBe('nothing_to_publish');
130+
const line = info.mock.calls.map((c) => String(c[0])).find((m) => NOOP_LINE.test(m));
131+
// Fails if the log line is dropped — the exit would be traceless again.
132+
expect(line).toBeDefined();
133+
// Names the packageId…
134+
expect(line).toContain("'app.empty'");
135+
// …and states the two facts `success: false` cannot carry alone:
136+
// nothing was pending, and nothing was refused.
137+
expect(line).toMatch(/no pending drafts/);
138+
expect(line).toMatch(/nothing was refused/);
139+
});
140+
141+
it('the trace is SPECIFIC to the no-op: a successful publish and a refusal do not emit it', async () => {
142+
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
143+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
144+
void warn;
145+
146+
const ok = makeProtocol([{ type: 'view', name: 'cases' }]);
147+
ok.promote.mockImplementation(async (req: any) => ok.promoteOk(req));
148+
const okRes: any = await ok.protocol.publishPackageDrafts({ packageId: 'app.edu' });
149+
expect(okRes.outcome).toBe('published');
150+
151+
const refused = makeProtocol([{ type: 'view', name: 'cases' }]);
152+
refused.promote.mockImplementation(async () => { throw new Error('promotion refused'); });
153+
const refusedRes: any = await refused.protocol.publishPackageDrafts({ packageId: 'app.edu' });
154+
expect(refusedRes.outcome).toBe('refused');
155+
156+
// Those two exits leave their own records (audit rows / warn) — the
157+
// info line belongs to the no-op alone.
158+
expect(info.mock.calls.map((c) => String(c[0])).some((m) => NOOP_LINE.test(m))).toBe(false);
159+
});
160+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15120,6 +15120,20 @@ export class ObjectStackProtocolImplementation implements
1512015120
aiModel?: string;
1512115121
}): Promise<{
1512215122
success: boolean;
15123+
/**
15124+
* [#10462] First-class discriminant for WHICH exit answered — the fact
15125+
* `success` alone cannot carry: a publish with nothing to promote and
15126+
* a genuine refusal both answer `success: false` (cloud#1488 graded
15127+
* the former as a rollback and burned two repair rounds on artifacts
15128+
* that were already correct). Producer invariants, pinned in the
15129+
* conformance suites: `outcome === 'refused'` if and only if
15130+
* `failed.length > 0`; `outcome === 'nothing_to_publish'` if and only
15131+
* if `published.length === 0 && failed.length === 0`; and
15132+
* `success === (outcome === 'published')` — `success` keeps its exact
15133+
* pre-#10462 value and is now derivable. Values are lowercase snake,
15134+
* matching the `sys_metadata_audit` outcome vocabulary.
15135+
*/
15136+
outcome: 'published' | 'refused' | 'nothing_to_publish';
1512315137
publishedCount: number;
1512415138
failedCount: number;
1512515139
published: Array<{
@@ -15413,6 +15427,10 @@ export class ObjectStackProtocolImplementation implements
1541315427
}
1541415428
return {
1541515429
success: false,
15430+
// [#10462] Guarded by `preflightViolations.length > 0`, so
15431+
// `failed[]` is non-empty by construction — this exit is
15432+
// always a refusal.
15433+
outcome: 'refused',
1541615434
publishedCount: 0,
1541715435
failedCount: preflightViolations.length,
1541815436
published: [],
@@ -15817,6 +15835,16 @@ export class ObjectStackProtocolImplementation implements
1581715835
});
1581815836
return {
1581915837
success: false,
15838+
// [#10462] `failedOut` mirrors `ordered` one-to-one, so it is
15839+
// empty ONLY when the batch had zero drafts — a failure in the
15840+
// transaction machinery itself over nothing pending. Deriving
15841+
// the outcome (rather than hard-coding 'refused') keeps the
15842+
// producer invariant `outcome === 'refused' iff
15843+
// failed.length > 0` true on every return site; on that edge
15844+
// "nothing to publish" stays the truthful answer (nothing was
15845+
// pending, nothing was refused) and the `console.warn` above
15846+
// remains the record of the machinery failure.
15847+
outcome: failedOut.length > 0 ? 'refused' : 'nothing_to_publish',
1582015848
publishedCount: 0,
1582115849
failedCount: failedOut.length,
1582215850
published: [],
@@ -15968,8 +15996,32 @@ export class ObjectStackProtocolImplementation implements
1596815996
// ADR-0067 D2 — the commit record was written INSIDE the Phase-1
1596915997
// transaction above, together with the promotions it describes.
1597015998

15999+
// [#10462] The no-op exit — a publish with nothing to promote — used
16000+
// to be the ONLY exit that left no trace at all: no audit row (right:
16001+
// `sys_metadata_audit` rows are keyed on `(type, name)`, and a batch
16002+
// with zero items has no honest identity to mint — the limiting case
16003+
// of the rule both refusal sites already follow) and no log line
16004+
// (wrong: an operator who reads `success: false` as a refusal goes
16005+
// looking for a rollback that never happened). `info`, not `warn`:
16006+
// nothing was claimed persisted and nothing was lost, so this is
16007+
// neither a durability nor a functional degradation.
16008+
if (published.length === 0 && failed.length === 0) {
16009+
console.info(
16010+
`[Protocol] publishPackageDrafts: nothing to publish for package `
16011+
+ `'${request.packageId}' — no pending drafts were found, and nothing was refused.`,
16012+
);
16013+
}
16014+
1597116015
return {
1597216016
success: failed.length === 0 && published.length > 0,
16017+
// [#10462] Derived, never stored: 'refused' the moment anything is
16018+
// in `failed[]` (defensive — every failure on this route unwinds
16019+
// through the Phase-1 catch above today), else 'published' iff
16020+
// something landed, else the no-op. Exactly the three-way fact
16021+
// `success` compresses into one boolean.
16022+
outcome: failed.length > 0
16023+
? 'refused'
16024+
: published.length > 0 ? 'published' : 'nothing_to_publish',
1597316025
publishedCount: published.length,
1597416026
failedCount: failed.length,
1597516027
published,

0 commit comments

Comments
 (0)