Skip to content

Commit a79bd35

Browse files
Elon Muskclaude
andauthored
Publish refusals: declare failed[].issues + seedApplied.issues, then trim error to a headline (#10895)
* fix(metadata-protocol): publish refusals state a headline; the prose rides the declared issues[] channel Declare-then-trim (#10524): PublishPackageDraftsResponseSchema's failed[] element declares the issues[] the producer has emitted since #8333, and seedApplied declares the seed refusal's issues[]; then the #4463 gate's 422 message and seedRequestValidationError's message shrink to one-sentence headlines (count + path [rule|code] locators) instead of restating the prose the structured channel carries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * chore: changeset + regenerated spec artifacts for #10524 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 145ba75 commit a79bd35

13 files changed

Lines changed: 540 additions & 25 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
Publish refusals no longer render each validation finding twice (#10524) — declare-then-trim.
8+
9+
**Declared (spec, additive):** `PublishPackageDraftsResponseSchema.failed[]` elements now
10+
declare `issues[]` (the `RuntimeAuthoringIssueSchema` findings the producer has emitted
11+
since #8333 but no declared parse could carry), and `seedApplied` declares `issues[]`
12+
(`{ path, message, code? }`, the seed-body schema refusal's findings). Typed consumers —
13+
the SDK's `PublishPackageDraftsResponse`, any `parse` through the schema — can now read
14+
the structured findings back instead of having them silently stripped.
15+
16+
**Trimmed (producers):** the #4463 author-time gate's 422 message and
17+
`seedRequestValidationError`'s message are one-sentence headlines — total count plus up to
18+
three `path [rule]` / `path [zod-code]` locators — instead of restating the issue prose
19+
that `issues[]` carries on the same response. Consumers that render only `error` (CLI,
20+
logs) keep what failed, where, under which rule, and how many; consumers that render both
21+
channels stop repeating themselves. The old `(+N more)` tail is subsumed by the leading
22+
count. Both catches that surface the seed refusal onto `seedApplied` now thread the
23+
structured findings beside the headline.
24+
25+
Error `code`/`status` vocabularies, `advisories`, the DESTRUCTIVE_CHANGE (409) message,
26+
and `saveMetaItem`'s spec-validation 422 message are unchanged. Messages are not contract
27+
(the machine-readable channels are `code` and `issues[]`), so this is not a breaking
28+
change and registers no migration.

content/docs/references/api/protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1248,7 +1248,7 @@ List packages response
12481248
| **publishedCount** | `integer` || Number of drafts promoted to active — `published.length`. 0 on every refusal path (the batch is all-or-nothing, ADR-0067 D2). |
12491249
| **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. |
12501250
| **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` || Every draft promoted to active, in publish order. Empty on every refusal path. |
1251-
| **failed** | `{ type: string; name: string; error: string; code?: string }[]` || Items that did not publish. Because the batch is all-or-nothing (ADR-0067 D2), a non-empty list means NOTHING landed: `published: []`, `publishedCount: 0`. |
1251+
| **failed** | `{ type: string; name: string; error: string; code?: string; … }[]` || Items that did not publish. Because the batch is all-or-nothing (ADR-0067 D2), a non-empty list means NOTHING landed: `published: []`, `publishedCount: 0`. |
12521252
| **seedApplied** | `{ success: boolean; inserted?: integer; updated?: integer; error?: string; … }` | optional | Aggregate outcome of materializing EVERY published `seed` body in one multi-pass loader run (cross-seed references need the whole set). Present ONLY when the batch published at least one seed. Two producers, one key: the batch itself self-applies (`applySeedBodies`), and the REST door back-fills the same key for custom protocols that do not — never both (an externalId-less seed would double-insert). Best-effort: a seed problem is surfaced here, never thrown. |
12531253
| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `permission``sys_permission_set`), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. |
12541254
| **probes** | `any` | optional | ADR-0038 L3 post-publish runtime probe report — one real read per published artifact (seeded objects have rows, views are readable, widget dataset selections execute). DELIBERATELY OPAQUE in this contract (#9406): the key is declared and carried through verbatim, but its inner shape is intentionally not modeled until a consumer needs a field of it. Present only when something was publishable; probes never fail the publish. |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ directory rather than per file.
257257
| Dir | Sites |
258258
|---|---|
259259
| `ai/` | 77 |
260-
| `api/` | 406 |
260+
| `api/` | 407 |
261261
| `cloud/` | 83 |
262262
| `identity/` | 32 |
263263
| `integration/` | 10 |

packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,14 @@ describe('publishPackageDrafts judges each draft against the BATCH closure (#103
380380
const causal = res.failed.find((f) => f.name === 'customer_dashboard')!;
381381
expect(causal.code).toBe('INVALID_METADATA');
382382
expect(causal.error).toMatch(/widget-dataset-unknown/);
383-
expect(causal.error).toMatch(/no_such_dataset_xyz/);
383+
// [#10524] `error` is a headline now (path + rule locators); the
384+
// dataset NAME lives in the finding's message, once, on the
385+
// structured channel the batch response declares (`failed[].issues`).
386+
const unknownDs = (causal as any).issues.find(
387+
(i: any) => i.rule === 'widget-dataset-unknown',
388+
);
389+
expect(unknownDs.message).toMatch(/no_such_dataset_xyz/);
390+
expect(causal.error).not.toContain(unknownDs.message);
384391
// ADR-0067 D2 — all-or-nothing: the healthy sibling is aborted, not
385392
// published around the refusal.
386393
expect(res.failed.find((f) => f.name === 'shyx_customer_ds')?.code).toBe('BATCH_ABORTED');

packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,16 +441,20 @@ describe('[#8441] [GUARD] the Studio publish surface still gets the code it bran
441441
// WHICH DRAFT.
442442
expect(failure.type).toBe('flow');
443443
expect(failure.name).toBe('leave_approval');
444-
// WHICH FIELD — the located path, in the human sentence (#8333's half).
444+
// WHICH FIELD — the located path, in the human sentence (#8333's
445+
// half). [#10524] The sentence is a HEADLINE: path and rule id stay
446+
// in it, and the message prose lives once, in `issues[]` below.
445447
expect(failure.error).toContain('flows[0].nodes[1].config.approvers[0].value');
446-
expect(failure.error).toContain('does not parse as CEL');
448+
expect(failure.error).toContain('[approval-expression-invalid]');
449+
expect(failure.error).not.toContain('does not parse as CEL');
447450
// …and the machine-readable halves the form highlights with. `code` is
448451
// THIS card's field: catalogued, so it passes through byte for byte.
449452
expect(failure.code).toBe('INVALID_METADATA');
450453
expectCataloged(failure.code);
451454
expect(Array.isArray(failure.issues)).toBe(true);
452455
expect(failure.issues[0].path).toBe('flows[0].nodes[1].config.approvers[0].value');
453456
expect(failure.issues[0].rule).toBe('approval-expression-invalid');
457+
expect(failure.issues[0].message).toMatch(/does not parse as CEL/);
454458
});
455459
});
456460

packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,9 +594,16 @@ describe('[#8333] [GUARD] a spec-validation failure on the publish path still na
594594
// WHICH DRAFT.
595595
expect(failure.type).toBe('flow');
596596
expect(failure.name).toBe('leave_approval');
597-
// WHICH FIELD — the located path, in the human sentence.
597+
// WHICH FIELD — the located path, in the human sentence. [#10524]
598+
// The sentence is a HEADLINE now: it keeps the path and the rule id
599+
// (this pin's guarded property — the withhold must not blank WHICH
600+
// FIELD of WHICH DRAFT) while the message prose lives once, in
601+
// `issues[]` below, instead of being restated here — every console
602+
// rendering both channels was showing each finding twice.
598603
expect(failure.error).toContain('flows[0].nodes[1].config.approvers[0].value');
599-
expect(failure.error).toContain('does not parse as CEL');
604+
expect(failure.error).toContain('[approval-expression-invalid]');
605+
expect(failure.error).not.toContain('does not parse as CEL');
606+
expect(failure.issues[0].message).toMatch(/does not parse as CEL/);
600607
// …and the machine-readable halves the Studio form highlights with.
601608
expect(failure.code).toBe('INVALID_METADATA');
602609
expect(Array.isArray(failure.issues)).toBe(true);
@@ -632,6 +639,19 @@ describe('[#8333] the seed request’s schema rejection DECLARES itself, so the
632639
// multi-line stringified `ZodError`, which is why this is evidence.
633640
expect(r.error).toContain('seeds.0.mode');
634641
expect(r.error).not.toContain('"code":');
642+
643+
// [#10524] The message is a HEADLINE (count + `path [zod code]`
644+
// locators); the curated per-key prose rides the receipt ONCE,
645+
// structurally, on `issues[]` — which is what lets the sentence stop
646+
// restating it without the author losing anything. Only the declared
647+
// 422 threads this key; the driver-fault cases below stay issue-less.
648+
expect(Array.isArray(r.issues)).toBe(true);
649+
const modeIssue = r.issues!.find((i: any) => i.path === 'seeds.0.mode');
650+
expect(modeIssue).toBeDefined();
651+
expect(typeof modeIssue!.message).toBe('string');
652+
for (const i of r.issues!) {
653+
expect(r.error).not.toContain(i.message);
654+
}
635655
});
636656

637657
it('the unreadable-bodies guard is untouched — a different fact, a different sentence', async () => {

packages/metadata-protocol/src/protocol.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,8 +2046,10 @@ function clientFacingFailureCode(err: unknown): string | undefined {
20462046
*
20472047
* The author is also strictly better off: the old path stringified a whole
20482048
* `ZodError`, so `seedApplied.error` was a multi-line JSON dump of raw zod
2049-
* internals. This is the curated summary {@link zodIssuesToMetadataIssues}
2050-
* already produces for every other authoring surface.
2049+
* internals. The curated findings {@link zodIssuesToMetadataIssues} produces
2050+
* for every other authoring surface ride this error's `issues` — surfaced on
2051+
* `seedApplied.issues` by the catches (#10524) — and the message is their
2052+
* one-sentence headline.
20512053
*
20522054
* [#8443] EXPORTED alongside {@link clientFacingFailureText}: the runtime
20532055
* package-publish door parses the SAME `SeedLoaderRequestSchema` in its own
@@ -2059,19 +2061,40 @@ function clientFacingFailureCode(err: unknown): string | undefined {
20592061
*/
20602062
export function seedRequestValidationError(zodIssues: unknown): Error {
20612063
const issues = zodIssuesToMetadataIssues(zodIssues);
2062-
const summary = issues.slice(0, 3)
2063-
.map((i: { path: string; message: string }) => `${i.path || '<root>'}: ${i.message}`)
2064-
.join('; ');
2064+
// [#10524] `message` is a HEADLINE — count plus `path [zod code]`
2065+
// locators — never a restatement of the issue prose: the same `issues`
2066+
// array rides the error structurally, and the catches that surface this
2067+
// refusal thread it onto `seedApplied.issues` beside the headline, so
2068+
// the author's curated per-key prose still arrives exactly once.
20652069
const err = new Error(
2066-
`[invalid_metadata] the published seed bodies failed spec validation: ${summary}`
2067-
+ (issues.length > 3 ? ` (+${issues.length - 3} more)` : ''),
2070+
`[invalid_metadata] the published seed bodies failed spec validation: `
2071+
+ metadataIssueHeadline(issues),
20682072
);
20692073
(err as any).code = 'INVALID_METADATA';
20702074
(err as any).status = 422;
20712075
(err as any).issues = issues;
20722076
return err;
20732077
}
20742078

2079+
/**
2080+
* [#10524] The one-sentence headline for a refusal whose per-path detail
2081+
* rides `issues[]` structurally: total count plus up to three
2082+
* `path [zod code]` locators. Restating the issue MESSAGES here is exactly
2083+
* the duplication #10524 removed — every console rendering both channels
2084+
* showed each finding twice — so the message names WHERE and HOW MANY and
2085+
* leaves the prose to the structured channel. The leading count subsumes the
2086+
* old `(+N more)` tail. The author-time gate composes its own analogue with
2087+
* `[rule]` locators (`runtime-authoring-gate.ts`), deliberately: rule ids
2088+
* and zod codes are different vocabularies and folding them into one helper
2089+
* would blur which one a reader is looking at.
2090+
*/
2091+
function metadataIssueHeadline(issues: MetadataIssueEntry[]): string {
2092+
const locators = issues.slice(0, 3)
2093+
.map((i) => `${i.path || '<root>'}${i.code ? ` [${i.code}]` : ''}`)
2094+
.join('; ');
2095+
return `${issues.length} issue${issues.length === 1 ? '' : 's'} — ${locators}`;
2096+
}
2097+
20752098
/**
20762099
* A batch row that names no record id for an operation that needs one — a
20772100
* caller error, so it carries VALIDATION_FAILED / 400 rather than falling
@@ -13304,6 +13327,19 @@ export class ObjectStackProtocolImplementation implements
1330413327
const parsed = schema.safeParse(request.item);
1330513328
if (!parsed.success) {
1330613329
const issues = zodIssuesToMetadataIssues(parsed.error.issues);
13330+
// [#10524] Deliberately NOT trimmed to the headline the
13331+
// author-time gate and `seedRequestValidationError` now
13332+
// compose, although this is the same duplication shape on
13333+
// the 422 envelope face (message prose + `details.issues`).
13334+
// Measured during that card: this message is quoted on
13335+
// faces where it is the SOLE carrier — `duplicatePackage`'s
13336+
// `failed[].error` threads no `issues`, and three #8333
13337+
// GUARD pins hold the author's prescription ("Unrecognized
13338+
// key(s) …", the `defineView(` spelling) to it. Trimming
13339+
// here without first declaring a structured channel on
13340+
// those faces deletes the prescription from the wire —
13341+
// the declare-then-trim order, violated. Filed as its own
13342+
// card; see the #10524 PR for the measurement.
1330713343
const summary = issues.slice(0, 3)
1330813344
.map((i: { path: string; message: string }) => `${i.path || '<root>'}: ${i.message}`)
1330913345
.join('; ');
@@ -14838,7 +14874,10 @@ export class ObjectStackProtocolImplementation implements
1483814874
private async applySeedBodies(
1483914875
bodies: unknown[],
1484014876
organizationId: string | null,
14841-
): Promise<{ success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }> {
14877+
): Promise<{
14878+
success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[];
14879+
issues?: Array<{ path: string; message: string; code?: string | undefined }>;
14880+
}> {
1484214881
try {
1484314882
const seeds = bodies.filter(
1484414883
(b: any) => b && typeof b.object === 'string' && Array.isArray(b.records),
@@ -14905,6 +14944,17 @@ export class ObjectStackProtocolImplementation implements
1490514944
return {
1490614945
success: false, inserted: 0, updated: 0,
1490714946
error: clientFacingFailureText(e, 'seed apply failed'),
14947+
// [#10524] A DECLARED refusal's structured findings ride the
14948+
// receipt beside the headline `error` — the message is a
14949+
// one-sentence headline now, so this is where the per-key
14950+
// prose reaches the author. Guarded by the same declaration
14951+
// test as the text above: only the declared 422
14952+
// (`seedRequestValidationError`) attaches `issues`; no driver
14953+
// error carries them (the #8441 measurement), so nothing
14954+
// undeclared is routed around the withhold.
14955+
...(declaresClientRefusal(e) && Array.isArray(e?.issues)
14956+
? { issues: e.issues }
14957+
: {}),
1490814958
};
1490914959
}
1491014960
}

packages/metadata-protocol/src/runtime-authoring-gate.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -635,11 +635,30 @@ export function evaluateRuntimeAuthoringGate(args: {
635635
if (result.errors.length === 0 && localIssues.length === 0) return { error: null, advisories };
636636

637637
const issues = [...result.errors.map(toIssue), ...localIssues];
638-
const summary = issues
638+
// [#10524] Two renderings of one array, two audiences — deliberately NOT
639+
// one string:
640+
//
641+
// - `detail` is the WHOLE refusal — path, rule and message prose — and
642+
// goes only where no structured channel exists: the operator's
643+
// un-deduped hatch warn below (#4463 acceptance).
644+
// - the thrown 422's `message` is `headline`: what failed, where, which
645+
// rules, how many. Every wire face the message lands on carries the
646+
// SAME `issues` array structurally (`error.details.issues` on the
647+
// single-item 422, `failed[].issues` on the batch response), so
648+
// restating the issue prose in the message made every console render
649+
// each finding twice — the summary-then-bullets duplication this trim
650+
// removes. The leading count subsumes the old `(+N more)` tail; the
651+
// prose lives once, in `issues[]`.
652+
const locators = issues
639653
.slice(0, 3)
640-
.map((i) => `${i.path || i.where || '<root>'}: [${i.rule}] ${i.message}`)
654+
.map((i) => `${i.path || i.where || '<root>'} [${i.rule}]`)
641655
.join('; ');
642-
const detail = summary + (issues.length > 3 ? ` (+${issues.length - 3} more)` : '');
656+
const headline = `${issues.length} issue${issues.length === 1 ? '' : 's'}${locators}`;
657+
const detail = issues
658+
.slice(0, 3)
659+
.map((i) => `${i.path || i.where || '<root>'}: [${i.rule}] ${i.message}`)
660+
.join('; ')
661+
+ (issues.length > 3 ? ` (+${issues.length - 3} more)` : '');
643662
// The registry's own disclosure, plus the gate-local rule when it was
644663
// applicable to this type. `rulesRun` exists so a caller can tell "clean"
645664
// from "nothing ran"; a judgement that can refuse a write and never appears
@@ -668,7 +687,7 @@ export function evaluateRuntimeAuthoringGate(args: {
668687
}
669688

670689
const err = new Error(
671-
`[invalid_metadata] ${args.type}/${args.name} failed author-time validation: ${detail}`,
690+
`[invalid_metadata] ${args.type}/${args.name} failed author-time validation: ${headline}`,
672691
);
673692
(err as any).code = 'INVALID_METADATA';
674693
(err as any).status = 422;

0 commit comments

Comments
 (0)