From c81b5410fa33212815828faa08890d6dcce696b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:03:16 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): the author-time gate's 422 stops opening with a bracketed restatement of its own code The runtime authoring gate's refusal opened with `[invalid_metadata]` in front of the `INVALID_METADATA` / 422 the same throw declares. The message now opens with the sentence; `code`, `status`, `issues` and `rulesRun` are unchanged, and the `[rule]` locators inside the headline stay. The absence pin widens to this third producer, with a per-file refusal floor and the gate's advisory-log `[rule]` locator declared by name. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- ...l.bracketed-refusal-opener-absence.test.ts | 98 +++++++++++++++++-- .../protocol.runtime-authoring-gate.test.ts | 9 +- .../src/runtime-authoring-gate.ts | 10 +- 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts index 20522271f56..fc4a3d904d0 100644 --- a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts +++ b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts @@ -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 @@ -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 @@ -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> = { + '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 @@ -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. @@ -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[] = []; @@ -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 @@ -111,7 +152,7 @@ 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, @@ -119,8 +160,8 @@ describe('refusal messages open with prose, never with a bracketed restatement o ).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); }); }); @@ -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}]`); + }); }); diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 21bea06d2b0..1b8435c16d2 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -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. diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index a80e778caeb..c3698c31340 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -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; From 58b526dbdd7124e2161e7a379f13a3780385f05b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:04:48 +0000 Subject: [PATCH 2/2] docs(qa): re-spell the located-error shape the authoring-validation item quotes; changeset The studio-authoring item quoted a bracketed `[invalid_metadata]` opener and a `: Required` tail that saveMetaItem's spec-validation refusal no longer emits. The clause now quotes the measured headline, the dispatcher source pointer says what that test pins, and the producer's per-face renderer is cited beside it. Revision 2 -> 3 with its history entry. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude --- .../19709-authoring-gate-bracketed-opener.md | 21 +++++++++++++++++++ .../areas/studio-authoring.json | 10 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 .changeset/19709-authoring-gate-bracketed-opener.md diff --git a/.changeset/19709-authoring-gate-bracketed-opener.md b/.changeset/19709-authoring-gate-bracketed-opener.md new file mode 100644 index 00000000000..fa30da7d909 --- /dev/null +++ b/.changeset/19709-authoring-gate-bracketed-opener.md @@ -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. diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index fe97323e93a..71bafb3f0db 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -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"], @@ -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..type: Required' shape)", + "clause": "an invalid object shape is rejected at save with a located error naming the failing path (the 'object/ failed spec validation: issue(s) — fields..type []' 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" @@ -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..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" } ] }, {