Skip to content

Commit 46e0e0b

Browse files
committed
fix(spec): name the surface, list the keys and suggest the rename on the protection block
`ProtectionSchema` was a bare `z.object({…}).strict()` with no error map, so an unknown key inside `protection` was refused with zod's own default text — `Unrecognized key: "lockk"` — carrying no surface name, no declared-key list and no rename suggestion. The block is mounted on very nearly every authorable metadata type in the platform, so that bare message was what an author saw wherever a protection key was misspelled. Converted to the `strictObject` helper, mirroring what #16328 did for `PluginPermissionsSchema`. The helper derives the candidate list from the shape, so no second copy of the key list is introduced. The accept set does not move: `strictObject(o, shape)` is `z.object(shape, { error }).strict()`, and an error map is consulted only for an issue already being raised. Pinned rather than argued. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47863f4 commit 46e0e0b

2 files changed

Lines changed: 224 additions & 2 deletions

File tree

‎packages/spec/src/shared/protection.test.ts‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, expect, it } from 'vitest';
4+
import { AgentSchema } from '../ai/agent.zod';
45
import { ProtectionSchema, applyProtection } from './protection.zod';
56

7+
/**
8+
* The `unrecognized_keys` issue this parse raised, or `undefined`.
9+
*
10+
* Read off the ISSUE rather than the formatted string: the formatter is a
11+
* second surface with its own tests, and pinning through it would make these
12+
* assertions fail for a reason that has nothing to do with this block.
13+
*/
14+
function unknownKeyIssue(result: { success: boolean; error?: { issues: readonly unknown[] } }) {
15+
if (result.success) return undefined;
16+
return (result.error!.issues as { code: string; message: string; keys?: string[] }[])
17+
.find((i) => i.code === 'unrecognized_keys');
18+
}
19+
620
describe('ProtectionSchema', () => {
721
it('accepts the four lock values', () => {
822
for (const lock of ['none', 'no-overlay', 'no-delete', 'full'] as const) {
@@ -104,3 +118,127 @@ describe('applyProtection', () => {
104118
expect(item._lockReason).toBe('new');
105119
});
106120
});
121+
122+
/**
123+
* #16845 — the refusal TEXT, pinned at parse level.
124+
*
125+
* Before this block adopted `strictObject`, it was a bare `z.object(…).strict()`
126+
* with no error map, so its rejection was zod's own default —
127+
* `Unrecognized key: "lockk"` — carrying no surface name, no declared-key list
128+
* and no rename, on every one of the metadata types that mount it. Its own
129+
* closure was never the defect; the message was.
130+
*
131+
* These pins are written against the message the author actually reads, because
132+
* reading the source cannot distinguish "has an error map" from "has an error
133+
* map that says something useful".
134+
*/
135+
describe('ProtectionSchema — unknown-key refusal (#16845)', () => {
136+
it('names the surface, echoes the key and suggests the rename', () => {
137+
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', lockk: 'system' } as never));
138+
expect(issue, 'a `.strict()` shape must still raise unrecognized_keys').toBeDefined();
139+
expect(issue!.keys).toEqual(['lockk']);
140+
// ① the surface — true at every mount, named without transcribing any.
141+
expect(issue!.message).toContain('the `protection` block of this metadata item');
142+
// ② the offending key, echoed back.
143+
expect(issue!.message).toContain('`lockk`');
144+
// ③ the rename.
145+
expect(issue!.message).toContain('Did you mean `lockk` → `lock`?');
146+
// …and the declared-key list, which the template carries in `history`.
147+
expect(issue!.message).toContain('The declared keys are `lock`, `reason` and `docsUrl`.');
148+
// The pre-fix message, pinned as ABSENT: zod's bare default is the defect.
149+
expect(issue!.message).not.toBe('Unrecognized key: "lockk"');
150+
});
151+
152+
it('reaches an authorable surface — the card\'s own case, through `AgentSchema`', () => {
153+
const issue = unknownKeyIssue(AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } } as never));
154+
expect(issue, 'the mount must carry the declaring schema\'s error map').toBeDefined();
155+
expect((issue as unknown as { path: unknown[] }).path).toEqual(['protection']);
156+
expect(issue!.message).toContain('the `protection` block of this metadata item');
157+
expect(issue!.message).toContain('Did you mean `lockk` → `lock`?');
158+
});
159+
160+
it('corrects the two spellings the distance fallback answered WRONG', () => {
161+
// Measured on the pre-fix build: `docs` and `link` are each 2 edits from
162+
// `lock`, inside the length-relative budget, so the fallback pointed an
163+
// author who meant the documentation URL at the lock policy.
164+
for (const written of ['docs', 'link'] as const) {
165+
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', [written]: 'x' } as never));
166+
expect(issue!.message).toContain(`Did you mean \`${written}\` → \`docsUrl\`?`);
167+
expect(issue!.message).not.toContain(`\`${written}\` → \`lock\``);
168+
}
169+
});
170+
171+
it('answers the prose slot and the `_lock*` envelope family', () => {
172+
for (const written of ['description', 'message', 'explanation', 'lockReason'] as const) {
173+
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', [written]: 'x' } as never));
174+
expect(issue!.message).toContain(`Did you mean \`${written}\` → \`reason\`?`);
175+
}
176+
// One prescription for the whole family, once per message.
177+
const env = unknownKeyIssue(ProtectionSchema.safeParse(
178+
{ lock: 'full', reason: 'r', _lock: 'full', _lockReason: 'r', _lockSource: 'package' } as never,
179+
));
180+
expect(env!.message).toContain('the runtime\'s PRIVATE envelope');
181+
expect(env!.message.match(/PRIVATE envelope/g)).toHaveLength(1);
182+
// A wrong-layer boolean gets the VALUE mapping, not a bare rename.
183+
const ro = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', readOnly: true } as never));
184+
expect(ro!.message).toContain("write `lock: 'no-overlay'`");
185+
expect(ro!.message).not.toContain('→ `lock`?');
186+
});
187+
188+
it('the `history` key list is the shape\'s key list — the one transcription here, pinned', () => {
189+
// `history` spells the declared keys out in prose because the template
190+
// has no declared-key channel of its own. That is a second copy of the
191+
// shape, so it is held equal to the shape rather than trusted.
192+
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ zzz: 1 } as never));
193+
for (const key of Object.keys(ProtectionSchema.shape)) {
194+
expect(issue!.message, `\`${key}\` is declared but the history sentence does not name it`)
195+
.toContain(`\`${key}\``);
196+
}
197+
});
198+
});
199+
200+
/**
201+
* #16845 clause ② — the accept set did NOT move.
202+
*
203+
* `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a
204+
* zod error map is consulted only for an issue already being raised, so it
205+
* cannot make a rejected value accepted or an accepted value rejected. That is
206+
* an argument; this is the measurement. Every row below reads identically on the
207+
* pre-fix build.
208+
*/
209+
describe('ProtectionSchema — accept set is unchanged (#16845)', () => {
210+
it('declares exactly `lock`, `reason`, `docsUrl`', () => {
211+
expect(Object.keys(ProtectionSchema.shape).sort()).toEqual(['docsUrl', 'lock', 'reason']);
212+
});
213+
214+
it('accepts what it accepted, and returns the same data', () => {
215+
expect(ProtectionSchema.parse({ lock: 'full', reason: 'core', docsUrl: 'https://example.com/d' }))
216+
.toEqual({ lock: 'full', reason: 'core', docsUrl: 'https://example.com/d' });
217+
expect(ProtectionSchema.parse({ lock: 'none', reason: 'r' })).toEqual({ lock: 'none', reason: 'r' });
218+
});
219+
220+
it('rejects what it rejected, with the same issue CODES', () => {
221+
const rows: [string, unknown, string][] = [
222+
['unknown key', { lock: 'full', reason: 'r', lockk: 'x' }, 'unrecognized_keys'],
223+
['missing lock', { reason: 'r' }, 'invalid_value'],
224+
['missing reason', { lock: 'full' }, 'invalid_type'],
225+
['bad lock value', { lock: 'nope', reason: 'r' }, 'invalid_value'],
226+
['bad docsUrl', { lock: 'full', reason: 'r', docsUrl: 'x' }, 'invalid_format'],
227+
['reason too long', { lock: 'full', reason: 'x'.repeat(501) }, 'too_big'],
228+
['reason empty', { lock: 'full', reason: '' }, 'too_small'],
229+
];
230+
for (const [label, input, code] of rows) {
231+
const r = ProtectionSchema.safeParse(input as never);
232+
expect(r.success, label).toBe(false);
233+
expect(r.error!.issues.map((i) => i.code), label).toContain(code);
234+
}
235+
});
236+
237+
it('a valid block still parses through a real mount', () => {
238+
const r = AgentSchema.safeParse({
239+
name: 'a', label: 'A', role: 'r', instructions: 'i',
240+
protection: { lock: 'full', reason: 'core' },
241+
} as never);
242+
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
243+
});
244+
});

