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
6 changes: 6 additions & 0 deletions .changeset/publish-door-advisories.md
Original file line numberDiff line numberDiff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/api/protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |


Expand DownExpand Up@@ -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 | |


Expand Down
53 changes: 43 additions & 10 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -13924,8 +13952,13 @@ export class ObjectStackProtocolImplementation implements
{ type: singularType, name: request.name, org: orgId ?? 'env' } as Parameters<typeof repo.get>[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',
Expand All@@ -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
Expand All@@ -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(
Expand Down
Loading
Loading