Skip to content

Commit 4d7bebf

Browse files
authored
feat(spec)!: reject unknown keys on RLS policies, sharing rules, and positions (#4001 step 2) (#4099)
Second click of the unknown-key strictness ratchet (first: flow + permission, #4071), covering the remaining small security-class authoring surfaces from the ledger's next-target list: - security/rls.zod.ts: RowLevelSecurityPolicySchema is .strict() — a silently dropped key meant a row-level restriction was never compiled into the filter. The runtime shapes stay tolerant; the retired `priority` keeps its tombstone. - security/sharing.zod.ts: the sharing-rule base, criteria extension, and sharedWith recipient are .strict() (strictness and the error map ride the base into the extension). - identity/position.zod.ts: PositionSchema is .strict() and gains the protection block + ADR-0010 runtime envelope, closing the sibling gap the #4071 ledger flagged. Fourth ledger finding: position.test.ts asserted a fictional hierarchy — a pre-ADR-0090 `parent` key that only ever "passed" because .strip ate it. Rewritten to assert rejection with the flatness guidance. Verified: spec 6973 tests + tsc clean; all 12 check gates; plugin-security 677 / lint 546 / metadata 276 / platform-objects 239 / metadata-protocol 99; dogfood 72 files / 418 tests; showcase / crm / todo validate clean.
1 parent b3a3d83 commit 4d7bebf

11 files changed

Lines changed: 429 additions & 48 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: reject unknown keys on RLS policies, sharing rules, and positions (#4001 step 2)
6+
7+
Second click of the unknown-key strictness ratchet (first: flow + permission,
8+
#4071), extending `.strict()` + the `strictUnknownKeyError` fixable-error
9+
factory to the remaining small security-class authoring surfaces, per
10+
`docs/audits/2026-07-unknown-key-strictness-ledger.md`:
11+
12+
- **`security/rls.zod.ts`**`RowLevelSecurityPolicySchema` is `.strict()`.
13+
A silently dropped key on an RLS policy meant a row-level restriction the
14+
author wrote was never compiled into the filter. The runtime shapes
15+
(`RLSUserContextSchema`, `RLSEvaluationResultSchema`) stay tolerant. The
16+
retired `priority` key keeps its existing tombstone.
17+
- **`security/sharing.zod.ts`** — the sharing-rule surface is `.strict()`
18+
(base + criteria extension + the `sharedWith` recipient shape). A silently
19+
dropped key meant a share the author intended was never materialised.
20+
- **`identity/position.zod.ts`**`PositionSchema` is `.strict()`, and gains
21+
the author-facing `protection` block plus the ADR-0010 runtime protection
22+
envelope (`_lock`, `_packageId`, `_provenance`, …) — closing the sibling
23+
gap the #4071 ledger flagged: `applyProtection` stamps every registered
24+
metadata type, and position was the last one whose schema could not
25+
represent the stamp.
26+
27+
**Migration.** Any key these schemas now reject was previously stripped and
28+
had **no runtime effect** — removing or renaming it never changes behavior.
29+
The error carries the fix; FROM → TO mappings baked in include:
30+
31+
- RLS policy: `roles`/`role``positions` (ADR-0090 D3 rename),
32+
`withCheck``check` (the PostgreSQL spelling), `condition`/`filter`/`where`
33+
`using`. `priority` stays a tombstone (#3896: OR-combined policies have no
34+
precedence to order — delete the key).
35+
- Sharing rule: `criteria``condition` (the persisted row spells the
36+
compiled predicate `criteria_json`; the authored key is the CEL
37+
`condition`), `access`/`level``accessLevel`,
38+
`recipient`/`shareWith`/`sharedTo``sharedWith`, `enabled``active`;
39+
recipient `id`/`target``value`. `ownedBy` carries the removed
40+
owner-type-rule prescription (only `criteria` rules are authorable).
41+
- Position: `title``label`; `permissionSets` / `users` are runtime
42+
bindings (`sys_position_permission_set` / `sys_user_position`), never
43+
authored on the position; `parent` is rejected with the flatness rule
44+
(ADR-0090 D3 — hierarchy is the business-unit tree, not a position tree).

content/docs/references/identity/position.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ const result = Position.parse(data);
8585
| **label** | `string` || Display label (e.g. VP of Sales) |
8686
| **description** | `string` | optional | |
8787
| **delegatable** | `boolean` || ADR-0091 D3: holders may self-service delegate this position, time-boxed (default false). |
88+
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this position. |
89+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
90+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
91+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
92+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
93+
| **_packageId** | `string` | optional | Owning package machine id. |
94+
| **_packageVersion** | `string` | optional | Owning package version. |
95+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
8896

8997

9098
---

content/docs/releases/v17.mdx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ 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)
310+
### Flow, permission, RLS, sharing and position schemas reject unknown keys (#4001)
311311

312312
Zod's default is `.strip`: a key a schema does not declare is silently
313313
discarded and the instance keeps parsing. On an authorable surface that is the
@@ -327,6 +327,20 @@ schema to the two highest-risk authorable surfaces, per the triage in
327327
`FlowVariableSchema`. A node's `config` stays an **open** record: it is
328328
per-node-type, owned by the executor's `configSchema` and the conversion
329329
layer (see the alias table above).
330+
- **RLS policies**`RowLevelSecurityPolicySchema`. A silently dropped key
331+
here meant a row-level restriction the author wrote was never compiled into
332+
the filter (the pre-ADR-0090 D3 vocabulary → `positions`,
333+
`withCheck``check`, `condition`/`filter`/`where``using`). The runtime
334+
evaluation shapes stay tolerant.
335+
- **Sharing rules** — the rule, its criteria extension, and the `sharedWith`
336+
recipient (`criteria``condition`, `access``accessLevel`,
337+
`recipient``sharedWith`; `ownedBy` carries the removed owner-type-rule
338+
prescription).
339+
- **Positions**`PositionSchema`, including the guidance that
340+
`permissionSets`/`users` are runtime bindings and `parent` has no meaning on
341+
a deliberately flat position (ADR-0090 D3). Position also gains the
342+
`protection` block and ADR-0010 runtime envelope every sibling registered
343+
type already declared.
330344

331345
Every rejection is written to be self-fixing: it names the offending key and,
332346
where recognisable, the canonical spelling (`steps``nodes`, edge

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,19 @@ strict gate could prove it. Note the asymmetry in the two schema gaps — both
9898
were *inverse* drift (runtime writes a key the spec cannot express), which the
9999
liveness ledger's per-property direction cannot see.
100100

101-
**Known sibling gap (follow-up, not this step):** `identity/position.zod.ts`
102-
— the other registered security type — also omits `MetadataProtectionFields`
103-
while `applyProtection` stamps it. Harmless today because the schema is not
104-
strict (the keys are silently stripped, as permission's were), but it must be
105-
declared before `position` joins the ratchet.
101+
**Known sibling gap — CLOSED in step 2:** `identity/position.zod.ts` — the
102+
other registered security type — also omitted `MetadataProtectionFields` while
103+
`applyProtection` stamps it. Declared (with the author-facing `protection`
104+
block) when `position` joined the ratchet.
105+
106+
4. **`position.test.ts` asserted a fictional hierarchy** (found in step 2 when
107+
`PositionSchema` went strict): the pre-ADR-0090 test "should accept position
108+
with parent" — plus four "real-world hierarchy" examples — authored a
109+
`parent` key on positions. It only ever "passed" because `.strip` ate the
110+
key; no position tree exists (ADR-0090 D3 finalized flatness; hierarchy is
111+
the business-unit tree). The tests were codifying the strip-era fiction as
112+
expected behavior. Rewritten: `parent` is now asserted to be *rejected* with
113+
the flatness guidance.
106114

107115
## File-level triage — the five authorable directories
108116

@@ -168,8 +176,8 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
168176
|---|---|---|---|
169177
| `explain.zod.ts` | 11 | wire | permission-explain responses — never strict |
170178
| `permission.zod.ts` | 4 | authorable | **strict as of #4001**; `EffectiveObjectPermissionSchema` explicitly `.strip()`s (wire) |
171-
| `rls.zod.ts` | 3 | authorable | **next candidate** a stripped RLS key is a silent policy hole |
172-
| `sharing.zod.ts` | 2 | authorable | **next candidate**same class |
179+
| `rls.zod.ts` | 3 | authorable | **`RowLevelSecurityPolicySchema` strict as of #4001 step 2** (a stripped RLS key is a silent policy hole); `RLSUserContextSchema` / `RLSEvaluationResultSchema` are runtime shapes — stay tolerant |
180+
| `sharing.zod.ts` | 2 | authorable | **strict as of #4001 step 2**rule + recipient shapes; strictness and the error map ride the base into the criteria extension |
173181

174182
### `studio/` — 27 sites
175183

@@ -189,20 +197,23 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
189197
| `cloud/` | 83 | wire | multi-tenant runtime |
190198
| `ai/` | 75 | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire |
191199
| `integration/` | 64 | wire | connector payloads — upstream adds fields freely |
192-
| `identity/` | 34 | mixed | position/user shapes authored; auth payloads wire |
200+
| `identity/` | 34 | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire |
193201
| `shared/` | 25 | n/a | utilities and building blocks; strictness decided at the consuming schema |
194202
| `qa/` | 6 | n/a | test fixtures |
195203

196204
## Next steps (verify-then-enforce, one shape at a time)
197205

198206
1. `ui/app.zod.ts``AppSchema` + navigation union (highest-traffic remaining
199207
authorable type; needs union-error design so the strict error is readable).
200-
2. `security/rls.zod.ts` + `security/sharing.zod.ts` — small, security-class.
201-
3. `automation/approval.zod.ts` — new v17 authoring surface, tighten while young.
202-
4. `data/hook.zod.ts`, `data/datasource.zod.ts``defineHook` / stack config.
203-
5. Promote this ledger to a machine-checked gate (pattern of
208+
2. `automation/approval.zod.ts` — new v17 authoring surface, tighten while young.
209+
3. `data/hook.zod.ts`, `data/datasource.zod.ts``defineHook` / stack config.
210+
4. Promote this ledger to a machine-checked gate (pattern of
204211
`packages/spec/liveness/` + `check:liveness`) once enough of the surface is
205212
classified that the table above is enforceable rather than descriptive.
206213

214+
Done in step 2 (this PR): `security/rls.zod.ts` + `security/sharing.zod.ts`
215+
strict; `PositionSchema` strict with the protection envelope declared (closing
216+
the known sibling gap below).
217+
207218
Long tail stays gated on a verification pass per shape — never a one-shot
208219
"make all ~453 sites strict" (ADR-0054 ratchet; #4001's own recommendation).

packages/spec/authorable-surface.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3876,10 +3876,18 @@
38763876
"identity/Organization:name",
38773877
"identity/Organization:slug",
38783878
"identity/Organization:updatedAt",
3879+
"identity/Position:_lock",
3880+
"identity/Position:_lockDocsUrl",
3881+
"identity/Position:_lockReason",
3882+
"identity/Position:_lockSource",
3883+
"identity/Position:_packageId",
3884+
"identity/Position:_packageVersion",
3885+
"identity/Position:_provenance",
38793886
"identity/Position:delegatable",
38803887
"identity/Position:description",
38813888
"identity/Position:label",
38823889
"identity/Position:name",
3890+
"identity/Position:protection",
38833891
"identity/SCIMAddress:country",
38843892
"identity/SCIMAddress:formatted",
38853893
"identity/SCIMAddress:locality",

packages/spec/src/identity/position.test.ts

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -61,19 +61,24 @@ describe('PositionSchema', () => {
6161
expect(() => PositionSchema.parse(position)).not.toThrow();
6262
});
6363

64-
it('should accept position with parent', () => {
65-
const position: Position = {
64+
it('rejects the retired `parent` hierarchy key (positions are flat, ADR-0090 D3)', () => {
65+
// Pre-ADR-0090 this test asserted `parent` parsed — which only "worked"
66+
// because zod's default `.strip` silently ate the key; no position tree
67+
// ever existed. Since #4001 the fiction is a loud error instead.
68+
const result = PositionSchema.safeParse({
6669
name: 'vp_sales',
6770
label: 'VP of Sales',
6871
parent: 'ceo',
69-
};
70-
71-
expect(() => PositionSchema.parse(position)).not.toThrow();
72+
});
73+
expect(result.success).toBe(false);
74+
expect(
75+
result.error!.issues.find((i) => i.code === 'unrecognized_keys')!.message,
76+
).toContain('business-unit');
7277
});
7378
});
7479

7580
describe('Real-World Position Examples', () => {
76-
it('should accept complete sales organization hierarchy', () => {
81+
it('should accept a complete sales organization (flat — ADR-0090 D3)', () => {
7782
const positions: Position[] = [
7883
{
7984
name: 'ceo',
@@ -83,31 +88,26 @@ describe('PositionSchema', () => {
8388
{
8489
name: 'vp_sales',
8590
label: 'VP of Sales',
86-
parent: 'ceo',
8791
description: 'Leads entire sales organization',
8892
},
8993
{
9094
name: 'regional_sales_director',
9195
label: 'Regional Sales Director',
92-
parent: 'vp_sales',
9396
description: 'Manages sales for a specific region',
9497
},
9598
{
9699
name: 'sales_manager',
97100
label: 'Sales Manager',
98-
parent: 'regional_sales_director',
99101
description: 'Manages a team of sales representatives',
100102
},
101103
{
102104
name: 'senior_sales_rep',
103105
label: 'Senior Sales Representative',
104-
parent: 'sales_manager',
105106
description: 'Senior member of sales team',
106107
},
107108
{
108109
name: 'sales_rep',
109110
label: 'Sales Representative',
110-
parent: 'sales_manager',
111111
description: 'Individual contributor in sales',
112112
},
113113
];
@@ -117,7 +117,7 @@ describe('PositionSchema', () => {
117117
});
118118
});
119119

120-
it('should accept service organization hierarchy', () => {
120+
it('should accept a service organization (flat)', () => {
121121
const positions: Position[] = [
122122
{
123123
name: 'vp_customer_success',
@@ -126,17 +126,14 @@ describe('PositionSchema', () => {
126126
{
127127
name: 'support_manager',
128128
label: 'Support Manager',
129-
parent: 'vp_customer_success',
130129
},
131130
{
132131
name: 'senior_support_agent',
133132
label: 'Senior Support Agent',
134-
parent: 'support_manager',
135133
},
136134
{
137135
name: 'support_agent',
138136
label: 'Support Agent',
139-
parent: 'support_manager',
140137
},
141138
];
142139

@@ -145,7 +142,7 @@ describe('PositionSchema', () => {
145142
});
146143
});
147144

148-
it('should accept product organization hierarchy', () => {
145+
it('should accept a product organization (flat)', () => {
149146
const positions: Position[] = [
150147
{
151148
name: 'cto',
@@ -154,28 +151,23 @@ describe('PositionSchema', () => {
154151
{
155152
name: 'vp_engineering',
156153
label: 'VP of Engineering',
157-
parent: 'cto',
158154
},
159155
{
160156
name: 'engineering_manager',
161157
label: 'Engineering Manager',
162-
parent: 'vp_engineering',
163158
description: 'Manages engineering team',
164159
},
165160
{
166161
name: 'tech_lead',
167162
label: 'Technical Lead',
168-
parent: 'engineering_manager',
169163
},
170164
{
171165
name: 'senior_engineer',
172166
label: 'Senior Software Engineer',
173-
parent: 'engineering_manager',
174167
},
175168
{
176169
name: 'engineer',
177170
label: 'Software Engineer',
178-
parent: 'engineering_manager',
179171
},
180172
];
181173

@@ -184,7 +176,7 @@ describe('PositionSchema', () => {
184176
});
185177
});
186178

187-
it('should accept matrix organization with multiple reporting lines', () => {
179+
it('should accept a matrix organization (reporting lines live on sys_user.manager_id, not positions)', () => {
188180
const positions: Position[] = [
189181
{
190182
name: 'ceo',
@@ -193,27 +185,22 @@ describe('PositionSchema', () => {
193185
{
194186
name: 'regional_vp_americas',
195187
label: 'Regional VP - Americas',
196-
parent: 'ceo',
197188
},
198189
{
199190
name: 'regional_vp_emea',
200191
label: 'Regional VP - EMEA',
201-
parent: 'ceo',
202192
},
203193
{
204194
name: 'regional_vp_apac',
205195
label: 'Regional VP - APAC',
206-
parent: 'ceo',
207196
},
208197
{
209198
name: 'country_manager_us',
210199
label: 'Country Manager - US',
211-
parent: 'regional_vp_americas',
212200
},
213201
{
214202
name: 'country_manager_uk',
215203
label: 'Country Manager - UK',
216-
parent: 'regional_vp_emea',
217204
},
218205
];
219206

@@ -231,7 +218,6 @@ describe('PositionSchema', () => {
231218
{
232219
name: 'team_member',
233220
label: 'Team Member',
234-
parent: 'founder',
235221
},
236222
];
237223

@@ -241,3 +227,51 @@ describe('PositionSchema', () => {
241227
});
242228
});
243229
});
230+
231+
// #4001 step 2 — PositionSchema joins the strict ratchet, and gains the
232+
// ADR-0010 protection envelope the #4071 ledger flagged as the known sibling
233+
// gap (applyProtection stamps EVERY registered metadata type; position could
234+
// not represent the stamp).
235+
describe('unknown keys are rejected, not stripped (#4001)', () => {
236+
const unknownKeyIssue = (value: unknown) => {
237+
const result = PositionSchema.safeParse(value);
238+
expect(result.success).toBe(false);
239+
return result.error!.issues.find((i) => i.code === 'unrecognized_keys');
240+
};
241+
242+
it('rejects an undeclared key instead of silently dropping it', () => {
243+
expect(unknownKeyIssue({ name: 'p', label: 'P', notAKey: 1 })!.message)
244+
.toContain('`notAKey`');
245+
});
246+
247+
it('points permissionSets/users/parent at the runtime binding or flatness rule', () => {
248+
expect(unknownKeyIssue({ name: 'p', label: 'P', permissionSets: [] })!.message)
249+
.toContain('sys_position_permission_set');
250+
expect(unknownKeyIssue({ name: 'p', label: 'P', users: [] })!.message)
251+
.toContain('sys_user_position');
252+
expect(unknownKeyIssue({ name: 'p', label: 'P', parent: 'boss' })!.message)
253+
.toContain('FLAT');
254+
});
255+
256+
it('round-trips the ADR-0010 runtime protection envelope', () => {
257+
const parsed = PositionSchema.parse({
258+
name: 'auditor', label: 'Auditor',
259+
_packageId: 'com.showcase', _provenance: 'package', _lock: 'full',
260+
});
261+
expect(parsed._packageId).toBe('com.showcase');
262+
expect(parsed._lock).toBe('full');
263+
});
264+
265+
it('accepts every key the schema declares (guards POSITION_KEYS drift)', () => {
266+
const probes: Record<string, unknown> = {
267+
description: 'd', delegatable: true, protection: { lock: 'none' },
268+
};
269+
for (const [key, value] of Object.entries(probes)) {
270+
const result = PositionSchema.safeParse({ name: 'p', label: 'P', [key]: value });
271+
const unknown = result.success
272+
? undefined
273+
: result.error.issues.find((i) => i.code === 'unrecognized_keys');
274+
expect(unknown, `\`${key}\` should be a declared Position key`).toBeUndefined();
275+
}
276+
});
277+
});

0 commit comments

Comments
 (0)