Skip to content

Commit c81b541

Browse files
committed
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 <noreply@anthropic.com>
1 parent c118524 commit c81b541

3 files changed

Lines changed: 105 additions & 12 deletions

File tree

‎packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts‎

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
* raised used to open with a `[lower_snake]` tag that was the restatement of the
88
* `code` the very same throw declared — `[no_draft]` in front of `NO_DRAFT`,
99
* `[item_locked]` in front of `ITEM_LOCKED`, and so on for the whole family.
10+
* The runtime authoring gate (`runtime-authoring-gate.ts`) is the third
11+
* producer: its one refusal opened with `[invalid_metadata]` in front of its own
12+
* `INVALID_METADATA` / 422, the same shape, and reached `dist` the same way.
1013
*
1114
* They were not invisible. `withoutDeclaredCodePrefix`
1215
* (`packages/rest/src/error-response.ts`) strips a leading restatement only when
@@ -28,8 +31,8 @@
2831
* this package assert the prose a given door answers, so one re-introduced tag
2932
* reds exactly one of them and a newly-written refusal reds none — and a new
3033
* refusal copied from a neighbouring producer is precisely how the idiom spread
31-
* in the first place. Reading the source covers every throw site in both files,
32-
* including ones no test can provoke.
34+
* in the first place. Reading the source covers every throw site in every
35+
* producer file, including ones no test can provoke.
3336
*
3437
* ⚠️ A scan that matches nothing passes for free, so the family floor below is
3538
* part of the pin: the scanner must still be finding refusals to have an opinion
@@ -48,11 +51,29 @@ import { readFileSync } from 'node:fs';
4851
import { dirname, join } from 'node:path';
4952
import { fileURLToPath } from 'node:url';
5053
import { ObjectStackProtocolImplementation } from './protocol.js';
54+
import {
55+
PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING,
56+
evaluateRuntimeAuthoringGate,
57+
} from './runtime-authoring-gate.js';
5158

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

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

5778
/**
5879
* 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;
6586
* every grep for a literal tag walked straight past. A detector that reads only
6687
* literals would let exactly that shape back in.
6788
*/
68-
const TAGGED_OPENER = /(`|')\[(?:([A-Za-z][A-Za-z0-9_]*)\]|\$\{)/;
89+
const TAGGED_OPENER = /(`|')\[(?:([A-Za-z][A-Za-z0-9_]*)\]|\$\{([^}]*))/;
6990

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

106+
/**
107+
* The INTERPOLATED bracket that is NOT this family, by the expression it
108+
* interpolates.
109+
*
110+
* `advisory.rule` is the `[rule]` locator the author-time gate composes on its
111+
* advisory log line: it opens a continuation literal in the middle of that
112+
* line, after the `[Protocol]` prefix, and names WHICH rule produced the
113+
* finding. That is the `[rule]`-locator vocabulary the header above already
114+
* declares out of scope — it restates no declared `code`, and no throw sits
115+
* beside it. The line-based scan cannot tell a mid-message continuation from
116+
* an opener, so the exemption is spelled here rather than inferred.
117+
*
118+
* ⚠️ Keyed on the exact expression, never on "anything interpolated": the
119+
* interpolated arm exists because an opener written as `[${code}]` from the
120+
* throw's own `code` variable is the most redundant member of the family.
121+
* ⛔ Never add an expression here that holds a declared error code.
122+
*/
123+
const NON_REFUSAL_LOCATORS = new Set(['advisory.rule']);
124+
85125
function scan(file: string): { openers: string[]; refusals: number } {
86126
const lines = readFileSync(join(HERE, file), 'utf8').split('\n');
87127
const openers: string[] = [];
@@ -96,13 +136,14 @@ function scan(file: string): { openers: string[]; refusals: number } {
96136
// The bracket must open the literal, not merely appear inside it.
97137
if (!m || line[m.index + 1] !== '[') continue;
98138
if (m[2] !== undefined && NON_REFUSAL_PREFIXES.has(m[2])) continue;
139+
if (m[3] !== undefined && NON_REFUSAL_LOCATORS.has(m[3].trim())) continue;
99140
openers.push(`${file}:${i + 1} ${trimmed.slice(0, 100)}`);
100141
}
101142
return { openers, refusals };
102143
}
103144

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

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

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

122-
it('the whole family is covered — both producers together still raise the refusals this pin is about', () => {
123-
const total = PRODUCERS.reduce((n, f) => n + scan(f).refusals, 0);
163+
it('the whole family is covered — the producers together still raise the refusals this pin is about', () => {
164+
const total = Object.keys(PRODUCERS).reduce((n, f) => n + scan(f).refusals, 0);
124165
expect(total).toBeGreaterThanOrEqual(30);
125166
});
126167
});
@@ -168,4 +209,43 @@ describe('the refusal a caller actually receives', () => {
168209
expect(err.message.startsWith('['), `message opens with a tag: ${err.message.slice(0, 48)}`).toBe(false);
169210
expect(err.message).toContain("rollbackMetaItem requires a positive integer 'toVersion'");
170211
});
212+
213+
it('carries the token on `code` and opens with the sentence — the author-time gate `INVALID_METADATA`', () => {
214+
// A schedule-triggered, platform-level flow that creates rows naming no
215+
// organization, on a deployment that walls organizations: the gate-local
216+
// refusal, which needs no engine and no registry to fire.
217+
const { error: err } = evaluateRuntimeAuthoringGate({
218+
type: 'flow',
219+
name: 'nightly_sweep',
220+
state: 'active',
221+
organizationId: null,
222+
orgWallEnforced: true,
223+
body: {
224+
name: 'nightly_sweep',
225+
label: 'Nightly Sweep',
226+
type: 'schedule',
227+
status: 'active',
228+
runAs: 'system',
229+
nodes: [
230+
{ id: 'start', type: 'start', label: 'Start', config: { schedule: '0 1 * * *' } },
231+
{
232+
id: 'log',
233+
type: 'create_record',
234+
label: 'Write sweep log',
235+
config: { objectName: 'sweep_log', fields: { note: 'swept' } },
236+
},
237+
],
238+
edges: [{ id: 'e1', source: 'start', target: 'log' }],
239+
},
240+
}) as { error: any };
241+
242+
expect(err, 'the gate let a refused publish through — nothing to assert on').not.toBeNull();
243+
expect(err.code).toBe('INVALID_METADATA');
244+
expect(err.status).toBe(422);
245+
expect(err.message.startsWith('['), `message opens with a tag: ${err.message.slice(0, 48)}`).toBe(false);
246+
expect(err.message).not.toContain('[invalid_metadata]');
247+
expect(err.message).toMatch(/^flow\/nightly_sweep failed author-time validation: \d+ issues? — /);
248+
// The `[rule]` locator is NOT this family and stays in the sentence.
249+
expect(err.message).toContain(`[${PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING}]`);
250+
});
171251
});

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,16 @@ describe('runtime authoring gate on saveMetaItem (#4463)', () => {
211211
it('refuses an ACTIVE save of the broken approval flow with a 422', async () => {
212212
const { protocol, rows } = makeProtocol();
213213

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

220225
// D3 — the structured envelope Studio already renders for a Zod
221226
// failure, carrying the four keys an author needs to act.

‎packages/metadata-protocol/src/runtime-authoring-gate.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -770,8 +770,16 @@ export function evaluateRuntimeAuthoringGate(args: {
770770
return { error: null, advisories };
771771
}
772772

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

0 commit comments

Comments
 (0)