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
15 changes: 15 additions & 0 deletions .changeset/26707-draft-diagnostics-publish-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): a draft's `_diagnostics` reports what its publish will refuse (#26707)

Clause-②: no (the draft verdict reports more; no accepted value is refused)
Metadata-protocol: none

A draft the runtime authoring gate refuses at publish (a flow edge whose condition does not parse, an API-triggered flow with no secret, an RLS predicate over a field the database cannot filter, a summary field with no operations, a dashboard global filter on an unknown field, a dataset `include` that names an unknown relationship) used to read `_diagnostics.valid: true` on the draft read, so the editors and the Problems tab called it valid and the refusal arrived only at Publish, where one such draft rolls back the whole package release.

- **One rule set.** The draft read (`GET /api/v1/meta/:type/:name?state=draft`, and the `previewDrafts` read of a pending draft) now runs the same runtime authoring gate the publish runs, on the stored draft body, with the same package scope, the same pending in-package drafts (so a reference to a sibling draft the same Publish-all lands is not an error) and the same restored credential paths. Each refusal is added to `_diagnostics.errors` with the gate's rule id as `code` and the publish's own `message`, and `valid` becomes `false`. ⛔ No rule is restated.
- **The draft still saves.** No draft save is refused; publish refuses exactly what it refused before; package publish keeps its atomic rollback.
- **Paths.** A gate path inside the item (`objects.invoice.fields.total.summaryOperations`) is served item-relative (`fields.total.summaryOperations`); a gate path that names a place rather than a key (a flow edge) is served as the gate wrote it.
- **Not covered.** The save-door refusals (they refuse the save itself, so no stored draft carries them), the plugin authoring gates and the package build probes that `publish-drafts` runs, and the list reads and the diagnostics sweep, which keep their read-time verdicts.
63 changes: 63 additions & 0 deletions packages/metadata-protocol/src/metadata-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ import { zodIssuesToMetadataIssues } from './protocol.js';
// landed by #8300). Composed into `decorateMetadataItem` below — see the
// ordering note there for why it is not applied per read exit.
import { redactMetadataItem } from './metadata-redaction.js';
// [#26707] The snapshot key a write type's findings are spelled under — the
// runtime gate's own answer, so the publish verdict below is re-spelled
// against the same prefix the gate wrote it with.
import { stackKeyForType } from '@objectstack/lint/runtime';
import type { RuntimeAuthoringIssue } from '@objectstack/spec/api';

/**
* Re-export the canonical validation-result type so callers in this
Expand Down Expand Up @@ -316,6 +321,64 @@ export function decorateMetadataItems<T>(type: string, items: T[]): T[] {
return items.map((item) => decorateMetadataItem(type, item));
}

// ---------------------------------------------------------------------------
// [#26707] The publish verdict on a draft's `_diagnostics`
// ---------------------------------------------------------------------------

/**
* A runtime-gate finding's path, re-spelled relative to the item it was found
* on: the gate spells it inside its private per-write snapshot
* (`flows[0].edges[1].condition`, `objects.invoice.fields.total`), and
* `_diagnostics` paths are dotted and item-relative (`edges.1.condition`), the
* spelling Studio's inline errors and canvas badges resolve. A path that does
* not start at the written item (a finding located on a sibling, or a `where`-
* style locator) is kept as the gate wrote it.
*/
function itemRelativeIssuePath(type: string, name: string, path: string): string {
const stackKey = stackKeyForType(PLURAL_TO_SINGULAR[type] ?? type);
if (!stackKey) return path;
let rest: string | undefined;
for (const prefix of [`${stackKey}[0]`, `${stackKey}.${name}`]) {
if (path === prefix || path.startsWith(`${prefix}.`) || path.startsWith(`${prefix}[`)) {
rest = path.slice(prefix.length);
break;
}
}
if (rest === undefined) return path;
return rest.replace(/\[(\d+)\]/g, '.$1').replace(/^\./, '');
}

/**
* Fold what publishing a draft would refuse into the draft's `_diagnostics`.
*
* `issues` are the runtime authoring gate's refusal for the draft body —
* `ObjectStackProtocolImplementation#draftPublishVerdict`, the judgement the
* publish door enforces — and each becomes one `errors[]` entry carrying the
* publish's own `message`, with its `rule` id as the entry's `code`. Any of
* them makes the verdict `valid: false`: the draft is still stored, but it does
* not say it is valid while the publish says it is not. No issues: the schema
* verdict is returned as it was. A type with no schema verdict (`undefined`)
* gets one only when there is something to report.
*/
export function withPublishVerdict(
type: string,
name: string,
diagnostics: MetadataDiagnostics | undefined,
issues: readonly RuntimeAuthoringIssue[],
): MetadataDiagnostics | undefined {
if (issues.length === 0) return diagnostics;
const refusals = issues.map((issue) => ({
path: itemRelativeIssuePath(type, name, issue.path),
message: issue.message,
code: issue.rule,
}));
return {
...diagnostics,
valid: false,
errors: [...(diagnostics?.errors ?? []), ...refusals],
};
}

