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
21 changes: 21 additions & 0 deletions .changeset/19709-authoring-gate-bracketed-opener.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/metadata-protocol': patch
---

The runtime authoring gate's `422 INVALID_METADATA` refusal no longer opens its message with a bracketed `[invalid_metadata]` tag restating the `code` the same throw declares. `error` carries the human sentence, `code` carries the machine token, and the token is no longer duplicated onto the prose axis.

Clause-②: no

This is the third producer of the family the protocol and the metadata repository already retired. The gate refuses an `active` publish whose body fails an author-time rule, and its message opened with `[invalid_metadata]` in front of its own `code = 'INVALID_METADATA'` / `status = 422`. `withoutDeclaredCodePrefix` strips a leading restatement only when the message opens with the declared code followed by a colon, and a lowercase bracketed tag matches neither the casing nor the separator. So it was never stripped, and it reached every caller in `error.message`.

## FROM → TO

| before | now |
| --- | --- |
| `error: "[invalid_metadata] flow/leave_approval failed author-time validation: 1 issue — flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]"` | `error: "flow/leave_approval failed author-time validation: 1 issue — flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]"` |

**Every accept/reject verdict is unchanged.** The same bodies are refused under the same conditions, with the same `code`, `status`, `issues` and `rulesRun`. A reader matching `error.message` for `invalid_metadata` should read `error.code` (`INVALID_METADATA`) instead. A reader already using `code` needs no change.