‎packages/spec/src/shared/protection.zod.ts‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
MetadataLockSchema,
5050
type MetadataLock,
5151
} from '../kernel/metadata-protection.zod';
52+
import { strictObject } from './strict-object';
5253

5354
/**
5455
* Public protection block authored by package developers. Optional on
@@ -60,7 +61,90 @@ import {
6061
* (provenance, packageId, packageVersion) is auto-populated by the
6162
* loader and must not be supplied here.
6263
*/
63-
export const ProtectionSchema = z.object({
64+
export const ProtectionSchema = strictObject({
65+
// ⚠️ ONE declaring schema, reached from every mount — so the surface name
66+
// cannot be per-mount-context the way every neighbouring `strictObject`
67+
// adoption's is. `protection:` is mounted on very nearly every authorable
68+
// metadata type in the platform, and the mount list moves; transcribing it
69+
// into this string would mint exactly the second copy of the truth this
70+
// helper exists to delete, and a stale copy here would be published as a
71+
// confident sentence in a rejection. So the surface names the BLOCK, which
72+
// is what the author actually wrote and what the error path already shows
73+
// (`protection.lock`), and is true at every mount without naming one.
74+
surface: 'the `protection` block of this metadata item',
75+
history:
76+
'This block has refused unknown keys since it was introduced, but through zod\'s own '
77+
+ 'bare message: a one-keystroke `lockk` was echoed back and nothing else — no surface, '
78+
+ 'no declared-key list, no rename — while every neighbouring block on the same item '
79+
+ 'named all three. It is mounted on very nearly every authorable metadata type in the '
80+
+ 'platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, '
81+
+ 'permissions, positions, email templates, agents, tools, skills), so that bare message '
82+
+ 'was what an author saw wherever a protection key was misspelled. The declared keys '
83+
+ 'are `lock`, `reason` and `docsUrl`.',
84+
aliases: {
85+
// ── prose slot ────────────────────────────────────────────────────
86+
// `description` is declared on the mounting metadata types themselves,
87+
// one line up from this block, so an author reaching for prose inside
88+
// `protection` writes it by reflex. `message` and `explanation` are the
89+
// words this file's own docblock uses for the value ("user-visible
90+
// explanation surfaced in `403 ITEM_LOCKED` errors").
91+
description: 'reason',
92+
message: 'reason',
93+
explanation: 'reason',
94+
// The private envelope's public counterparts, written without the
95+
// underscore. Distance cannot reach them (`lockReason` → `reason` is 4
96+
// edits against a length-relative budget of 3); the underscored
97+
// spellings are answered by the guidance set below instead.
98+
lockReason: 'reason',
99+
lockDocsUrl: 'docsUrl',
100+
// ── docs slot ─────────────────────────────────────────────────────
101+
// ⚠️ `docs` and `link` are not merely unreached — measured on the
102+
// pre-fix build, the edit-distance fallback answered BOTH of them with
103+
// `lock` (`docs`/`link` are each 2 edits from `lock`, inside the budget
104+
// of 2 for a four-character key), i.e. it pointed an author who meant
105+
// the documentation URL at the lock policy. That is ledger finding 7's
106+
// shape — the campaign's own fix signposting into a second rejection —
107+
// and it is why these two entries carry judgement rather than typing.
108+
docs: 'docsUrl',
109+
link: 'docsUrl',
110+
url: 'docsUrl',
111+
href: 'docsUrl',
112+
helpUrl: 'docsUrl',
113+
documentationUrl: 'docsUrl',
114+
},
115+
guidance: {
116+
// Wrong-layer, and a bare rename would misinform about the VALUE: both
117+
// spellings are real boolean keys on neighbouring surfaces
118+
// (`data/field.zod.ts` `readonly`, `ui/component.zod.ts` `readOnly`),
119+
// whereas this block's equivalent is an enum, so `lock: true` would be
120+
// the author's next rejection.
121+
readonly:
122+
'`readonly` is a field/component-level boolean; this block expresses the same intent '
123+
+ 'as a policy — write `lock: \'no-overlay\'` (save blocked, delete still allowed) or '
124+
+ '`lock: \'full\'` (both blocked).',
125+
readOnly:
126+
'`readOnly` is a field/component-level boolean; this block expresses the same intent '
127+
+ 'as a policy — write `lock: \'no-overlay\'` (save blocked, delete still allowed) or '
128+
+ '`lock: \'full\'` (both blocked).',
129+
},
130+
guidanceSets: [
131+
{
132+
// The `_lock` envelope is the RUNTIME form of this block
133+
// (`kernel/metadata-protection.zod.ts`), stamped by
134+
// `applyProtection` below at registration time. An author who has
135+
// read the stored row writes its keys here; one prescription
136+
// answers the whole family, once per message.
137+
name: 'PRIVATE_LOCK_ENVELOPE_KEYS',
138+
keys: /^_lock/,
139+
examples: ['_lock', '_lockReason', '_lockDocsUrl', '_lockSource'],
140+
prescription:
141+
'The `_lock*` keys are the runtime\'s PRIVATE envelope, stamped by the loader from '
142+
+ 'this block — never authored. Write the public keys instead: `_lock` → `lock`, '
143+
+ '`_lockReason` → `reason`, `_lockDocsUrl` → `docsUrl`; `_lockSource` is derived '
144+
+ 'and has no author-facing counterpart.',
145+
},
146+
],
147+
}, {
64148
/**
65149
* Lock policy for this item. See {@link MetadataLockSchema} for
66150
* the full semantics table.
@@ -101,7 +185,7 @@ export const ProtectionSchema = z.object({
101185
docsUrl: z.string().url().optional().describe(
102186
'Optional URL the Studio banner links to for more context.',
103187
),
104-
}).strict();
188+
});
105189

106190
export type Protection = z.input<typeof ProtectionSchema>;
107191

0 commit comments

Comments
 (0)