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
25 changes: 25 additions & 0 deletions .changeset/protection-block-unknown-key-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/spec": patch
---

fix(spec): the `protection` block's unknown-key refusal now names the surface, lists the declared keys and suggests the rename (#16845)

`ProtectionSchema` (`shared/protection.zod.ts`) was a bare `z.object({ … }).strict()` with **no error map**, so an unknown key inside a `protection:` block was refused with zod's own default text and nothing else:

```
AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } })
✗ protection: Unrecognized key: "lockk"
```

`lockk` is one keystroke from the declared `lock`, and the author — human or AI, whose whole correction loop is the error text — was told the key was wrong and given no surface name, no declared-key list and no rename. The block is mounted on very nearly every authorable metadata type in the platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools, skills), so that was the message everywhere a protection key was misspelled.

It is now built with the `strictObject` helper — the same conversion #16328 made for the manifest `permissions` block — and answers:

```
✗ protection: Unrecognized key(s) on the `protection` block of this metadata item: `lockk`.
Did you mean `lockk` → `lock`? … The declared keys are `lock`, `reason` and `docsUrl`.
```

Curated alongside it: prose-slot aliases (`description` / `message` / `explanation` / `lockReason` → `reason`), documentation-link aliases (`docs` / `link` / `url` / `href` / `helpUrl` / `documentationUrl` → `docsUrl`), a wrong-layer prescription for the field-level `readonly` / `readOnly` booleans (which map to a `lock` *policy*, not a boolean), and one prescription for the whole private `_lock*` envelope family. Two of those aliases correct a measurably **wrong** answer: the edit-distance fallback used to point `docs` and `link` — each two edits from `lock` — at the lock policy rather than at `docsUrl`.