- **The `[rule]` locators stay.** Each one names the rule behind a finding, for example `[approval-expression-invalid]`, and no other field on the message carries that fact. Only the opener that restated `code` is gone.
- **The batch publish response is unaffected on its machine axis.** `publishPackageDrafts` already puts `code: 'INVALID_METADATA'` and the structured `issues` on the causal `failed[]` row beside this message.
- **Pinned as an absence.** The package's bracketed-opener pin now scans this producer too. A re-introduced tag, or a new refusal copied from a neighbour, fails it.
10 changes: 6 additions & 4 deletions docs/qa/platform-checklist/areas/studio-authoring.json
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@
"title": "An invalid authored shape is rejected at save with a LOCATED error and is not persisted",
"since": "v16",
"status": "active",
"revision": 2,
"revision": 3,
"priority": "P1",
"surface": "mixed",
"personas": ["admin"],
Expand All @@ -474,7 +474,7 @@
],
"acceptance": [
{
"clause": "an invalid object shape is rejected at save with a located error naming the failing path (the '[invalid_metadata] … fields.<name>.type: Required' shape)",
"clause": "an invalid object shape is rejected at save with a located error naming the failing path (the 'object/<name> failed spec validation: <N> issue(s) — fields.<name>.type [<zod code>]' headline beside code INVALID_METADATA, the per-issue prose on the structured issues[] carried with it)",
"oracle": "api",
"verify": "the rejection is 4xx and its body names the exact field path that failed spec validation",
"evidence": "the error body"
Expand Down Expand Up @@ -511,13 +511,15 @@
],
"traps": ["stale-console-bundle", "automation-input"],
"source": [
"packages/runtime/src/http-dispatcher.test.ts#error (the located '[invalid_metadata] object/bad failed spec validation: fields.amount.type: Required' error shape)",
"packages/runtime/src/http-dispatcher.test.ts#error (the dispatcher keeps the 422 and code INVALID_METADATA and threads the field-anchored issues through details.issues; its thrown message is a hand-built mock, not the producer's text)",
"packages/metadata-protocol/src/protocol.ts#specValidationFindings (the producer's per-face rendering of the spec-validation message: the REST and dispatcher doors get the 'path [zod code]' headline, the prose rides issues[])",
"packages/spec/src/ui/view.zod.ts#viewKind (container guidance map: type/columns/data/viewKind/filters/sort each name the wrap prescription)",
"dashboards.strict-widget-rejects-stray-keys (dashboard-kind stray keys — cross-referenced, not duplicated)"
],
"history": [
{ "revision": 1, "date": "2026-08-07", "change": "new item: authoring validation with located errors and verified non-persistence, sampling object + view kinds and cross-referencing the deepened dashboard stray-key item instead of duplicating it", "ref": "claude/platform-test-checklist-ocwugl" },
{ "revision": 2, "date": "2026-08-11", "change": "recorded run #7695's sighting of the ALREADY-KNOWN spurious '_diagnostics' banner on a freshly saved VALID draft (the designer re-validates the server's own annotation on read-back) as a knownGap + a NOT-this-item's-FAIL negative, so the sighting is not filed as a new defect and the false positive is told apart from a real located-error miss. Note only; no clause changed", "ref": "#7753" }
{ "revision": 2, "date": "2026-08-11", "change": "recorded run #7695's sighting of the ALREADY-KNOWN spurious '_diagnostics' banner on a freshly saved VALID draft (the designer re-validates the server's own annotation on read-back) as a knownGap + a NOT-this-item's-FAIL negative, so the sighting is not filed as a new defect and the false positive is told apart from a real located-error miss. Note only; no clause changed", "ref": "#7753" },
{ "revision": 3, "date": "2026-09-23", "change": "re-spelled the located-error shape, which had stopped being true: the clause quoted '[invalid_metadata] … fields.<name>.type: Required', but saveMetaItem's spec-validation refusal no longer opens with a bracketed restatement of its code (retired in 7a25a3ee9c; the token rides code INVALID_METADATA), and on the REST and dispatcher doors its message is a headline of 'path [zod code]' locators with the per-issue prose on issues[]. Measured on c11852406 for a field missing its type: 'object/qa_invalid_probe failed spec validation: 1 issue — fields.amount.type [invalid_value]', 422, INVALID_METADATA. The dispatcher source pointer presented that test's hand-built mock message as the producer's text; it now says what the test pins, and the producer's per-face renderer is cited beside it. The capability itself (4xx, located path, nothing persisted) is unchanged", "ref": "#19709" }
]
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
* raised used to open with a `[lower_snake]` tag that was the restatement of the
* `code` the very same throw declared — `[no_draft]` in front of `NO_DRAFT`,
* `[item_locked]` in front of `ITEM_LOCKED`, and so on for the whole family.
* The runtime authoring gate (`runtime-authoring-gate.ts`) is the third
* producer: its one refusal opened with `[invalid_metadata]` in front of its own
* `INVALID_METADATA` / 422, the same shape, and reached `dist` the same way.
*
* They were not invisible. `withoutDeclaredCodePrefix`
* (`packages/rest/src/error-response.ts`) strips a leading restatement only when
Expand All @@ -28,8 +31,8 @@
* this package assert the prose a given door answers, so one re-introduced tag
* reds exactly one of them and a newly-written refusal reds none — and a new
* refusal copied from a neighbouring producer is precisely how the idiom spread
* in the first place. Reading the source covers every throw site in both files,
* including ones no test can provoke.
* in the first place. Reading the source covers every throw site in every
* producer file, including ones no test can provoke.
*
* ⚠️ A scan that matches nothing passes for free, so the family floor below is
* part of the pin: the scanner must still be finding refusals to have an opinion
Expand All @@ -48,11 +51,29 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING,
evaluateRuntimeAuthoringGate,
} from './runtime-authoring-gate.js';

const HERE = dirname(fileURLToPath(import.meta.url));

/** The two producers the ruling was applied to. */
const PRODUCERS = ['protocol.ts', 'sys-metadata-repository.ts'] as const;
/**
* The producers the ruling was applied to, each with its REFUSAL FLOOR: the
* minimum `new Error(` count the scan must still see in that file for its
* silence to mean anything.
*
* ⚠️ Per file, not one shared number. `runtime-authoring-gate.ts` constructs
* exactly one error — its 422 — so the floor of 5 the first two producers
* carry would red it for being small rather than for being wrong, and a
* shared floor of 1 would let either large producer lose nearly every throw
* site before this pin noticed.
*/
const PRODUCERS: Readonly<Record<string, number>> = {
'protocol.ts': 5,
'sys-metadata-repository.ts': 5,
'runtime-authoring-gate.ts': 1,
};

