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
56 changes: 56 additions & 0 deletions .changeset/unknown-key-strictness-tier-a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/spec": major
---

feat(spec)!: reject unknown keys on the flow and permission authoring schemas (#4001 Tier-A)

Zod's default `.strip` silently discarded any key these schemas did not
declare — the instance kept parsing, so a mis-spelled or wrong-layer key
shipped as metadata that quietly ignored the author's config (#3405's
action-param `reference`, #1535's object-level `workflows`). #3746 tightened
one schema; this extends the same treatment to the two highest-risk
authorable surfaces, per the ADR-0054 ratchet and the
`docs/audits/2026-07-unknown-key-strictness-ledger.md` triage:

- **`security/permission.zod.ts`** — `PermissionSetSchema`,
`ObjectPermissionSchema`, `FieldPermissionSchema`, `AdminScopeSchema` are
now `.strict()`. A silently dropped key on the capability container meant
the author believed a grant or restriction was in place that the runtime
never saw. `EffectiveObjectPermissionSchema` (response-side) explicitly
`.strip()`s back and stays wire-tolerant.
- **`automation/flow.zod.ts`** — `FlowSchema`, `FlowNodeSchema`,
`FlowEdgeSchema`, `FlowVariableSchema` are now `.strict()`. A node's
`config` record stays **open**: it is per-node-type, owned by the
registered executor's `configSchema` (#4027/#4040) and the ADR-0087
conversion layer.
- **`shared/suggestions.zod.ts`** — new `strictUnknownKeyError` factory (the
#3746 hand-rolled map, generalized): every rejection names the offending
key(s) and, where recognisable, the canonical spelling or a retired-key
tombstone. `ui/action.zod.ts` re-homes onto it with byte-identical messages.
- **`PermissionSetSchema` gains `description`, `protection` and the ADR-0010
runtime protection envelope (`_lock`, `_packageId`, `_provenance`, …).** The
strict gate's own catches: all of these are written by real code — the
built-in default sets author `description` and the Setup projection reads
it; `applyProtection` stamps the envelope on every metadata type and
`getMetaItemLayered` → `saveMetaItem` round-trips it — but the schema could
not represent them, so they were silently stripped at every parse (ADR-0078
§3 inverse drift). Every sibling registered metadata type already spread
`MetadataProtectionFields`; permission was the outlier.

**Migration.** Any key these schemas now reject was previously stripped and
therefore had **no runtime effect** — removing or renaming it never changes
the behavior of a working app; validation simply stops lying about it. The
error message carries the fix; the FROM → TO mappings baked into it include:

- Permission set: `objectPermissions`→`objects`, `fieldPermissions`/`fls`→`fields`,
`tabs`→`tabPermissions`, `rls`/`policies`→`rowLevelSecurity`;
`read`/`edit`/`export`/…→`allowRead`/`allowEdit`/`allowExport`/…;
`readable`/`editable` vocabulary for FLS (`hidden` → declare `readable: false`).
Retired keys carry tombstones: `contextVariables` (ADR-0105 D11 — use a
registered `rlsMembership` resolver or an inline literal), `isProfile`
(ADR-0090 D2 — use `isDefault`).
- Flow: `steps`→`nodes`, `connections`/`transitions`/`links`→`edges`,
`trigger`/`triggerType`→`type`, `title`→`label`; edge `from`/`to`→`source`/`target`,
`guard`/`when`/`expression`→`condition`; a top-level `object`/`objectName`/`schedule`
belongs on the START node's `config` (`{ objectName, triggerType, condition,
schedule }`), not on the flow.
9 changes: 9 additions & 0 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const result = AdminScope.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Permission set unique name (lowercase snake_case) |
| **label** | `string` | optional | Display label |
| **description** | `string` | optional | Human-readable description shown in Setup (persisted as sys_permission_set.description) |
| **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) |
| **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) |
| **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 |
Expand All @@ -136,6 +137,14 @@ const result = AdminScope.parse(data);
| **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) |
| **rowLevelSecurity** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Row-level security policies (see rls.zod.ts for full spec) |
| **adminScope** | `{ businessUnit: string; includeSubtree: boolean; manageAssignments: boolean; manageBindings: boolean; … }` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this permission set. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
| **_packageId** | `string` | optional | Owning package machine id. |
| **_packageVersion** | `string` | optional | Owning package version. |
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |


---
Expand Down
42 changes: 42 additions & 0 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,48 @@ model), while `url` platform-wide means an HTTP endpoint to call (`http` node,
webhooks). The singular `input` on `map` / `subflow` / `connector_action` is
those nodes' own canonical key and is untouched.

### Flow and permission schemas reject unknown keys (#4001)

Zod's default is `.strip`: a key a schema does not declare is silently
discarded and the instance keeps parsing. On an authorable surface that is the
worst failure mode — the author (human or AI) gets a success envelope and
ships metadata that quietly ignores their config; that is exactly how a
correctly intended action-param `reference: 'sys_user'` degraded into a
paste-a-UUID text box (#3405). 17.0 extends #3746's strictness from that one
schema to the two highest-risk authorable surfaces, per the triage in
`docs/audits/2026-07-unknown-key-strictness-ledger.md`:

- **Permission sets** — `PermissionSetSchema`, `ObjectPermissionSchema`,
`FieldPermissionSchema`, `AdminScopeSchema`. A silently dropped key on the
capability container meant the author believed a grant or restriction was in
place that the runtime never saw. The response-side
`EffectiveObjectPermissionSchema` stays wire-tolerant.
- **Flows** — `FlowSchema`, `FlowNodeSchema`, `FlowEdgeSchema`,
`FlowVariableSchema`. A node's `config` stays an **open** record: it is
per-node-type, owned by the executor's `configSchema` and the conversion
layer (see the alias table above).

Every rejection is written to be self-fixing: it names the offending key and,
where recognisable, the canonical spelling (`steps` → `nodes`, edge
`from`/`to` → `source`/`target`, `read` → `allowRead`, `tabs` →
`tabPermissions`), a wrong-layer pointer (a flow-level `objectName` belongs on
the start node's `config`; `apiOperations` is response-side only), or a
retired-key tombstone (`contextVariables` → ADR-0105 D11's `rlsMembership`
resolvers; `isProfile` → `isDefault`, ADR-0090 D2).

Migration is by construction behavior-preserving: any key now rejected was
previously stripped, so it never had a runtime effect — remove it or rename it
to the canonical key the error suggests; nothing about a working app changes.

The gate paid for itself immediately by exposing keys the platform *writes*
but the spec could not express, so `PermissionSetSchema` gains three: the
**`description`** shown in Setup (persisted to
`sys_permission_set.description`), the author-facing **`protection`** block,
and the ADR-0010 **runtime protection envelope** (`_lock`, `_packageId`,
`_provenance`, …) that every sibling metadata type already carried — without
it, a package-owned permission set could not round-trip through an
environment overlay.

### `agent.tools[]` is removed — capability comes from skills (ADR-0109, #3820)

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