**Not a breaking change: the accept set does not move.** `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a zod error map is consulted only for an issue already being raised, so it can neither admit a value that was rejected nor reject one that was accepted. Measured rather than argued — the same parse probe across the declared key set, every accepted input, and every rejection's issue `code` reads byte-identical before and after.
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -1369,7 +1369,7 @@ rest at #5107.
| `ai/` | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire |
| `integration/` | wire | connector payloads — upstream adds fields freely |
| `identity/` | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire. **34 → 33 in #4641**: `identity.zod.ts` lost its `SessionSchema` site — a second, importerless declaration of a name `api/auth.zod.ts` already owned (the #4411 dual-source trap), deleted rather than reclassified |
| `shared/` | n/a | utilities and building blocks; strictness decided at the consuming schema |
| `shared/` | **mixed · `protection.zod.ts` authorable, the rest as written** | ⚠️ **Annotated at #16845 — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** ⛔ **This directory is still UNTRIAGED and this is not a per-file row** — `shared/` has never had per-file rows, so `shared/protection.zod.ts` was **never enumerated** by this campaign; it was not deliberately accepted, because no verdict was ever taken on it. Two measurements say why the silence carried no information. **①** This row's own rationale — *strictness decided at the consuming schema* — is false for this file: all **16** `protection: ProtectionSchema` mounts across **14** files are the bare `ProtectionSchema.optional()`, not one `.extend()` / `.merge()` / `.omit()` / re-posture among them, so both the strictness and the MESSAGE are decided here in `shared/` and inherited verbatim by objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools and skills — very nearly every authorable metadata type in the platform. A row is read as licence, which is what this ledger's own gate exists to prevent. **②** Even a per-file row would not have surfaced the defect, and this is the part worth keeping: `ProtectionSchema` has been `.strict()` since it was introduced, so it counts in the `strict` column and could never carry a remaining-strip row. **This ratchet's axis is CLOSURE; the defect was MESSAGE QUALITY** — a closed shape with no error map, refusing `lockk` with zod's bare `Unrecognized key: "lockk"`: no surface, no declared-key list, no rename, on every one of those mounts. `strictObject` at #16845 (the `PluginPermissionsSchema` conversion at #16328 one directory over is the precedent). ⛔ The population of *closed shapes still carrying zod's bare message* is **NOT MEASURED** here and is not this row's claim — #14722's rule stands that a sweep needs its own card with its own measured count. The rest of `shared/` is unchanged: utilities and building blocks whose strictness really is decided at the consuming schema |
| `qa/` | n/a | test fixtures |

## Next steps (verify-then-enforce, one shape at a time)
Expand Down
138 changes: 138 additions & 0 deletions packages/spec/src/shared/protection.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* The `unrecognized_keys` issue this parse raised, or `undefined`.
*
* Read off the ISSUE rather than the formatted string: the formatter is a
* second surface with its own tests, and pinning through it would make these
* assertions fail for a reason that has nothing to do with this block.
*/
function unknownKeyIssue(result: { success: boolean; error?: { issues: readonly unknown[] } }) {
if (result.success) return undefined;
return (result.error!.issues as { code: string; message: string; keys?: string[] }[])
.find((i) => i.code === 'unrecognized_keys');
}

describe('ProtectionSchema', () => {
it('accepts the four lock values', () => {
for (const lock of ['none', 'no-overlay', 'no-delete', 'full'] as const) {
Expand Down Expand Up @@ -104,3 +118,127 @@ describe('applyProtection', () => {
expect(item._lockReason).toBe('new');
});
});

/**
* #16845 — the refusal TEXT, pinned at parse level.
*
* Before this block adopted `strictObject`, it was a bare `z.object(…).strict()`
* with no error map, so its rejection was zod's own default —
* `Unrecognized key: "lockk"` — carrying no surface name, no declared-key list
* and no rename, on every one of the metadata types that mount it. Its own
* closure was never the defect; the message was.
*
* These pins are written against the message the author actually reads, because
* reading the source cannot distinguish "has an error map" from "has an error
* map that says something useful".
*/
describe('ProtectionSchema — unknown-key refusal (#16845)', () => {
it('names the surface, echoes the key and suggests the rename', () => {
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', lockk: 'system' } as never));
expect(issue, 'a `.strict()` shape must still raise unrecognized_keys').toBeDefined();
expect(issue!.keys).toEqual(['lockk']);
// ① the surface — true at every mount, named without transcribing any.
expect(issue!.message).toContain('the `protection` block of this metadata item');
// ② the offending key, echoed back.
expect(issue!.message).toContain('`lockk`');
// ③ the rename.
expect(issue!.message).toContain('Did you mean `lockk` → `lock`?');
// …and the declared-key list, which the template carries in `history`.
expect(issue!.message).toContain('The declared keys are `lock`, `reason` and `docsUrl`.');
// The pre-fix message, pinned as ABSENT: zod's bare default is the defect.
expect(issue!.message).not.toBe('Unrecognized key: "lockk"');
});

it('reaches an authorable surface — the card\'s own case, through `AgentSchema`', () => {
const issue = unknownKeyIssue(AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } } as never));
expect(issue, 'the mount must carry the declaring schema\'s error map').toBeDefined();
expect((issue as unknown as { path: unknown[] }).path).toEqual(['protection']);
expect(issue!.message).toContain('the `protection` block of this metadata item');
expect(issue!.message).toContain('Did you mean `lockk` → `lock`?');
});

it('corrects the two spellings the distance fallback answered WRONG', () => {
// Measured on the pre-fix build: `docs` and `link` are each 2 edits from
// `lock`, inside the length-relative budget, so the fallback pointed an
// author who meant the documentation URL at the lock policy.
for (const written of ['docs', 'link'] as const) {
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', [written]: 'x' } as never));
expect(issue!.message).toContain(`Did you mean \`${written}\` → \`docsUrl\`?`);
expect(issue!.message).not.toContain(`\`${written}\` → \`lock\``);
}
});

it('answers the prose slot and the `_lock*` envelope family', () => {
for (const written of ['description', 'message', 'explanation', 'lockReason'] as const) {
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', [written]: 'x' } as never));
expect(issue!.message).toContain(`Did you mean \`${written}\` → \`reason\`?`);
}
// One prescription for the whole family, once per message.
const env = unknownKeyIssue(ProtectionSchema.safeParse(
{ lock: 'full', reason: 'r', _lock: 'full', _lockReason: 'r', _lockSource: 'package' } as never,
));
expect(env!.message).toContain('the runtime\'s PRIVATE envelope');
expect(env!.message.match(/PRIVATE envelope/g)).toHaveLength(1);
// A wrong-layer boolean gets the VALUE mapping, not a bare rename.
const ro = unknownKeyIssue(ProtectionSchema.safeParse({ lock: 'full', reason: 'r', readOnly: true } as never));
expect(ro!.message).toContain("write `lock: 'no-overlay'`");
expect(ro!.message).not.toContain('→ `lock`?');
});

