Found while sweeping #14721 (the "ManifestSchema is an open object" prose sweep). Filed unassigned and unlabelled for triage. ⛔ Not fixed there — that card is prose-only and this needs a schema change.
What was measured
Against packages/spec built at 7a265fbb49, through dist/kernel/index.mjs:
permissions clean (control): ACCEPTED
permissions + near-miss key: REFUSED (1 issue(s))
code=invalid_union path=["permissions"] :: Invalid input
The fixture is otherwise valid — { services: ['object'], hoooks: ['x'] } — so unrecognized_keys is what should fire. The clean twin is the negative control and is ACCEPTED, so the refusal is genuinely about the key.
An author who transposes hooks as hoooks is told Invalid input at permissions, with no offending key, no surface name, and no rename suggestion.
Why this is a defect and not a preference
Every other closed block on ManifestSchema names the key. Measured in the same run:
| block |
reading |
| manifest root |
Unrecognized key(s) on this package manifest: 'namesapce'. Did you mean 'namesapce' → 'namespace'? |
contributes |
Unrecognized key(s) on the 'contributes' block ...: 'kind'. Did you mean 'kind' → 'kinds'? |
contributes.kinds[] entry |
Unrecognized key(s) on a 'contributes.kinds' entry ...: 'descriptio'. Did you mean ... → 'description'? |
engines |
Unrecognized key(s) on the 'engines' block ...: 'protocl'. Did you mean 'protocl' → 'protocol'? |
engine |
Unrecognized key(s) on the legacy 'engine' block ...: 'bogusKey'. |
permissions |
Invalid input |
permissions is the one door on this schema where the refusal carries nothing actionable.
Cause
ManifestPermissionsSchema (packages/spec/src/kernel/manifest.zod.ts) is a union:
export const ManifestPermissionsSchema = z.union([
z.array(z.string()), // legacy flat list
PluginPermissionsSchema, // structured block, .strict()
]);
PluginPermissionsSchema is closed (.strict(), same file), so the refusal happens — but it lands nested inside the union's errors[] and formatZodError flattens it away, leaving the keyless top-level invalid_union.
This is a class, not a one-off
This is the third known door. Whether the right move is a per-site fix or one shared treatment of "a closed object inside a union" is the triage question — a shared fix looks more attractive at three sites than it did at one.
Note on severity
permissions decides which services, hooks, network hosts and filesystem paths a plugin may reach. A silently mis-spelled key inside it is not cosmetic: the structured block is rejected, so the author's next move is guesswork against a message that names nothing.
Found while sweeping #14721 (the "
ManifestSchemais an open object" prose sweep). Filed unassigned and unlabelled for triage. ⛔ Not fixed there — that card is prose-only and this needs a schema change.What was measured
Against
packages/specbuilt at7a265fbb49, throughdist/kernel/index.mjs:The fixture is otherwise valid —
{ services: ['object'], hoooks: ['x'] }— sounrecognized_keysis what should fire. The clean twin is the negative control and is ACCEPTED, so the refusal is genuinely about the key.An author who transposes
hooksashoooksis toldInvalid inputatpermissions, with no offending key, no surface name, and no rename suggestion.Why this is a defect and not a preference
Every other closed block on
ManifestSchemanames the key. Measured in the same run:Unrecognized key(s) on this package manifest: 'namesapce'. Did you mean 'namesapce' → 'namespace'?contributesUnrecognized key(s) on the 'contributes' block ...: 'kind'. Did you mean 'kind' → 'kinds'?contributes.kinds[]entryUnrecognized key(s) on a 'contributes.kinds' entry ...: 'descriptio'. Did you mean ... → 'description'?enginesUnrecognized key(s) on the 'engines' block ...: 'protocl'. Did you mean 'protocl' → 'protocol'?engineUnrecognized key(s) on the legacy 'engine' block ...: 'bogusKey'.permissionsInvalid inputpermissionsis the one door on this schema where the refusal carries nothing actionable.Cause
ManifestPermissionsSchema(packages/spec/src/kernel/manifest.zod.ts) is a union:PluginPermissionsSchemais closed (.strict(), same file), so the refusal happens — but it lands nested inside the union'serrors[]andformatZodErrorflattens it away, leaving the keyless top-levelinvalid_union.This is a class, not a one-off
stack.devPlugins[]hidesManifestSchema's named unknown-key refusal underinvalid_union— an author sees a keyless "Invalid input" at that one door #14722 (closed) — the identical flattening atstack.devPlugins[],z.union([ManifestSchema, z.string()]). Its "Suggested direction" section applies here nearly verbatim.state-machine.zod.tsrow for theActionRef/GuardRefunions — reported there, not fixed.This is the third known door. Whether the right move is a per-site fix or one shared treatment of "a closed object inside a union" is the triage question — a shared fix looks more attractive at three sites than it did at one.
Note on severity
permissionsdecides which services, hooks, network hosts and filesystem paths a plugin may reach. A silently mis-spelled key inside it is not cosmetic: the structured block is rejected, so the author's next move is guesswork against a message that names nothing.