/**
* A string literal whose FIRST characters are a bracketed tag — the shape
Expand All @@ -65,7 +86,7 @@ const PRODUCERS = ['protocol.ts', 'sys-metadata-repository.ts'] as const;
* every grep for a literal tag walked straight past. A detector that reads only
* literals would let exactly that shape back in.
*/
const TAGGED_OPENER = /(`|')\[(?:([A-Za-z][A-Za-z0-9_]*)\]|\$\{)/;
const TAGGED_OPENER = /(`|')\[(?:([A-Za-z][A-Za-z0-9_]*)\]|\$\{([^}]*))/;

/**
* The bracketed openers that are NOT this family, by name.
Expand All @@ -82,6 +103,25 @@ const TAGGED_OPENER = /(`|')\[(?:([A-Za-z][A-Za-z0-9_]*)\]|\$\{)/;
*/
const NON_REFUSAL_PREFIXES = new Set(['Protocol', 'SysMetadataRepository']);

/**
* The INTERPOLATED bracket that is NOT this family, by the expression it
* interpolates.
*
* `advisory.rule` is the `[rule]` locator the author-time gate composes on its
* advisory log line: it opens a continuation literal in the middle of that
* line, after the `[Protocol]` prefix, and names WHICH rule produced the
* finding. That is the `[rule]`-locator vocabulary the header above already
* declares out of scope — it restates no declared `code`, and no throw sits
* beside it. The line-based scan cannot tell a mid-message continuation from
* an opener, so the exemption is spelled here rather than inferred.
*
* ⚠️ Keyed on the exact expression, never on "anything interpolated": the
* interpolated arm exists because an opener written as `[${code}]` from the
* throw's own `code` variable is the most redundant member of the family.
* ⛔ Never add an expression here that holds a declared error code.
*/
const NON_REFUSAL_LOCATORS = new Set(['advisory.rule']);

function scan(file: string): { openers: string[]; refusals: number } {
const lines = readFileSync(join(HERE, file), 'utf8').split('\n');
const openers: string[] = [];
Expand All @@ -96,13 +136,14 @@ function scan(file: string): { openers: string[]; refusals: number } {
// The bracket must open the literal, not merely appear inside it.
if (!m || line[m.index + 1] !== '[') continue;
if (m[2] !== undefined && NON_REFUSAL_PREFIXES.has(m[2])) continue;
if (m[3] !== undefined && NON_REFUSAL_LOCATORS.has(m[3].trim())) continue;
openers.push(`${file}:${i + 1} ${trimmed.slice(0, 100)}`);
}
return { openers, refusals };
}

describe('refusal messages open with prose, never with a bracketed restatement of their own code', () => {
it.each(PRODUCERS)('%s raises no message opening with a bracketed lowercase tag', (file) => {
it.each(Object.entries(PRODUCERS))('%s raises no message opening with a bracketed lowercase tag', (file, floor) => {
const { openers, refusals } = scan(file);

// THE FLOOR — the scan has to still be looking at refusals for its silence
Expand All @@ -111,16 +152,16 @@ describe('refusal messages open with prose, never with a bracketed restatement o
expect(
refusals,
`${file} no longer constructs errors here — this pin is scanning the wrong file`,
).toBeGreaterThanOrEqual(5);
).toBeGreaterThanOrEqual(floor);

expect(
openers,
`these refusals open with a tag restating their own declared code:\n${openers.join('\n')}`,
).toEqual([]);
});

it('the whole family is covered — both producers together still raise the refusals this pin is about', () => {
const total = PRODUCERS.reduce((n, f) => n + scan(f).refusals, 0);
it('the whole family is covered — the producers together still raise the refusals this pin is about', () => {
const total = Object.keys(PRODUCERS).reduce((n, f) => n + scan(f).refusals, 0);
expect(total).toBeGreaterThanOrEqual(30);
});
});
Expand Down Expand Up @@ -168,4 +209,43 @@ describe('the refusal a caller actually receives', () => {
expect(err.message.startsWith('['), `message opens with a tag: ${err.message.slice(0, 48)}`).toBe(false);
expect(err.message).toContain("rollbackMetaItem requires a positive integer 'toVersion'");
});

it('carries the token on `code` and opens with the sentence — the author-time gate `INVALID_METADATA`', () => {
// A schedule-triggered, platform-level flow that creates rows naming no
// organization, on a deployment that walls organizations: the gate-local
// refusal, which needs no engine and no registry to fire.
const { error: err } = evaluateRuntimeAuthoringGate({
type: 'flow',
name: 'nightly_sweep',
state: 'active',
organizationId: null,
orgWallEnforced: true,
body: {
name: 'nightly_sweep',
label: 'Nightly Sweep',
type: 'schedule',
status: 'active',
runAs: 'system',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: { schedule: '0 1 * * *' } },
{
id: 'log',
type: 'create_record',
label: 'Write sweep log',
config: { objectName: 'sweep_log', fields: { note: 'swept' } },
},
],
edges: [{ id: 'e1', source: 'start', target: 'log' }],
},
}) as { error: any };

expect(err, 'the gate let a refused publish through — nothing to assert on').not.toBeNull();
expect(err.code).toBe('INVALID_METADATA');
expect(err.status).toBe(422);
expect(err.message.startsWith('['), `message opens with a tag: ${err.message.slice(0, 48)}`).toBe(false);
expect(err.message).not.toContain('[invalid_metadata]');
expect(err.message).toMatch(/^flow\/nightly_sweep failed author-time validation: \d+ issues? — /);
// The `[rule]` locator is NOT this family and stays in the sentence.
expect(err.message).toContain(`[${PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING}]`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,16 @@ describe('runtime authoring gate on saveMetaItem (#4463)', () => {
it('refuses an ACTIVE save of the broken approval flow with a 422', async () => {
const { protocol, rows } = makeProtocol();

await expect(save(protocol, brokenApprovalFlow())).rejects.toThrow(/invalid_metadata/);

const err = await save(protocol, brokenApprovalFlow()).catch((e: any) => e);
expect(err).toBeInstanceOf(Error);
// The token rides `code`; the message is the human sentence and opens
// with it — no bracketed restatement of the code in front. Asserted as
// the sentence that opens, the count and the `[rule]` locator, so this
// cannot go green by the message turning empty or generic.
expect(err.status).toBe(422);
expect(err.code).toBe('INVALID_METADATA');
expect(err.message).toMatch(/^flow\/leave_approval failed author-time validation: 1 issue — /);
expect(err.message).toContain('flows[0].nodes[1].config.approvers[0].value [approval-expression-invalid]');

// D3 — the structured envelope Studio already renders for a Zod
// failure, carrying the four keys an author needs to act.
Expand Down
10 changes: 9 additions & 1 deletion packages/metadata-protocol/src/runtime-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,8 +770,16 @@ export function evaluateRuntimeAuthoringGate(args: {
return { error: null, advisories };
}

// The message opens with the sentence, not with a bracketed restatement of
// the `code` assigned below. The 2026-08-29 maintainer ruling is one
// envelope semantics — `error` is HUMAN LANGUAGE, `code` is the MACHINE
// TOKEN — and `withoutDeclaredCodePrefix` strips only the `CODE:` spelling,
// so a lowercase `[tag]` opener reached every caller's `error.message`
// restating what `code` already carries. The `[rule]` locators inside
// `headline` stay: they name WHICH finding, a fact no other field carries.
// Pinned as an absence in `protocol.bracketed-refusal-opener-absence.test.ts`.
const err = new Error(
`[invalid_metadata] ${args.type}/${args.name} failed author-time validation: ${headline}`,
`${args.type}/${args.name} failed author-time validation: ${headline}`,
);
(err as any).code = 'INVALID_METADATA';
(err as any).status = 422;
Expand Down
Loading