it('the `history` key list is the shape\'s key list — the one transcription here, pinned', () => {
// `history` spells the declared keys out in prose because the template
// has no declared-key channel of its own. That is a second copy of the
// shape, so it is held equal to the shape rather than trusted.
const issue = unknownKeyIssue(ProtectionSchema.safeParse({ zzz: 1 } as never));
for (const key of Object.keys(ProtectionSchema.shape)) {
expect(issue!.message, `\`${key}\` is declared but the history sentence does not name it`)
.toContain(`\`${key}\``);
}
});
});

/**
* #16845 clause ② — the accept set did NOT move.
*
* `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a
* zod error map is consulted only for an issue already being raised, so it
* cannot make a rejected value accepted or an accepted value rejected. That is
* an argument; this is the measurement. Every row below reads identically on the
* pre-fix build.
*/
describe('ProtectionSchema — accept set is unchanged (#16845)', () => {
it('declares exactly `lock`, `reason`, `docsUrl`', () => {
expect(Object.keys(ProtectionSchema.shape).sort()).toEqual(['docsUrl', 'lock', 'reason']);
});

it('accepts what it accepted, and returns the same data', () => {
expect(ProtectionSchema.parse({ lock: 'full', reason: 'core', docsUrl: 'https://example.com/d' }))
.toEqual({ lock: 'full', reason: 'core', docsUrl: 'https://example.com/d' });
expect(ProtectionSchema.parse({ lock: 'none', reason: 'r' })).toEqual({ lock: 'none', reason: 'r' });
});

it('rejects what it rejected, with the same issue CODES', () => {
const rows: [string, unknown, string][] = [
['unknown key', { lock: 'full', reason: 'r', lockk: 'x' }, 'unrecognized_keys'],
['missing lock', { reason: 'r' }, 'invalid_value'],
['missing reason', { lock: 'full' }, 'invalid_type'],
['bad lock value', { lock: 'nope', reason: 'r' }, 'invalid_value'],
['bad docsUrl', { lock: 'full', reason: 'r', docsUrl: 'x' }, 'invalid_format'],
['reason too long', { lock: 'full', reason: 'x'.repeat(501) }, 'too_big'],
['reason empty', { lock: 'full', reason: '' }, 'too_small'],
];
for (const [label, input, code] of rows) {
const r = ProtectionSchema.safeParse(input as never);
expect(r.success, label).toBe(false);
expect(r.error!.issues.map((i) => i.code), label).toContain(code);
}
});

it('a valid block still parses through a real mount', () => {
const r = AgentSchema.safeParse({
name: 'a', label: 'A', role: 'r', instructions: 'i',
protection: { lock: 'full', reason: 'core' },
} as never);
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
});
});
88 changes: 86 additions & 2 deletions packages/spec/src/shared/protection.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
MetadataLockSchema,
type MetadataLock,
} from '../kernel/metadata-protection.zod';
import { strictObject } from './strict-object';

