Skip to content

Commit 09a6eee

Browse files
os-steveclaude
andauthored
feat(spec,metadata-protocol): publish door reports the runtime authoring gate's advisories (#9344)
* feat(spec,metadata-protocol): publish door reports the runtime authoring gate's advisories (#9176) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP * chore(spec): regenerate reference docs for the publish-door advisories describe (#9176) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a5adba commit 09a6eee

7 files changed

Lines changed: 334 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
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.

content/docs/references/api/protocol.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,7 @@ List packages response
12111211
| **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. |
12121212
| **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`. |
12131213
| **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. |
1214+
| **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. |
12141215
| **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. |
12151216

12161217

@@ -1389,7 +1390,7 @@ List packages response
13891390
| **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. |
13901391
| **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. |
13911392
| **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. |
1392-
| **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. |
1393+
| **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). |
13931394
| **message** | `string` | optional | |
13941395

13951396

packages/metadata-protocol/src/protocol.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3801,10 +3801,12 @@ export class ObjectStackProtocolImplementation implements
38013801
*
38023802
* [#4717] Throws on the gating half, RETURNS the advisory half. Advisories
38033803
* do not block anything, so the only honest place for them is the 2xx the
3804-
* write earns — `saveMetaItem` attaches them to its response, and the only
3805-
* other caller (the draft→active promotion in `publishMetaItem`) simply
3806-
* ignores the value, which is why adding this channel could not change what
3807-
* either door does.
3804+
* write earns — `saveMetaItem` attaches them to its response, and since
3805+
* #9176 the draft→active promotion does the same: `promoteDraftForPublish`
3806+
* hands the findings out and `publishMetaItem` attaches them, so both
3807+
* write doors report what D1 makes both of them measure. (The batch route
3808+
* `publishPackageDrafts` still discards its per-draft findings — its
3809+
* response face is a different contract.)
38083810
* Returns an empty array on every early return: no rules ran, so there is
38093811
* nothing to report, and "clean" is told apart from "nothing ran" by the
38103812
* gate's own `rulesRun`, not by this.
@@ -13653,6 +13655,15 @@ export class ObjectStackProtocolImplementation implements
1365313655
* closed on the spec side.
1365413656
*/
1365513657
projectionApplied?: MutationProjectionOutcome;
13658+
/**
13659+
* [#9176] Present ONLY when the #4463 runtime authoring gate raised at
13660+
* least one non-blocking finding against the draft body this promotion
13661+
* carried to `active` — an empty array is never emitted, so a clean
13662+
* publish's response bytes are unchanged (the #4717 discipline, one
13663+
* door over). Every entry is `warning`/`info` by construction: an
13664+
* `error` finding refuses the promotion as the 422 instead.
13665+
*/
13666+
advisories?: RuntimeAuthoringIssue[];
1365613667
}> {
1365713668
// #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This is the
1365813669
// SEVENTH `/meta` entry point, and until #8769 it was the only one that
@@ -13736,7 +13747,7 @@ export class ObjectStackProtocolImplementation implements
1373613747
// site has no transaction of its own, so recording it in the `catch` is
1373713748
// where it always effectively landed; what changed is that the helper no
1373813749
// longer assumes that on behalf of the batch route too.
13739-
const { singularType, orgId, result } = await this.promoteDraftForPublish(request)
13750+
const { singularType, orgId, advisories, result } = await this.promoteDraftForPublish(request)
1374013751
.catch(async (err: unknown) => {
1374113752
await this.recordPendingDenialAudit(err);
1374213753
throw err;
@@ -13779,11 +13790,17 @@ export class ObjectStackProtocolImplementation implements
1377913790
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] };
1378013791
materializeApplied?: PublishMaterializeResult;
1378113792
projectionApplied?: MutationProjectionOutcome;
13793+
advisories?: RuntimeAuthoringIssue[];
1378213794
} = {
1378313795
success: true,
1378413796
version: result.version,
1378513797
seq: result.seq,
1378613798
message: `Published draft — type=${request.type}, name=${request.name} [seq=${result.seq}]`,
13799+
// [#9176] Omitted-when-empty, never `advisories: []` — a clean
13800+
// publish's response bytes are unchanged, and absence means
13801+
// "nothing to report", never "the gate did not run" (#4717's
13802+
// ruling, point 2, carried to this door).
13803+
...(advisories.length > 0 ? { advisories } : {}),
1378713804
};
1378813805
const effects = await this.runPublishSideEffects({
1378913806
singularType,
@@ -13852,6 +13869,17 @@ export class ObjectStackProtocolImplementation implements
1385213869
}): Promise<{
1385313870
singularType: string;
1385413871
orgId: string | null;
13872+
/**
13873+
* [#9176] The #4463 gate's advisory half for this promotion — the
13874+
* non-blocking findings `assertRuntimeAuthoringRules` RETURNS (its
13875+
* gating half throws before this method resolves). Empty when the
13876+
* gate raised nothing or did not run (no draft, package-author
13877+
* channel); `publishMetaItem` attaches it to its response only when
13878+
* non-empty, exactly as `saveMetaItem` does one door over. The batch
13879+
* caller (`publishPackageDrafts`) deliberately does not read it —
13880+
* its response face is a different contract.
13881+
*/
13882+
advisories: RuntimeAuthoringIssue[];
1385513883
result: { version: string; seq: number; item: MetadataItem; packageId: string | null };
1385613884
}> {
1385713885
const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type;
@@ -13909,8 +13937,13 @@ export class ObjectStackProtocolImplementation implements
1390913937
{ type: singularType, name: request.name, org: orgId ?? 'env' } as Parameters<typeof repo.get>[0],
1391013938
{ state: 'draft' },
1391113939
);
13912-
if (draftForGate) {
13913-
this.assertRuntimeAuthoringRules({
13940+
// [#9176] The gate's return is its advisory half (#4717): captured and
13941+
// handed out so `publishMetaItem` can attach it to the 2xx this
13942+
// promotion is about to earn, exactly as `saveMetaItem` attaches its
13943+
// own. Held in a local, never on `this` — the gate is per-write and
13944+
// two concurrent publishes must not read each other's findings.
13945+
const runtimeAdvisories: RuntimeAuthoringIssue[] = draftForGate
13946+
? this.assertRuntimeAuthoringRules({
1391413947
type: singularType,
1391513948
name: request.name,
1391613949
state: 'active',
@@ -13919,8 +13952,8 @@ export class ObjectStackProtocolImplementation implements
1391913952
// it the draft door would be a bypass for this refusal alone,
1392013953
// which is the exact hole #4463 D1 closed for the other 26.
1392113954
organizationId: orgId,
13922-
});
13923-
}
13955+
})
13956+
: [];
1392413957

1392513958
const artifactBacked = this.isArtifactBacked(singularType, request.name);
1392613959
const intent: 'override-artifact' | 'runtime-only' = artifactBacked
@@ -13943,7 +13976,7 @@ export class ObjectStackProtocolImplementation implements
1394313976
// present-and-undefined for the historical resolution to hold.
1394413977
...('packageId' in request ? { packageId: request.packageId ?? null } : {}),
1394513978
});
13946-
return { singularType, orgId, result };
13979+
return { singularType, orgId, advisories: runtimeAdvisories, result };
1394713980
} catch (err: any) {
1394813981
if (err instanceof ConflictError) {
1394913982
const conflict: any = new Error(

0 commit comments

Comments
 (0)