Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/publish-error-headline-issues.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-protocol': patch
'@objectstack/runtime': patch
---

Publish refusals no longer render each validation finding twice (#10524) — declare-then-trim.

**Declared (spec, additive):** `PublishPackageDraftsResponseSchema.failed[]` elements now
declare `issues[]` (the `RuntimeAuthoringIssueSchema` findings the producer has emitted
since #8333 but no declared parse could carry), and `seedApplied` declares `issues[]`
(`{ path, message, code? }`, the seed-body schema refusal's findings). Typed consumers —
the SDK's `PublishPackageDraftsResponse`, any `parse` through the schema — can now read
the structured findings back instead of having them silently stripped.

**Trimmed (producers):** the #4463 author-time gate's 422 message and
`seedRequestValidationError`'s message are one-sentence headlines — total count plus up to
three `path [rule]` / `path [zod-code]` locators — instead of restating the issue prose
that `issues[]` carries on the same response. Consumers that render only `error` (CLI,
logs) keep what failed, where, under which rule, and how many; consumers that render both
channels stop repeating themselves. The old `(+N more)` tail is subsumed by the leading
count. Both catches that surface the seed refusal onto `seedApplied` now thread the
structured findings beside the headline.

Error `code`/`status` vocabularies, `advisories`, the DESTRUCTIVE_CHANGE (409) message,
and `saveMetaItem`'s spec-validation 422 message are unchanged. Messages are not contract
(the machine-readable channels are `code` and `issues[]`), so this is not a breaking
change and registers no migration.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1248,7 +1248,7 @@ List packages response
| **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. |
| **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`. |
| **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`. |
| **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. |
| **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`. |
| **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. |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -257,7 +257,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 77 |
| `api/` | 406 |
| `api/` | 407 |
| `cloud/` | 83 |
| `identity/` | 32 |
| `integration/` | 10 |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -380,7 +380,14 @@ describe('publishPackageDrafts judges each draft against the BATCH closure (#103
const causal = res.failed.find((f) => f.name === 'customer_dashboard')!;
expect(causal.code).toBe('INVALID_METADATA');
expect(causal.error).toMatch(/widget-dataset-unknown/);
expect(causal.error).toMatch(/no_such_dataset_xyz/);
// [#10524] `error` is a headline now (path + rule locators); the
// dataset NAME lives in the finding's message, once, on the
// structured channel the batch response declares (`failed[].issues`).
const unknownDs = (causal as any).issues.find(
(i: any) => i.rule === 'widget-dataset-unknown',
);
expect(unknownDs.message).toMatch(/no_such_dataset_xyz/);
expect(causal.error).not.toContain(unknownDs.message);
// ADR-0067 D2 — all-or-nothing: the healthy sibling is aborted, not
// published around the refusal.
expect(res.failed.find((f) => f.name === 'shyx_customer_ds')?.code).toBe('BATCH_ABORTED');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,16 +441,20 @@ describe('[#8441] [GUARD] the Studio publish surface still gets the code it bran
// WHICH DRAFT.
expect(failure.type).toBe('flow');
expect(failure.name).toBe('leave_approval');
// WHICH FIELD — the located path, in the human sentence (#8333's half).
// WHICH FIELD — the located path, in the human sentence (#8333's
// half). [#10524] The sentence is a HEADLINE: path and rule id stay
// in it, and the message prose lives once, in `issues[]` below.
expect(failure.error).toContain('flows[0].nodes[1].config.approvers[0].value');
expect(failure.error).toContain('does not parse as CEL');
expect(failure.error).toContain('[approval-expression-invalid]');
expect(failure.error).not.toContain('does not parse as CEL');
// …and the machine-readable halves the form highlights with. `code` is
// THIS card's field: catalogued, so it passes through byte for byte.
expect(failure.code).toBe('INVALID_METADATA');
expectCataloged(failure.code);
expect(Array.isArray(failure.issues)).toBe(true);
expect(failure.issues[0].path).toBe('flows[0].nodes[1].config.approvers[0].value');
expect(failure.issues[0].rule).toBe('approval-expression-invalid');
expect(failure.issues[0].message).toMatch(/does not parse as CEL/);
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -594,9 +594,16 @@ describe('[#8333] [GUARD] a spec-validation failure on the publish path still na
// WHICH DRAFT.
expect(failure.type).toBe('flow');
expect(failure.name).toBe('leave_approval');
// WHICH FIELD — the located path, in the human sentence.
// WHICH FIELD — the located path, in the human sentence. [#10524]
// The sentence is a HEADLINE now: it keeps the path and the rule id
// (this pin's guarded property — the withhold must not blank WHICH
// FIELD of WHICH DRAFT) while the message prose lives once, in
// `issues[]` below, instead of being restated here — every console
// rendering both channels was showing each finding twice.
expect(failure.error).toContain('flows[0].nodes[1].config.approvers[0].value');
expect(failure.error).toContain('does not parse as CEL');
expect(failure.error).toContain('[approval-expression-invalid]');
expect(failure.error).not.toContain('does not parse as CEL');
expect(failure.issues[0].message).toMatch(/does not parse as CEL/);
// …and the machine-readable halves the Studio form highlights with.
expect(failure.code).toBe('INVALID_METADATA');
expect(Array.isArray(failure.issues)).toBe(true);
Expand DownExpand Up@@ -632,6 +639,19 @@ describe('[#8333] the seed request’s schema rejection DECLARES itself, so the
// multi-line stringified `ZodError`, which is why this is evidence.
expect(r.error).toContain('seeds.0.mode');
expect(r.error).not.toContain('"code":');

// [#10524] The message is a HEADLINE (count + `path [zod code]`
// locators); the curated per-key prose rides the receipt ONCE,
// structurally, on `issues[]` — which is what lets the sentence stop
// restating it without the author losing anything. Only the declared
// 422 threads this key; the driver-fault cases below stay issue-less.
expect(Array.isArray(r.issues)).toBe(true);
const modeIssue = r.issues!.find((i: any) => i.path === 'seeds.0.mode');
expect(modeIssue).toBeDefined();
expect(typeof modeIssue!.message).toBe('string');
for (const i of r.issues!) {
expect(r.error).not.toContain(i.message);
}
});

it('the unreadable-bodies guard is untouched — a different fact, a different sentence', async () => {
Expand Down
66 changes: 58 additions & 8 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2046,8 +2046,10 @@ function clientFacingFailureCode(err: unknown): string | undefined {
*
* The author is also strictly better off: the old path stringified a whole
* `ZodError`, so `seedApplied.error` was a multi-line JSON dump of raw zod
* internals. This is the curated summary {@link zodIssuesToMetadataIssues}
* already produces for every other authoring surface.
* internals. The curated findings {@link zodIssuesToMetadataIssues} produces
* for every other authoring surface ride this error's `issues` — surfaced on
* `seedApplied.issues` by the catches (#10524) — and the message is their
* one-sentence headline.
*
* [#8443] EXPORTED alongside {@link clientFacingFailureText}: the runtime
* package-publish door parses the SAME `SeedLoaderRequestSchema` in its own
Expand All@@ -2059,19 +2061,40 @@ function clientFacingFailureCode(err: unknown): string | undefined {
*/
export function seedRequestValidationError(zodIssues: unknown): Error {
const issues = zodIssuesToMetadataIssues(zodIssues);
const summary = issues.slice(0, 3)
.map((i: { path: string; message: string }) => `${i.path || '<root>'}: ${i.message}`)
.join('; ');
// [#10524] `message` is a HEADLINE — count plus `path [zod code]`
// locators — never a restatement of the issue prose: the same `issues`
// array rides the error structurally, and the catches that surface this
// refusal thread it onto `seedApplied.issues` beside the headline, so
// the author's curated per-key prose still arrives exactly once.
const err = new Error(
`[invalid_metadata] the published seed bodies failed spec validation: ${summary}`
+ (issues.length > 3 ? ` (+${issues.length - 3} more)` : ''),
`[invalid_metadata] the published seed bodies failed spec validation: `
+ metadataIssueHeadline(issues),
);
(err as any).code = 'INVALID_METADATA';
(err as any).status = 422;
(err as any).issues = issues;
return err;
}

/**
* [#10524] The one-sentence headline for a refusal whose per-path detail
* rides `issues[]` structurally: total count plus up to three
* `path [zod code]` locators. Restating the issue MESSAGES here is exactly
* the duplication #10524 removed — every console rendering both channels
* showed each finding twice — so the message names WHERE and HOW MANY and
* leaves the prose to the structured channel. The leading count subsumes the
* old `(+N more)` tail. The author-time gate composes its own analogue with
* `[rule]` locators (`runtime-authoring-gate.ts`), deliberately: rule ids
* and zod codes are different vocabularies and folding them into one helper
* would blur which one a reader is looking at.
*/
function metadataIssueHeadline(issues: MetadataIssueEntry[]): string {
const locators = issues.slice(0, 3)
.map((i) => `${i.path || '<root>'}${i.code ? ` [${i.code}]` : ''}`)
.join('; ');
return `${issues.length} issue${issues.length === 1 ? '' : 's'} — ${locators}`;
}

/**
* A batch row that names no record id for an operation that needs one — a
* caller error, so it carries VALIDATION_FAILED / 400 rather than falling
Expand DownExpand Up@@ -13304,6 +13327,19 @@ export class ObjectStackProtocolImplementation implements
const parsed = schema.safeParse(request.item);
if (!parsed.success) {
const issues = zodIssuesToMetadataIssues(parsed.error.issues);
// [#10524] Deliberately NOT trimmed to the headline the
// author-time gate and `seedRequestValidationError` now
// compose, although this is the same duplication shape on
// the 422 envelope face (message prose + `details.issues`).
// Measured during that card: this message is quoted on
// faces where it is the SOLE carrier — `duplicatePackage`'s
// `failed[].error` threads no `issues`, and three #8333
// GUARD pins hold the author's prescription ("Unrecognized
// key(s) …", the `defineView(` spelling) to it. Trimming
// here without first declaring a structured channel on
// those faces deletes the prescription from the wire —
// the declare-then-trim order, violated. Filed as its own
// card; see the #10524 PR for the measurement.
const summary = issues.slice(0, 3)
.map((i: { path: string; message: string }) => `${i.path || '<root>'}: ${i.message}`)
.join('; ');
Expand DownExpand Up@@ -14838,7 +14874,10 @@ export class ObjectStackProtocolImplementation implements
private async applySeedBodies(
bodies: unknown[],
organizationId: string | null,
): Promise<{ success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }> {
): Promise<{
success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[];
issues?: Array<{ path: string; message: string; code?: string | undefined }>;
}> {
try {
const seeds = bodies.filter(
(b: any) => b && typeof b.object === 'string' && Array.isArray(b.records),
Expand DownExpand Up@@ -14905,6 +14944,17 @@ export class ObjectStackProtocolImplementation implements
return {
success: false, inserted: 0, updated: 0,
error: clientFacingFailureText(e, 'seed apply failed'),
// [#10524] A DECLARED refusal's structured findings ride the
// receipt beside the headline `error` — the message is a
// one-sentence headline now, so this is where the per-key
// prose reaches the author. Guarded by the same declaration
// test as the text above: only the declared 422
// (`seedRequestValidationError`) attaches `issues`; no driver
// error carries them (the #8441 measurement), so nothing
// undeclared is routed around the withhold.
...(declaresClientRefusal(e) && Array.isArray(e?.issues)
? { issues: e.issues }
: {}),
};
}
}
Expand Down
27 changes: 23 additions & 4 deletions packages/metadata-protocol/src/runtime-authoring-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -635,11 +635,30 @@ export function evaluateRuntimeAuthoringGate(args: {
if (result.errors.length === 0 && localIssues.length === 0) return { error: null, advisories };

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

const err = new Error(
`[invalid_metadata] ${args.type}/${args.name} failed author-time validation: ${detail}`,
`[invalid_metadata] ${args.type}/${args.name} failed author-time validation: ${headline}`,
);
(err as any).code = 'INVALID_METADATA';
(err as any).status = 422;
Expand Down
Loading
Loading