/**
* Public protection block authored by package developers. Optional on
Expand All @@ -60,7 +61,90 @@ import {
* (provenance, packageId, packageVersion) is auto-populated by the
* loader and must not be supplied here.
*/
export const ProtectionSchema = z.object({
export const ProtectionSchema = strictObject({
// ⚠️ ONE declaring schema, reached from every mount — so the surface name
// cannot be per-mount-context the way every neighbouring `strictObject`
// adoption's is. `protection:` is mounted on very nearly every authorable
// metadata type in the platform, and the mount list moves; transcribing it
// into this string would mint exactly the second copy of the truth this
// helper exists to delete, and a stale copy here would be published as a
// confident sentence in a rejection. So the surface names the BLOCK, which
// is what the author actually wrote and what the error path already shows
// (`protection.lock`), and is true at every mount without naming one.
surface: 'the `protection` block of this metadata item',
history:
'This block has refused unknown keys since it was introduced, but through zod\'s own '
+ 'bare message: a one-keystroke `lockk` was echoed back and nothing else — no surface, '
+ 'no declared-key list, no rename — while every neighbouring block on the same item '
+ 'named all three. It is mounted on very nearly every authorable metadata type in the '
+ 'platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, '
+ 'permissions, positions, email templates, agents, tools, skills), so that bare message '
+ 'was what an author saw wherever a protection key was misspelled. The declared keys '
+ 'are `lock`, `reason` and `docsUrl`.',
aliases: {
// ── prose slot ────────────────────────────────────────────────────
// `description` is declared on the mounting metadata types themselves,
// one line up from this block, so an author reaching for prose inside
// `protection` writes it by reflex. `message` and `explanation` are the
// words this file's own docblock uses for the value ("user-visible
// explanation surfaced in `403 ITEM_LOCKED` errors").
description: 'reason',
message: 'reason',
explanation: 'reason',
// The private envelope's public counterparts, written without the
// underscore. Distance cannot reach them (`lockReason` → `reason` is 4
// edits against a length-relative budget of 3); the underscored
// spellings are answered by the guidance set below instead.
lockReason: 'reason',
lockDocsUrl: 'docsUrl',
// ── docs slot ─────────────────────────────────────────────────────
// ⚠️ `docs` and `link` are not merely unreached — measured on the
// pre-fix build, the edit-distance fallback answered BOTH of them with
// `lock` (`docs`/`link` are each 2 edits from `lock`, inside the budget
// of 2 for a four-character key), i.e. it pointed an author who meant
// the documentation URL at the lock policy. That is ledger finding 7's
// shape — the campaign's own fix signposting into a second rejection —
// and it is why these two entries carry judgement rather than typing.
docs: 'docsUrl',
link: 'docsUrl',
url: 'docsUrl',
href: 'docsUrl',
helpUrl: 'docsUrl',
documentationUrl: 'docsUrl',
},
guidance: {
// Wrong-layer, and a bare rename would misinform about the VALUE: both
// spellings are real boolean keys on neighbouring surfaces
// (`data/field.zod.ts` `readonly`, `ui/component.zod.ts` `readOnly`),
// whereas this block's equivalent is an enum, so `lock: true` would be
// the author's next rejection.
readonly:
'`readonly` is a field/component-level boolean; this block expresses the same intent '
+ 'as a policy — write `lock: \'no-overlay\'` (save blocked, delete still allowed) or '
+ '`lock: \'full\'` (both blocked).',
readOnly:
'`readOnly` is a field/component-level boolean; this block expresses the same intent '
+ 'as a policy — write `lock: \'no-overlay\'` (save blocked, delete still allowed) or '
+ '`lock: \'full\'` (both blocked).',
},
guidanceSets: [
{
// The `_lock` envelope is the RUNTIME form of this block
// (`kernel/metadata-protection.zod.ts`), stamped by
// `applyProtection` below at registration time. An author who has
// read the stored row writes its keys here; one prescription
// answers the whole family, once per message.
name: 'PRIVATE_LOCK_ENVELOPE_KEYS',
keys: /^_lock/,
examples: ['_lock', '_lockReason', '_lockDocsUrl', '_lockSource'],
prescription:
'The `_lock*` keys are the runtime\'s PRIVATE envelope, stamped by the loader from '
+ 'this block — never authored. Write the public keys instead: `_lock` → `lock`, '
+ '`_lockReason` → `reason`, `_lockDocsUrl` → `docsUrl`; `_lockSource` is derived '
+ 'and has no author-facing counterpart.',
},
],
}, {
/**
* Lock policy for this item. See {@link MetadataLockSchema} for
* the full semantics table.
Expand Down Expand Up @@ -101,7 +185,7 @@ export const ProtectionSchema = z.object({
docsUrl: z.string().url().optional().describe(
'Optional URL the Studio banner links to for more context.',
),
}).strict();
});

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

Expand Down
Loading