diff --git a/.changeset/publish-door-advisories.md b/.changeset/publish-door-advisories.md new file mode 100644 index 0000000000..2b5d669160 --- /dev/null +++ b/.changeset/publish-door-advisories.md @@ -0,0 +1,6 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +The publish door now reports the runtime authoring gate's advisory findings (#9176). `POST /api/v1/meta/:type/:name/publish` carries the same optional, omitted-when-empty `advisories` key the save door already carries (#4463 D1/D3, #4717): `PublishMetaItemResponseSchema` declares it (`RuntimeAuthoringIssueSchema` elements, declared once in `@objectstack/spec`), and `publishMetaItem` attaches the findings the promotion-time gate run returns instead of discarding them. A clean publish's response bytes are unchanged — the key is present only when at least one `warning`/`info` finding was raised; `error` findings still refuse the promotion as the 422 envelope. This matters most for Studio / MCP / AI authors, whose designer takes draft-then-publish on every edit and has no CLI to surface the same findings. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a828099a0c..a29481d641 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1211,6 +1211,7 @@ List packages response | **seedApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string; … }` | optional | Outcome of materializing a published `seed` body into data rows. Present ONLY when the published type is `seed` — publishing a seed is what makes its rows live, so the load rides along with the metadata promotion. Best-effort: a seed-load problem is surfaced here, never thrown, so a caller must check `seedApplied.success` instead of assuming the 200 covered the data. Absent on the batch path, which suppresses the per-item apply and loads every seed body in one later pass. | | **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that projects the published body into its data-plane row (e.g. `permission` → `sys_permission_set`, under the owning package). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model. The same receipt `{@link SaveMetaItemResponseSchema}` carries, because the projector runs on BOTH write doors: a direct active save and this draft→active promotion. Present ONLY when a projector is registered for this metadata type. Best-effort — a projector failure is reported here and logged, never thrown. | +| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the #4463 runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to the DRAFT body this promotion carried to `active` (#9176, the same key `SaveMetaItemResponseSchema` carries, because the gate runs on both write doors by #4463 D1). The promotion SUCCEEDED; these are what the gate has to say about it anyway. Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean publish's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the promotion and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. This door is the one Studio's designer takes on every edit (draft save, then publish), and a Studio / MCP / AI author has no CLI at all — which is the gap #4463 exists to close. | | **message** | `string` | optional | Human-readable receipt, e.g. `Published draft — type=view, name=cases [seq=3]`. The producer sets it on every publish today; it stays optional to match the producer's own signature and its `SaveMetaItemResponse` twin, and because an absent human-readable string strips no data — the failure mode #5745 exists to prevent. | @@ -1389,7 +1390,7 @@ List packages response | **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `permission` → `sys_permission_set`). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | -| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the #4463 runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (#4717, closing #4463 D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap #4463 exists to close. NOTE the door asymmetry — `POST /meta/:type/:name/publish` does not carry this field yet (its declaration landed separately as #7294); the gate runs on both doors, only the save door reports. | +| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the #4463 runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (#4717, closing #4463 D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap #4463 exists to close. The gate runs on both write doors (#4463 D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema` (#9176). | | **message** | `string` | optional | | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fa9cbafe2e..86f68263a2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3816,10 +3816,12 @@ export class ObjectStackProtocolImplementation implements * * [#4717] Throws on the gating half, RETURNS the advisory half. Advisories * do not block anything, so the only honest place for them is the 2xx the - * write earns — `saveMetaItem` attaches them to its response, and the only - * other caller (the draft→active promotion in `publishMetaItem`) simply - * ignores the value, which is why adding this channel could not change what - * either door does. + * write earns — `saveMetaItem` attaches them to its response, and since + * #9176 the draft→active promotion does the same: `promoteDraftForPublish` + * hands the findings out and `publishMetaItem` attaches them, so both + * write doors report what D1 makes both of them measure. (The batch route + * `publishPackageDrafts` still discards its per-draft findings — its + * response face is a different contract.) * Returns an empty array on every early return: no rules ran, so there is * nothing to report, and "clean" is told apart from "nothing ran" by the * gate's own `rulesRun`, not by this. @@ -13668,6 +13670,15 @@ export class ObjectStackProtocolImplementation implements * closed on the spec side. */ projectionApplied?: MutationProjectionOutcome; + /** + * [#9176] Present ONLY when the #4463 runtime authoring gate raised at + * least one non-blocking finding against the draft body this promotion + * carried to `active` — an empty array is never emitted, so a clean + * publish's response bytes are unchanged (the #4717 discipline, one + * door over). Every entry is `warning`/`info` by construction: an + * `error` finding refuses the promotion as the 422 instead. + */ + advisories?: RuntimeAuthoringIssue[]; }> { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This is the // SEVENTH `/meta` entry point, and until #8769 it was the only one that @@ -13751,7 +13762,7 @@ export class ObjectStackProtocolImplementation implements // site has no transaction of its own, so recording it in the `catch` is // where it always effectively landed; what changed is that the helper no // longer assumes that on behalf of the batch route too. - const { singularType, orgId, result } = await this.promoteDraftForPublish(request) + const { singularType, orgId, advisories, result } = await this.promoteDraftForPublish(request) .catch(async (err: unknown) => { await this.recordPendingDenialAudit(err); throw err; @@ -13794,11 +13805,17 @@ export class ObjectStackProtocolImplementation implements seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] }; materializeApplied?: PublishMaterializeResult; projectionApplied?: MutationProjectionOutcome; + advisories?: RuntimeAuthoringIssue[]; } = { success: true, version: result.version, seq: result.seq, message: `Published draft — type=${request.type}, name=${request.name} [seq=${result.seq}]`, + // [#9176] Omitted-when-empty, never `advisories: []` — a clean + // publish's response bytes are unchanged, and absence means + // "nothing to report", never "the gate did not run" (#4717's + // ruling, point 2, carried to this door). + ...(advisories.length > 0 ? { advisories } : {}), }; const effects = await this.runPublishSideEffects({ singularType, @@ -13867,6 +13884,17 @@ export class ObjectStackProtocolImplementation implements }): Promise<{ singularType: string; orgId: string | null; + /** + * [#9176] The #4463 gate's advisory half for this promotion — the + * non-blocking findings `assertRuntimeAuthoringRules` RETURNS (its + * gating half throws before this method resolves). Empty when the + * gate raised nothing or did not run (no draft, package-author + * channel); `publishMetaItem` attaches it to its response only when + * non-empty, exactly as `saveMetaItem` does one door over. The batch + * caller (`publishPackageDrafts`) deliberately does not read it — + * its response face is a different contract. + */ + advisories: RuntimeAuthoringIssue[]; result: { version: string; seq: number; item: MetadataItem; packageId: string | null }; }> { const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type; @@ -13924,8 +13952,13 @@ export class ObjectStackProtocolImplementation implements { type: singularType, name: request.name, org: orgId ?? 'env' } as Parameters[0], { state: 'draft' }, ); - if (draftForGate) { - this.assertRuntimeAuthoringRules({ + // [#9176] The gate's return is its advisory half (#4717): captured and + // handed out so `publishMetaItem` can attach it to the 2xx this + // promotion is about to earn, exactly as `saveMetaItem` attaches its + // own. Held in a local, never on `this` — the gate is per-write and + // two concurrent publishes must not read each other's findings. + const runtimeAdvisories: RuntimeAuthoringIssue[] = draftForGate + ? this.assertRuntimeAuthoringRules({ type: singularType, name: request.name, state: 'active', @@ -13934,8 +13967,8 @@ export class ObjectStackProtocolImplementation implements // it the draft door would be a bypass for this refusal alone, // which is the exact hole #4463 D1 closed for the other 26. organizationId: orgId, - }); - } + }) + : []; const artifactBacked = this.isArtifactBacked(singularType, request.name); const intent: 'override-artifact' | 'runtime-only' = artifactBacked @@ -13958,7 +13991,7 @@ export class ObjectStackProtocolImplementation implements // present-and-undefined for the historical resolution to hold. ...('packageId' in request ? { packageId: request.packageId ?? null } : {}), }); - return { singularType, orgId, result }; + return { singularType, orgId, advisories: runtimeAdvisories, result }; } catch (err: any) { if (err instanceof ConflictError) { const conflict: any = new Error( diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index 9cbfd42cb4..a1396f5a2f 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -349,3 +349,186 @@ describe('publishMetaItem response conforms to PublishMetaItemResponseSchema (#7 ).rejects.toMatchObject({ status: 403 }); }); }); + +/** + * #9176 — the conditional `advisories` field on the PUBLISH door, both + * directions. The mirror of the #4717 block in + * `save-meta-response-conformance.test.ts`, which is the stated precedent: + * the #4463 gate runs on BOTH write doors (D1 — a draft→active promotion is + * gated exactly as a direct active save), and until #9176 only the save door + * put the advisory half on its response; the promotion call site received the + * gate's return and discarded it. Studio's designer takes draft-then-publish + * on every edit, so the discarded half was the one its authors would see. + * + * These drive the SHIPPED rule registry (`@objectstack/lint`'s + * `AUTHORING_RULES`, filtered to the runtime surface) through the shipped + * `publishMetaItem`. Nothing here is stubbed: the finding below is produced by + * `lintFlowPatterns`, the same rule `os lint` runs, reached through the same + * `evaluateRuntimeAuthoringGate` a Studio publish reaches. + */ +describe('publishMetaItem carries the runtime authoring gate\'s advisories (#9176 — #4463 D3)', () => { + /** + * The reachable success-with-advisories case, verbatim from the save + * door's #4717 measurement: a nightly sweep whose ONLY defect is a + * `delete_record` node declaring `multi: true` with no `filter`. + * `lintFlowPatterns` raises `flow-multi-write-unfiltered` at + * `severity: 'warning'`, so the gate's `errors` set is empty, the + * promotion is NOT refused, and before #9176 the finding was computed at + * the promotion call site and thrown away. + * + * `runAs: 'system'` is load-bearing, not decoration: without it + * `flow-runas-unscoped` fires at `severity: 'error'` and the publish + * becomes a 422 — a refusal wearing an advisory's clothes, which would + * make this case pass for the wrong reason. + * + * Env-wide (`org: null`), not org-scoped: the registry declares + * `allowOrgOverride: false` for `flow`, so an org-scoped draft is refused + * 403 NOT_OVERRIDABLE long before the authoring gate runs. + */ + const advisoryFlow = () => ({ + name: 'nightly_purge', + label: 'Nightly Purge', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'purge', + type: 'delete_record', + label: 'Purge', + config: { objectName: 'audit_logs', multi: true }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'purge' }], + }); + + /** The same flow with the bulk write bounded — no finding of any severity. */ + const cleanFlow = () => { + const flow = advisoryFlow(); + (flow.nodes[1] as any).config.filter = [{ field: 'created_at', operator: 'lt', value: '2020-01-01' }]; + return flow; + }; + + it('a publish whose draft\'s only defect is advisory succeeds AND reports the finding', async () => { + const p = await makeProtocol(); + const raw: any = await stageAndPublish(p, { + type: 'flow', name: 'nightly_purge', item: advisoryFlow(), org: null, + }); + + // The promotion succeeded — this rides a 2xx, which is the whole + // point of the advisory half. (The gate throws on its gating half, so + // reaching this line at all is the "still a 200" assertion; the REST + // route maps a clean return to 200 and a throw to its `status`.) + expect(raw.success).toBe(true); + + // The finding reached the caller, with the id and severity the rule + // emits. Asserting the RULE ID rather than just a non-empty array: an + // array of the wrong findings is a different defect from an empty one. + expect(raw.advisories).toHaveLength(1); + expect(raw.advisories[0].rule).toBe('flow-multi-write-unfiltered'); + expect(raw.advisories[0].severity).toBe('warning'); + expect(raw.advisories[0].where).toContain('nightly_purge'); + + // …and it survives the DECLARED parse. This is the assertion that + // goes red if `PublishMetaItemResponseSchema.advisories` is removed, + // or if either side renames the key: a plain `z.object` STRIPS what + // it does not declare, so the field would vanish silently rather + // than fail. + expect(strippedKeys(raw)).toEqual([]); + const parsed = PublishMetaItemResponseSchema.parse(raw); + expect(parsed.advisories).toEqual(raw.advisories); + + // Every element key the declaration promises is really carried — a + // narrower element schema would strip inside the array without + // changing its length. + expect(Object.keys(parsed.advisories![0]!).sort()) + .toEqual(['hint', 'message', 'path', 'rule', 'severity', 'where']); + }); + + /** + * The constraint most likely to be broken by a well-meaning refactor: + * "zero advisories must not change the response bytes" (the #4717 ruling, + * point 2, carried to this door). Emitting `advisories: []` would satisfy + * every OTHER assertion in this file — the spec declares the key, so + * nothing is stripped and `strippedKeys` stays `[]` — which is exactly + * why this has to be pinned on the raw object's KEY SET and not through + * the parse. + */ + it('zero advisories changes nothing: the key is absent, not empty', async () => { + const p = await makeProtocol(); + const raw: any = await stageAndPublish(p, { + type: 'flow', name: 'bounded_purge', item: cleanFlow(), org: null, + }); + + expect(raw.success).toBe(true); + expect('advisories' in raw).toBe(false); + expect(raw.advisories).toBeUndefined(); + // Byte-for-byte: the serialized response of a clean publish carries + // no trace of the field. `JSON.stringify` is the wire, and the wire + // is the promise being made to existing callers. + expect(JSON.stringify(raw)).not.toContain('advisories'); + expect(PublishMetaItemResponseSchema.safeParse(raw).success).toBe(true); + }); + + /** + * The gated half is unchanged, and it is asserted on its ENVELOPE — `code` + * AND `status` — not on the bare fact of a throw. A `toThrow()` here would + * prove nothing: only the envelope can tell the refusal apart from any + * other failure, and the refusal is what must NOT have moved when the + * success path grew a channel. The draft SAVE succeeds (D1 — drafts are + * never gated); the PROMOTION is where the gate refuses. + */ + it('the gating half still refuses the promotion with the 422 envelope, unchanged', async () => { + const p = await makeProtocol(); + const brokenApproval = { + name: 'leave_approval', + label: 'Leave Approval', + type: 'autolaunched', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'approve', + type: 'approval', + label: 'Approve', + config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'approve' }], + }; + + // Staging the draft succeeds — a draft save is never gated (D1). + await (p as any).saveMetaItem({ + type: 'flow', name: 'leave_approval', item: brokenApproval, mode: 'draft', + }); + // Promoting it is where the gate runs, and it refuses with the same + // envelope the save door's direct-active refusal carries. + await expect( + (p as any).publishMetaItem({ type: 'flow', name: 'leave_approval' }), + ).rejects.toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + }); + + /** + * ⚠️ GUARD, NOT EVIDENCE — green in BOTH directions, and labelled so on + * purpose (the save door's #4717 dispatch asked for this variant to be + * MEASURED rather than reasoned about, and the measurement carries over). + * + * The pre-existing conformance cases above publish clean `view` bodies, + * for which the gate raises nothing, so the conditional key is absent and + * `strippedKeys` is `[]` whether or not the spec declares `advisories`. + * This case states that fact as an assertion so the next reader does not + * mistake those cases' green for coverage of the new field: the existing + * gate CANNOT go red to report a missing or misspelled declaration, which + * is precisely why the two directional cases above exist. + */ + it('GUARD (green either way): a clean view publish is untouched by the new field', async () => { + const p = await makeProtocol(); + const raw: any = await stageAndPublish(p); + + expect('advisories' in raw).toBe(false); + expect(strippedKeys(raw)).toEqual([]); + expect(Object.keys(raw).sort()).toEqual(['message', 'seq', 'success', 'version']); + }); +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 8224ca2bc9..ef6345722f 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1259,6 +1259,7 @@ "api/PresignedUrlResponse:error", "api/PresignedUrlResponse:meta", "api/PresignedUrlResponse:success", + "api/PublishMetaItemResponse:advisories", "api/PublishMetaItemResponse:materializeApplied", "api/PublishMetaItemResponse:message", "api/PublishMetaItemResponse:projectionApplied", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index e62a089e74..cafdbb49e9 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -919,6 +919,74 @@ describe('SaveMetaItemResponseSchema.advisories (#4717 — #4463 D3 on the respo }); }); +/** + * #9176 — `advisories` on the PUBLISH response: the same #4463 D3 key, mirrored + * onto the other write door. The gate runs on both doors by D1 (a draft→active + * promotion is gated exactly as a direct active save), but until #9176 only the + * save door reported — the promotion call site received the gate's advisory + * return and discarded it. Studio's designer takes draft-then-publish on every + * edit, so the door its authors actually use was the one that said nothing. + * + * Same conditional-field caveat as the save door: the producer-side conformance + * gate (`packages/objectql/src/publish-meta-response-conformance.test.ts`) + * asserts nothing is stripped, and an absent key strips nothing — so these + * declaration pins are what make the key checkable at all, in both directions. + */ +describe('PublishMetaItemResponseSchema.advisories (#9176 — #4463 D3 on the publish door)', () => { + const realPublishResponse = { + success: true, + version: 'sha256:7aad99c8d969efb5067fff275fb3e5be7ec90f9cd610d41709fcddbf8c34b1f0', + seq: 2, + message: 'Published draft — type=flow, name=nightly_purge [seq=2]', + }; + + /** A verbatim capture of a real finding — `lintFlowPatterns`, warning tier. */ + const advisory = { + rule: 'flow-multi-write-unfiltered', + path: 'flow \'nightly_purge\' · node \'purge\' (delete_record)', + where: 'flow \'nightly_purge\' · node \'purge\' (delete_record)', + message: 'declares `multi: true` with no `filter` key — this is a WHOLE-OBJECT write.', + hint: 'Add a `filter`, or state the whole-object intent explicitly.', + severity: 'warning' as const, + }; + + it('carries a real advisory through parse without stripping it', () => { + const parsed = PublishMetaItemResponseSchema.parse({ ...realPublishResponse, advisories: [advisory] }); + expect(parsed.advisories).toEqual([advisory]); + }); + + it('is OPTIONAL — absence means "nothing to report", never "the gate did not run"', () => { + expect(PublishMetaItemResponseSchema.safeParse(realPublishResponse).success).toBe(true); + expect(PublishMetaItemResponseSchema.parse(realPublishResponse).advisories).toBeUndefined(); + }); + + it('does not fabricate an empty array when the key is absent', () => { + // The producer omits the key rather than emitting `[]`, so a clean + // publish's response bytes are unchanged. A `.default([])` here would + // quietly undo that on the consumer side: every caller would see a key + // the server never sent, and "absent" would stop being distinguishable. + expect('advisories' in PublishMetaItemResponseSchema.parse(realPublishResponse)).toBe(false); + }); + + it('rejects a non-array, so a single issue object cannot masquerade as the list', () => { + expect( + PublishMetaItemResponseSchema.safeParse({ ...realPublishResponse, advisories: advisory }).success, + ).toBe(false); + }); + + it('element shape is the ONE declared finding shape — a lossy element is refused, not narrowed', () => { + // The element schema is `RuntimeAuthoringIssueSchema` by reference, not a + // local re-declaration — so an element missing a required key fails the + // whole parse rather than surviving with keys silently dropped. This is + // what keeps the save door's `advisories[]`, the publish door's, and the + // 422 `issues[]` one dialect (#4717). + const partial = { rule: 'x', severity: 'warning' }; + expect( + PublishMetaItemResponseSchema.safeParse({ ...realPublishResponse, advisories: [partial] }).success, + ).toBe(false); + }); +}); + /** * The element shape itself — declared once (#4717) and re-exported by * `@objectstack/metadata-protocol` as its `RuntimeAuthoringIssue`, so the 422's diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index e0b5058a7e..b1af72d5c5 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -539,10 +539,10 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ + '422 `invalid_metadata` envelope instead of here. A caller that ignores ' + 'this key behaves exactly as before. Runtime-only: the CLI surfaces the ' + 'same findings on its own stdout, and a Studio / MCP / AI author has no ' - + 'CLI at all, which is the gap #4463 exists to close. NOTE the door ' - + 'asymmetry — `POST /meta/:type/:name/publish` does not carry this field ' - + 'yet (its declaration landed separately as #7294); the gate runs on both ' - + 'doors, only the save door reports.', + + 'CLI at all, which is the gap #4463 exists to close. The gate runs on ' + + 'both write doors (#4463 D1), and both report: ' + + '`POST /meta/:type/:name/publish` carries the same key on ' + + '`PublishMetaItemResponseSchema` (#9176).', ), message: z.string().optional(), })); @@ -584,6 +584,15 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ * than thrown. So `success: true` on the envelope does not mean the data plane * caught up — a caller that needs it live must read the receipt's own * `success`, which is why every receipt carries one. + * + * **`advisories` is the fourth conditional key** (#9176, mirroring #4717 one + * door over): the #4463 runtime authoring gate runs on BOTH write doors by + * D1 — a draft→active promotion is gated exactly as a direct active save — + * and its non-blocking findings ride the 2xx of whichever door earned it. + * Until #9176 only the save door reported; the promotion call site received + * the gate's advisory return and discarded it, which mattered precisely + * because Studio's designer takes draft-then-publish on every edit, so the + * one door its authors actually use was the one that said nothing. */ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ success: z.boolean().describe( @@ -652,6 +661,24 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ + 'this metadata type. Best-effort — a projector failure is reported here ' + 'and logged, never thrown.', ), + advisories: z.array(RuntimeAuthoringIssueSchema).optional().describe( + 'Non-gating findings from the #4463 runtime authoring gate — the same ' + + 'shared author-time rules `os validate` / `os build` / `os lint` run, ' + + 'applied to the DRAFT body this promotion carried to `active` (#9176, ' + + 'the same key `SaveMetaItemResponseSchema` carries, because the gate ' + + 'runs on both write doors by #4463 D1). The promotion SUCCEEDED; these ' + + 'are what the gate has to say about it anyway. Present ONLY when at ' + + 'least one advisory was raised — an empty array is never emitted, so a ' + + 'clean publish\'s response bytes are unchanged and absence means ' + + '"nothing to report", never "the gate did not run". Advisory by ' + + 'construction: every entry has `severity` `warning` or `info`, because ' + + 'an `error` finding refuses the promotion and arrives as the 422 ' + + '`invalid_metadata` envelope instead of here. A caller that ignores ' + + 'this key behaves exactly as before. This door is the one Studio\'s ' + + 'designer takes on every edit (draft save, then publish), and a Studio ' + + '/ MCP / AI author has no CLI at all — which is the gap #4463 exists ' + + 'to close.', + ), message: z.string().optional().describe( 'Human-readable receipt, e.g. `Published draft — type=view, name=cases ' + '[seq=3]`. The producer sets it on every publish today; it stays optional '