Skip to content

Commit 8f81731

Browse files
authored
feat(spec)!: reject unknown keys on flow and permission authoring schemas (#4001 Tier-A) (#4071)
Zod's default .strip silently discarded undeclared keys on authorable schemas — the instance kept parsing, so a mis-spelled or wrong-layer key shipped as metadata that quietly ignored the author's config (#3405, #1535). This lands the #4001 Tier-A slice inside the v17 breaking window. - docs/audits/2026-07-unknown-key-strictness-ledger.md: the authorable / wire / open triage (#4001 step 1), classified by "who writes this schema's input", with the ratchet's verified next targets. - shared/suggestions.zod.ts: new strictUnknownKeyError factory generalizing the #3746 hand-rolled map (alias table + length-relative edit distance + tombstone/wrong-layer guidance); ui/action.zod.ts re-homes onto it with byte-identical messages. - security/permission.zod.ts: PermissionSetSchema, ObjectPermissionSchema, FieldPermissionSchema, AdminScopeSchema are .strict(). EffectiveObjectPermissionSchema explicitly .strip()s back — response shapes stay wire-tolerant. - automation/flow.zod.ts: FlowSchema, FlowNodeSchema, FlowEdgeSchema, FlowVariableSchema are .strict(); a node's config record stays open (the executor's configSchema, #4027/#4040, and the ADR-0087 conversion layer own it). Three real defects the gate caught in its first two runs, all previously invisible — each key written by real code, silently dropped at parse: PermissionSetSchema could represent neither `description` (authored by the built-in default sets, read by the Setup projection) nor the ADR-0010 runtime protection envelope (stamped by applyProtection on every metadata type and round-tripped through getMetaItemLayered -> saveMetaItem; every sibling registered type already spread MetadataProtectionFields, permission was the outlier); and a dogfood fixture declared the object-level `sharingModel` on a flow. Two of the three are inverse drift — the direction the per-property liveness ledger cannot see. Migration is behavior-preserving by construction: any key now rejected was previously stripped and therefore never had a runtime effect.
1 parent 46e86ba commit 8f81731

15 files changed

Lines changed: 1067 additions & 44 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: reject unknown keys on the flow and permission authoring schemas (#4001 Tier-A)
6+
7+
Zod's default `.strip` silently discarded any key these schemas did not
8+
declare — the instance kept parsing, so a mis-spelled or wrong-layer key
9+
shipped as metadata that quietly ignored the author's config (#3405's
10+
action-param `reference`, #1535's object-level `workflows`). #3746 tightened
11+
one schema; this extends the same treatment to the two highest-risk
12+
authorable surfaces, per the ADR-0054 ratchet and the
13+
`docs/audits/2026-07-unknown-key-strictness-ledger.md` triage:
14+
15+
- **`security/permission.zod.ts`**`PermissionSetSchema`,
16+
`ObjectPermissionSchema`, `FieldPermissionSchema`, `AdminScopeSchema` are
17+
now `.strict()`. A silently dropped key on the capability container meant
18+
the author believed a grant or restriction was in place that the runtime
19+
never saw. `EffectiveObjectPermissionSchema` (response-side) explicitly
20+
`.strip()`s back and stays wire-tolerant.
21+
- **`automation/flow.zod.ts`**`FlowSchema`, `FlowNodeSchema`,
22+
`FlowEdgeSchema`, `FlowVariableSchema` are now `.strict()`. A node's
23+
`config` record stays **open**: it is per-node-type, owned by the
24+
registered executor's `configSchema` (#4027/#4040) and the ADR-0087
25+
conversion layer.
26+
- **`shared/suggestions.zod.ts`** — new `strictUnknownKeyError` factory (the
27+
#3746 hand-rolled map, generalized): every rejection names the offending
28+
key(s) and, where recognisable, the canonical spelling or a retired-key
29+
tombstone. `ui/action.zod.ts` re-homes onto it with byte-identical messages.
30+
- **`PermissionSetSchema` gains `description`, `protection` and the ADR-0010
31+
runtime protection envelope (`_lock`, `_packageId`, `_provenance`, …).** The
32+
strict gate's own catches: all of these are written by real code — the
33+
built-in default sets author `description` and the Setup projection reads
34+
it; `applyProtection` stamps the envelope on every metadata type and
35+
`getMetaItemLayered``saveMetaItem` round-trips it — but the schema could
36+
not represent them, so they were silently stripped at every parse (ADR-0078
37+
§3 inverse drift). Every sibling registered metadata type already spread
38+
`MetadataProtectionFields`; permission was the outlier.
39+
40+
**Migration.** Any key these schemas now reject was previously stripped and
41+
therefore had **no runtime effect** — removing or renaming it never changes
42+
the behavior of a working app; validation simply stops lying about it. The
43+
error message carries the fix; the FROM → TO mappings baked into it include:
44+
45+
- Permission set: `objectPermissions``objects`, `fieldPermissions`/`fls``fields`,
46+
`tabs``tabPermissions`, `rls`/`policies``rowLevelSecurity`;
47+
`read`/`edit`/`export`/…→`allowRead`/`allowEdit`/`allowExport`/…;
48+
`readable`/`editable` vocabulary for FLS (`hidden` → declare `readable: false`).
49+
Retired keys carry tombstones: `contextVariables` (ADR-0105 D11 — use a
50+
registered `rlsMembership` resolver or an inline literal), `isProfile`
51+
(ADR-0090 D2 — use `isDefault`).
52+
- Flow: `steps``nodes`, `connections`/`transitions`/`links``edges`,
53+
`trigger`/`triggerType``type`, `title``label`; edge `from`/`to``source`/`target`,
54+
`guard`/`when`/`expression``condition`; a top-level `object`/`objectName`/`schedule`
55+
belongs on the START node's `config` (`{ objectName, triggerType, condition,
56+
schedule }`), not on the flow.

content/docs/references/security/permission.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const result = AdminScope.parse(data);
127127
| :--- | :--- | :--- | :--- |
128128
| **name** | `string` || Permission set unique name (lowercase snake_case) |
129129
| **label** | `string` | optional | Display label |
130+
| **description** | `string` | optional | Human-readable description shown in Setup (persisted as sys_permission_set.description) |
130131
| **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) |
131132
| **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) |
132133
| **isDefault** | `boolean` || [ADR-0090 D5] App baseline for the everyone position: app-level sets are auto-bound at boot (guarded, idempotent); package-level sets become install-time suggestions an admin confirms |
@@ -136,6 +137,14 @@ const result = AdminScope.parse(data);
136137
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | App/tab visibility: visible, hidden, default_on (shown by default), default_off (available but hidden initially) |
137138
| **rowLevelSecurity** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Row-level security policies (see rls.zod.ts for full spec) |
138139
| **adminScope** | `{ businessUnit: string; includeSubtree: boolean; manageAssignments: boolean; manageBindings: boolean; … }` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) |
140+
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this permission set. |
141+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
142+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
143+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
144+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
145+
| **_packageId** | `string` | optional | Owning package machine id. |
146+
| **_packageVersion** | `string` | optional | Owning package version. |
147+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
139148

140149

141150
---

content/docs/releases/v17.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,48 @@ model), while `url` platform-wide means an HTTP endpoint to call (`http` node,
307307
webhooks). The singular `input` on `map` / `subflow` / `connector_action` is
308308
those nodes' own canonical key and is untouched.
309309

310+
### Flow and permission schemas reject unknown keys (#4001)
311+
312+
Zod's default is `.strip`: a key a schema does not declare is silently
313+
discarded and the instance keeps parsing. On an authorable surface that is the
314+
worst failure mode — the author (human or AI) gets a success envelope and
315+
ships metadata that quietly ignores their config; that is exactly how a
316+
correctly intended action-param `reference: 'sys_user'` degraded into a
317+
paste-a-UUID text box (#3405). 17.0 extends #3746's strictness from that one
318+
schema to the two highest-risk authorable surfaces, per the triage in
319+
`docs/audits/2026-07-unknown-key-strictness-ledger.md`:
320+
321+
- **Permission sets**`PermissionSetSchema`, `ObjectPermissionSchema`,
322+
`FieldPermissionSchema`, `AdminScopeSchema`. A silently dropped key on the
323+
capability container meant the author believed a grant or restriction was in
324+
place that the runtime never saw. The response-side
325+
`EffectiveObjectPermissionSchema` stays wire-tolerant.
326+
- **Flows**`FlowSchema`, `FlowNodeSchema`, `FlowEdgeSchema`,
327+
`FlowVariableSchema`. A node's `config` stays an **open** record: it is
328+
per-node-type, owned by the executor's `configSchema` and the conversion
329+
layer (see the alias table above).
330+
331+
Every rejection is written to be self-fixing: it names the offending key and,
332+
where recognisable, the canonical spelling (`steps``nodes`, edge
333+
`from`/`to``source`/`target`, `read``allowRead`, `tabs`
334+
`tabPermissions`), a wrong-layer pointer (a flow-level `objectName` belongs on
335+
the start node's `config`; `apiOperations` is response-side only), or a
336+
retired-key tombstone (`contextVariables` → ADR-0105 D11's `rlsMembership`
337+
resolvers; `isProfile``isDefault`, ADR-0090 D2).
338+
339+
Migration is by construction behavior-preserving: any key now rejected was
340+
previously stripped, so it never had a runtime effect — remove it or rename it
341+
to the canonical key the error suggests; nothing about a working app changes.
342+
343+
The gate paid for itself immediately by exposing keys the platform *writes*
344+
but the spec could not express, so `PermissionSetSchema` gains three: the
345+
**`description`** shown in Setup (persisted to
346+
`sys_permission_set.description`), the author-facing **`protection`** block,
347+
and the ADR-0010 **runtime protection envelope** (`_lock`, `_packageId`,
348+
`_provenance`, …) that every sibling metadata type already carried — without
349+
it, a package-owned permission set could not round-trip through an
350+
environment overlay.
351+
310352
### `agent.tools[]` is removed — capability comes from skills (ADR-0109, #3820)
311353

312354
ADR-0064's invariant is "an agent's tool set is the union of its

0 commit comments

Comments
 (0)