// ---------------------------------------------------------------------------
// ADR-0047 — reference-integrity diagnostics for list views
// ---------------------------------------------------------------------------
Expand Down
93 changes: 78 additions & 15 deletions packages/metadata-protocol/src/protocol-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
import type { ObjectStackProtocolImplementation } from './protocol.js';
import { permissionSetOwnerBindingRefusal, runtimeHookWithoutBodyRefusal } from './protocol.js';
import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js';
import type { RuntimeAuthoringIssue, RuntimePendingDeclarations } from './runtime-authoring-gate.js';
import type { RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js';
import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
Expand All @@ -67,11 +67,13 @@ export interface AuthoringGateHost {
assertRuntimeAuthoringRules: ObjectStackProtocolImplementation['assertRuntimeAuthoringRules'];
readonly authoringChannel: ObjectStackProtocolImplementation['authoringChannel'];
readonly authoringGates: ObjectStackProtocolImplementation['authoringGates'];
collectBatchPendingDeclarations: ObjectStackProtocolImplementation['collectBatchPendingDeclarations'];
credentialChannelFor: ObjectStackProtocolImplementation['credentialChannelFor'];
defaultRecordPageSaveRefusal: ObjectStackProtocolImplementation['defaultRecordPageSaveRefusal'];
readonly engine: ObjectStackProtocolImplementation['engine'];
externalBindingSaveRefusal: ObjectStackProtocolImplementation['externalBindingSaveRefusal'];
foldStoredCollection: ObjectStackProtocolImplementation['foldStoredCollection'];
getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo'];
hierarchyResolverMounted: ObjectStackProtocolImplementation['hierarchyResolverMounted'];
isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked'];
orgWallEnforced: ObjectStackProtocolImplementation['orgWallEnforced'];
Expand Down Expand Up @@ -167,9 +169,33 @@ export async function runAuthoringGate(
});
}

/**
* The members {@link judgeRuntimeAuthoringRules} reads: the host facts and
* collections every judgement of the shared rules is made against.
*/
type RuntimeRulesHost = Pick<AuthoringGateHost, 'authoringChannel' | 'engine' | 'foldStoredCollection' | 'hierarchyResolverMounted' | 'orgWallEnforced' | 'resolveSduiManifest' | 'resolveWritePackageScope' | 'rlsMembershipResolverKeys' | 'tenancyPostureInForce'>;

/** The body of `ObjectStackProtocolImplementation#assertRuntimeAuthoringRules`; the method's docblock there is its contract. */
export async function assertRuntimeAuthoringRules(
this: Pick<AuthoringGateHost, 'authoringChannel' | 'engine' | 'foldStoredCollection' | 'hierarchyResolverMounted' | 'orgWallEnforced' | 'resolveSduiManifest' | 'resolveWritePackageScope' | 'rlsMembershipResolverKeys' | 'tenancyPostureInForce'>,
this: RuntimeRulesHost,
evt: Parameters<typeof judgeRuntimeAuthoringRules>[0],
): Promise<RuntimeAuthoringIssue[]> {
const verdict = await judgeRuntimeAuthoringRules.call(this, evt);
if (verdict.error) throw verdict.error;
return verdict.advisories;
}

/**
* [#26707] The judgement {@link assertRuntimeAuthoringRules} enforces, handed
* back instead of thrown: the gate's {@link RuntimeAuthoringVerdict}, its
* refusal (`error`) beside its advisories. The assert above is this function
* plus the throw, so a caller that only needs to KNOW the verdict — the draft
* read's publish verdict ({@link draftPublishVerdict}) — reads the same
* collections, the same host facts and the same early returns as the publish
* that enforces it, and there is no second account of either.
*/
export async function judgeRuntimeAuthoringRules(
this: RuntimeRulesHost,
evt: {
type: string; name: string; state: 'draft' | 'active'; body: unknown; source?: string;
/**
Expand Down Expand Up @@ -211,7 +237,8 @@ export async function assertRuntimeAuthoringRules(
* round trip for a secret it still holds.
*/
restoredCredentialPaths?: () => Promise<readonly string[]>;
}): Promise<RuntimeAuthoringIssue[]> {
}): Promise<RuntimeAuthoringVerdict> {
const clean: RuntimeAuthoringVerdict = { error: null, advisories: [] };
// [#6710] The ADR-0005 carve-out, now DECLARED instead of inferred.
//
// This line used to read `if (this.environmentId === undefined)
Expand Down Expand Up @@ -250,21 +277,24 @@ export async function assertRuntimeAuthoringRules(
// #6710 re-keyed this activation and left that one on the retired
// proxy, which cost the ADR-0090 D11 object posture gate every
// host-config deployment until #7674 finished the move.
if (this.authoringChannel === 'package-author') return [];
if (this.authoringChannel === 'package-author') return clean;
if (evt.state !== 'active') {
// [#26188] D1: no rule gates a draft. The one finding a draft does hear
// is a hierarchy scope this runtime cannot enforce, as a warning on the
// 2xx; it needs no collection read, so the draft keeps paying none.
// A `migrate-stored` pass heals storage and hears nothing (see below).
if (evt.source === 'migrate-stored') return [];
if (evt.source === 'migrate-stored') return clean;
const hierarchyResolverMounted = this.hierarchyResolverMounted();
return evaluateRuntimeAuthoringGate({
type: PLURAL_TO_SINGULAR[evt.type] ?? evt.type,
name: evt.name,
state: evt.state,
body: evt.body,
...(hierarchyResolverMounted !== undefined ? { hierarchyResolverMounted } : {}),
}).advisories;
return {
error: null,
advisories: evaluateRuntimeAuthoringGate({
type: PLURAL_TO_SINGULAR[evt.type] ?? evt.type,
name: evt.name,
state: evt.state,
body: evt.body,
...(hierarchyResolverMounted !== undefined ? { hierarchyResolverMounted } : {}),
}).advisories,
};
}
// `os migrate meta --stored --apply` rewrites rows that ALREADY EXIST
// into the current dialect. It is not an author publishing anything —
Expand All @@ -280,7 +310,7 @@ export async function assertRuntimeAuthoringRules(
// no caller can spell its way past the gate. `duplicatePackage` is
// deliberately NOT here — it mints brand-new rows under new names, and
// a copy of a broken flow is a new broken flow.
if (evt.source === 'migrate-stored') return [];
if (evt.source === 'migrate-stored') return clean;
const singular = PLURAL_TO_SINGULAR[evt.type] ?? evt.type;

// Resolution context. Best-effort: a host without a registry (a
Expand Down Expand Up @@ -420,6 +450,39 @@ export async function assertRuntimeAuthoringRules(
// [#26188] With it the gate refuses a hierarchy scope no resolver enforces.
...(hierarchyResolverMounted !== undefined ? { hierarchyResolverMounted } : {}),
});
if (verdict.error) throw verdict.error;
return verdict.advisories;
return verdict;
}

/** The body of `ObjectStackProtocolImplementation#draftPublishVerdict`; the method's docblock there is its contract. */
export async function draftPublishVerdict(
this: RuntimeRulesHost & Pick<AuthoringGateHost, 'collectBatchPendingDeclarations' | 'credentialChannelFor' | 'getOverlayRepo'>,
draft: { type: string; name: string; body: unknown; packageId: string | null },
): Promise<RuntimeAuthoringIssue[]> {
const singular = PLURAL_TO_SINGULAR[draft.type] ?? draft.type;
// The closure "Publish all" judges this draft against: its package's other
// drafts, collected by the batch door's own collector from the batch door's
// own listing. A package-unbound draft has no batch; its publish is the
// per-item door's, which states none.
let pending: RuntimePendingDeclarations | undefined;
if (draft.packageId) {
const repo = this.getOverlayRepo();
if (typeof repo.listDrafts === 'function') {
pending = await this.collectBatchPendingDeclarations(await repo.listDrafts({ packageId: draft.packageId }));
}
}
// The promotion's restored-credential read: the stored draft holds no
// credential the write-only channel holds, so the gate is told where one is.
const channel = this.credentialChannelFor(singular);
const verdict = await judgeRuntimeAuthoringRules.call(this, {
type: singular,
name: draft.name,
state: 'active',
body: draft.body,
packageId: draft.packageId,
...(pending !== undefined ? { pending } : {}),
...(channel
? { restoredCredentialPaths: () => channel.heldPaths({ name: draft.name, state: 'draft', item: draft.body }) }
: {}),
});
return (verdict.error as { issues?: RuntimeAuthoringIssue[] } | null)?.issues ?? [];
}
Loading
Loading