Skip to content

Commit c8cb510

Browse files
objectstack-fleet[bot]zhuangjianguoclaude
authored
feat(spec,security): a field permission entry can carry a row condition (when) on its edit vote (#25866) (#25980)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb7q6UepuLZRqHF7Q7j1iobb)_ Fixes #25866 Clause-②: yes (widening) Metadata-protocol: added Ruling-ref: 6408558941 ## What lands `FieldPermissionSchema` gains an optional `when`: a row condition in the same canonical CEL as `rowLevelSecurity[].using`, on the **edit** vote only (letter A of the ruling, comment 6408558941). - **Spec** (`packages/spec/src/security/permission.zod.ts`). `when` is a non-blank string; `readable: false` beside it is refused at parse with a message that says the condition reaches the edit vote only; `condition` / `where` / `filter` are refused with a pointer to `when`. The describe states the whole contract (absent votes on every row; present votes as declared on matching rows and is **absent** elsewhere; stored row before an update, incoming row on an insert; unjudgeable rows are the lock; validated like an RLS predicate; row-blind readers report the lock). Generated artefacts regenerated (`authorable-surface/security.json`, `content/docs/references/{api/protocol,security/permission}.mdx`, `liveness/state-counts/permission.md`); `dropped-refinements.baseline.json` gains the `security/FieldPermission` root site and the six `fields.valueType` sites the build printed; `liveness/permission.json` classifies `fields.when` live with its readers. - **Core** (`effective-object-permissions.ts`). `fieldPermissionVote(entry, verdict)` is the ONE reading of an entry under a tri-state verdict (`matched` / `unmatched` / `unjudged`), and `buildEffectiveFieldPermissions(sets)` is the row-blind most-permissive merge the hono endpoint now calls. Bits are read `=== true`, byte-identical to the previous merges for an entry without `when`. - **RLS compiler** (`rls-compiler.ts`). The `current_user` builder is extracted from `compileFilter` into one private `userContextOf` (reserved-key refusal included), and `compileRowPredicate(expression, executionContext, fieldGuard?)` compiles ONE predicate through the same compile, field-existence judge and comparand faces a `check` clause takes — no OR-combination, no deny sentinel. ⛔ Not the `ql.findOne` query rewrite of the `using` gate: the row is judged in process. - **Evaluator** (`permission-evaluator.ts`). `getFieldPermissions(object, sets, judge?)` votes each entry through `fieldPermissionVote`; without a judge a conditioned entry is `unjudged` (its lock). `conditionedFieldEntries(object, sets)` lists them for explain. - **Write gate** (`security-plugin-object-inputs.ts` → `computeForbiddenFieldWrites`, now async, with a `row` argument). Fast path when no set carries a `when` for the object. Otherwise each distinct `when` is compiled once (`compileRowPredicate` with the object's field guard) and judged by the gate's own `storedFormCheckJudge`: each incoming row of an insert on its own values (the union is refused), the caller-read stored row of a by-id update (`getCallerPreImage`, fetched lazily and only when a `when` exists). A null pre-image, a predicate update, a reader that holds no row, or a condition that yields no filter for this request applies the entry as its lock and WARNs why. Wired at the middleware's field step (2.5) and in `canWriteObject` (`security-plugin-object-admission.ts`: inserts judged on the payload, updates applied as the lock since the method holds no row). - **Explain** (`explain-engine.ts`). The `fls` layer appends a sentence naming each row-conditioned entry as such: `amount (locked when status == 'approved', set "hr_user")` — judged per row by the write gate, applied as the lock elsewhere. - **Lint** (`validate-rls-predicate-enforceability.ts`). A new `when` clause: `permissions[].fields["<object>.<field>"].when` is walked by the passes `using` / `check` take, against the key's object (the head before the first `.`), with the cost named: the entry cannot be judged and is applied as its lock on every row. Ids: the existing `rls-predicate-*` five. - **Hono** (`current-user-endpoints.ts`). The inline `fields` merge of `/auth/me/permissions` is `buildEffectiveFieldPermissions(resolved)`: row-blind, so a conditioned entry is reported as its lock and no client is told of an edit the gate refuses. - **ADR-0066 ⑧** gains the dated note (2026-10-26): the row condition narrows where an entry votes and does not change the union-without-deny posture. The ADR anchor for `permission-evaluator.ts` already pins ADR-0066, so no anchor file changes. - **Changeset** `.changeset/25866-field-permission-row-condition.md`: `minor` on spec, core, plugin-security, lint, plugin-hono-server. ## Readers of `getFieldPermissions` — each one's conclusion | Reader | Conclusion | | --- | --- | | `security-plugin-explain-access.ts` (`getFieldMask`, `getPartialMaskRules`) | Row-blind by design; no judge passed, so a conditioned entry is its lock. The explain report additionally names each such entry in the `fls` layer. No change to the mask derivation. | | `security-plugin-field-readers.ts` (writable-field projection, :188/:212) | Row-blind; lock. A form may show a field read-only on a row the gate would admit — the honest direction (never wider than the gate). Unchanged code. | | `security-plugin-object-inputs.ts` `computeQueryGuardFieldPerms` (:460/:466) and `maskOperationResult` | Row-blind; lock (read bits are row-blind by contract, so the masker's answer is unchanged). Unchanged code. | | `packages/lint/src/validate-security-posture.ts` :755 / :814 | Reads the entry's key qualification and field existence only; `when` changes neither. The predicate itself is judged in `validate-rls-predicate-enforceability.ts` (see deviations). Unchanged code. | | `packages/core/src/security/effective-object-permissions.ts` ~:546 | The `objects` merge is untouched; the `fields` merge now lives beside it (`buildEffectiveFieldPermissions`) and reads the lock. | | `plugin-hono-server/src/current-user-endpoints.ts` ~:900 | Calls the core merge; row-blind lock. | ## Landing-point deviations from the Claim (6410022810) 1. **Lint landed in `validate-rls-predicate-enforceability.ts`, not `validate-security-posture.ts`.** The ruling says "validated like an RLS predicate (field names exist, no cross-object)". That rule already owns the compiler-backed passes (`isSupportedRlsExpression`, the reference pass, the comparand faces) and the five `rls-predicate-*` ids; a second spelling in `validate-security-posture.ts` would be the drift the rule exists to avoid. `validate-security-posture.ts` is unchanged. 2. **`plugin-hono-server/src/current-user-endpoints.ts` is touched** (outside the Claim's parenthetical list, inside its "other readers" obligation): it carried an inline copy of the field merge, which would otherwise have reported a conditioned entry as editable. 3. **Spec generated artefacts** beyond `json-schema`: the build also rewrites `authorable-surface/security.json`, two reference `.mdx` pages, the dropped-refinements ledger and the liveness ledger + state count. All in the Claim's "+ generated spec artefacts". 4. **Three hand-written doc pages** are touched: `content/docs/permissions/field-level-security.mdx` and `content/docs/protocol/objectql/security.mdx` gain a `when` paragraph (both stated that a field permission entry has exactly two keys), and the validation matrix row in `content/docs/deployment/validating-metadata.mdx` now names the field permission `when` among what the RLS predicate rule judges. The Docs Drift Check on this PR named all three; leaving the sentences false is the drift the documentation guardrail exists to stop. Hand-written trees are editable per AGENTS.md; no release-owned page is touched. ## Not measured here - Studio form (`../objectui`) is not readable from this seat: the generated JSON Schema / authorable surface carries `when`, so the form's key offer is the schema's; the Studio reading itself is unmeasured. - hotclm#126/#127 are not readable from this seat; the shape in the tests is the card's (a lock on a stage field from `approved` onward). ## 维护者速读(草稿) - 字段权限条目新增可选 `when`(与 RLS `using` 同一 CEL),只作用于 **editable**;`readable: false` 与 `when` 同时出现在解析期拒绝。 - 条件**只收窄投票范围**:匹配行按声明投票,不匹配行视为未声明;不改变"并集不否决"的 ⑧ 姿态(ADR-0066 已加 2026-10-26 注)。 - 写门在 update 前判定**库中行**、insert 判定**入参行**,进程内判定(`compileRowPredicate` + `storedFormCheckJudge`),不走查询改写;无法判定(谓词更新、读不到的行、本请求无法解析的条件)按**锁**处理并 WARN。 - 不持有行的读取方(`/auth/me/permissions`、字段投影、`os explain`)一律按锁上报,保证不比写门更宽。 - lint 复用 `validateRlsPredicateEnforceability` 的全部通道判定 `permissions[].fields[].when`。 - Tier H(ADR 改动),保持草稿,待维护者批准。 ## Acceptance notes - Spec: `permission.test.ts` — conditioned lock/grant accepted, `readable:false`+`when` refused at `when` with the stated message, blank/null refused, aliases pointed at `when`, rides on `PermissionSetSchema`. - Core: `fieldPermissionVote` under the three verdicts; `buildEffectiveFieldPermissions` row-blind lock, union without deny, fresh entries. - Plugin-security (`security-plugin.test.ts`): insert draft admitted / approved refused / bulk union; by-id update judged on the stored row (approved pre-image refused even when the payload moves to draft; draft pre-image admitted even when the payload approves) with ONE caller-context by-id read; predicate update → lock + WARN; null pre-image → 404 upstream at 2.7 and the gate's own derivation → lock + WARN; `current_user.id` resolves against the caller; unresolvable `current_user.nope` → lock + WARN; `canWriteObject` insert-on-payload / update-as-lock. Evaluator: matched / unmatched / unjudged votes, union with a sibling grant, `conditionedFieldEntries`. Explain: the `fls` sentence. - Lint: the four injections at the `when` site, path/where/message, silent on a valid condition, key-object targeting, blank skipped. ## Verification See the `os-dev-report` comment on #25866 for the gate run record (exit codes captured before any pipe, reconciled with `dispatch-gates --ran`) and the per-package typecheck/test results. --- _Generated by [Claude Code](https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd --------- Co-authored-by: zhuangjianguo <zhuangjianguo@steedos.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a6ebff commit c8cb510

28 files changed

Lines changed: 1132 additions & 96 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/lint': minor
6+
'@objectstack/plugin-hono-server': minor
7+
---
8+
9+
feat(spec,security): a field permission entry can carry a row condition (`when`) on its edit vote
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: added
13+
Ruling-ref: 6408558941
14+
15+
- **Before.** A `fields["<object>.<field>"]` entry voted `readable` / `editable` on every row of the object. A lock that should apply on some rows only (the amount of an approved opportunity, say) had no spelling: a validation rule or a hook carried it, outside the permission model and outside `os explain`.
16+
- **Now.** `FieldPermissionSchema.when` is an optional row condition, authored in the same canonical CEL as `rowLevelSecurity[].using` (`status == 'approved'`, `owner_id == current_user.id`, …). An entry with a `when` votes as declared on the rows the condition names and is **absent** on the others, as if the set had not declared the field; an entry without one votes on every row, as before. The vote reaches the **edit** bit only — reads are row-blind — so `readable: false` beside a `when` is refused at parse, and the message says why. `condition`, `where` and `filter` are refused with a pointer to `when`.
17+
- **Judged on the row the write changes.** The write gate (the engine middleware's field-level step and `canWriteObject`) judges the **stored** row before an update, read as the caller reads it, and each **incoming** row of an insert — in process, by `RLSCompiler.compileRowPredicate` (the compile, `current_user` context, field guard and comparand faces an RLS `check` clause takes) and the write gate's own stored-form judge. Never a query rewrite. A row that cannot be judged — a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request — applies the entry as its **lock** (no edit grant), and a WARN line says why.
18+
- **Row-blind readers apply the lock.** `/auth/me/permissions` (`buildEffectiveFieldPermissions`, new in `@objectstack/core`, the one vote `PermissionEvaluator.getFieldPermissions` takes per object), the writable-field projections, the query guard and the result masker hold no row, so a conditioned entry is reported as its lock: no client is told of an edit the gate refuses. `os explain`'s `fls` layer names each row-conditioned entry as such — judged per row by the write gate, applied as the lock elsewhere.
19+
- **Validated like an RLS predicate.** `@objectstack/lint`'s `validateRlsPredicateEnforceability` walks `permissions[].fields[].when` by the passes `using` / `check` take, against the key's object: `rls-predicate-unenforceable`, `-unparseable`, `-over-budget`, `-unknown-field`, `-unknown-user-variable`, with the cost named (the entry cannot be judged and is applied as its lock on every row).
20+
- **Unchanged.** Field grants union without deny: a matched conditioned lock is out-voted by a sibling's unconditioned `editable: true` exactly as before, and only declaring entries vote. The `/auth/me/permissions` `fields` merge keeps its most-permissive rule. Recorded under ADR-0066 ⑧ (note of 2026-10-26).

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
488488
| Organization-axis red lines (ADR-0105 D6) | ✓ | ✓ | ✓ | — |
489489
| Declared enforcement that cannot run, **declared on the object being written** — a validation rule's regex / JSON Schema (#4762) and its `format` names (#5178) | ✓ | ✓ | ✓ | ✓ᵒ |
490490
| Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698) | ✓ | ✓ | ✓ | — |
491-
| Row-level-security predicates (#4983) — a permission set's policies, an object's `defaultRowScope` and a membership key's `filter`; at the save door also a membership key nothing owns (#25234) | ✓ | ✓ | ✓ | ✓ᵖᵉᵒ |
491+
| Row-level-security predicates (#4983) — a permission set's policies, an object's `defaultRowScope`, a membership key's `filter` and a field permission entry's `when` (#25866); at the save door also a membership key nothing owns (#25234) | ✓ | ✓ | ✓ | ✓ᵖᵉᵒ |
492492
| Membership key declarations — `userField` / `valueField` columns, one key on one object, a key the registered resolver also owns (save door only) (#25234) | ✓ | ✓ | ✓ | ✓ᵒ |
493493
| Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ |
494494
| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ |

‎content/docs/permissions/field-level-security.mdx‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Control visibility and editability of specific fields.
88

99
## Field Permissions in Permission Sets
1010

11-
Field permissions are keyed `<object>.<field>` and use `readable` / `editable`:
11+
Field permissions are keyed `<object>.<field>` and use `readable` / `editable`,
12+
plus an optional `when` — a row condition on the edit vote (below):
1213

1314
```typescript
1415
fields: {
@@ -22,6 +23,48 @@ fields: {
2223
}
2324
```
2425

26+
## Row-conditioned edit locks (`when`)
27+
28+
An entry may carry `when`: a row condition in the same canonical CEL as an
29+
RLS `using` predicate (a field of the object compared with a literal or a
30+
`current_user.*` value; `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, combined with
31+
`&&` / `||`). The entry then votes **only on the rows the condition matches**
32+
and is absent on every other row, as if the set had not declared the field:
33+
34+
```typescript
35+
fields: {
36+
// Locked from approval onward; a draft is still editable.
37+
'opportunity.amount': { readable: true, editable: false, when: "status in ['approved', 'closed']" },
38+
// Editable on the caller's own rows only.
39+
'task.estimate': { readable: true, editable: true, when: 'assigned_to == current_user.id' },
40+
}
41+
```
42+
43+
- **Edit vote only.** Reads are judged row-blind, so `readable: false`
44+
beside a `when` is refused at parse time; keep `readable: true` (the
45+
default) on a row-conditioned entry.
46+
- **Which row.** The write gate judges the **stored** row before an update
47+
(a lock on `status in [...]` holds on an approved row even when the same
48+
write moves `status` back), and each **incoming** row of an insert, as the
49+
caller sent it. The condition is evaluated in process against the row —
50+
never rewritten into the query.
51+
- **When no row can be judged** — a predicate (bulk) update with no row
52+
address, a stored row the caller cannot read, a condition that does not
53+
resolve for the request — the entry is applied as its **lock**: no edit
54+
grant on that write, and a WARN line says why.
55+
- **Readers that hold no row** (`/auth/me/permissions`, the writable-field
56+
projections, `os explain`) report a row-conditioned entry as its lock, so
57+
a client never advertises an edit the gate refuses. `os explain` names
58+
each such entry as a row-conditioned lock in its `fls` layer.
59+
- **Union without deny is unchanged.** A matched conditioned lock is still
60+
out-voted by another set's unconditioned `editable: true`; an entry
61+
without `when` votes on every row, as before (ADR-0066 ⑧, note of
62+
2026-10-26).
63+
- **Validated at authoring.** `objectstack validate` judges `when` like an
64+
RLS predicate (`rls-predicate-*` findings on `permissions[].fields[].when`):
65+
the fields it names must exist on the entry's object, no cross-object
66+
reach, no unresolvable `current_user.*` variable.
67+
2568
## Hidden vs. Read-Only
2669

2770
```typescript

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ rowLevelSecurity:
217217

218218
## 3. Field-Level Security
219219

220-
Field permissions are a map on the permission set keyed by `<object>.<field>`, each with two boolean flags (`FieldPermissionSchema`):
220+
Field permissions are a map on the permission set keyed by `<object>.<field>`, each with two boolean flags and an optional row condition (`FieldPermissionSchema`):
221221

222222
```yaml
223223
# sales_rep.permission.yml (continued)
@@ -256,10 +256,13 @@ fields:
256256
<object>.<field>:
257257
readable: true | false # default true — can see the field
258258
editable: true | false # default false — can modify the field
259+
when: "status == 'approved'" # optional — a row condition on the EDIT vote, in RLS `using`'s CEL
259260
```
260261

261262
> There is no separate `create` flag for fields. `editable` governs both create-time and update-time writes; read stripping is governed by `readable`.
262263
264+
> `when` narrows **where** the entry votes: on a matching row it votes as declared, on every other row it is absent (as if the set had not declared the field). The write gate judges the stored row before an update and each incoming row of an insert, in process; a row it cannot judge (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) applies the entry as its lock. `readable: false` beside a `when` is refused at parse time, and `objectstack validate` judges the condition like an RLS predicate. Readers that hold no row (`/auth/me/permissions`, `os explain`) report the entry as its lock. See [Field-level security](/docs/permissions/field-level-security#row-conditioned-edit-locks-when).
265+
263266
### Static `readonly` fields on the write path
264267

265268
`readonly: true` on a field definition is a *schema-level* lock, independent of permission

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1633,7 +1633,7 @@ One changed key path of the pending draft, and the last write that changed it.
16331633
| :--- | :--- | :--- | :--- |
16341634
| **object** | `string` | ✅ | Object name |
16351635
| **permissions** | `{ allowCreate?: boolean; allowRead?: boolean; allowEdit?: boolean; allowDelete?: boolean; … }` | ✅ | Object-level permissions |
1636-
| **fieldPermissions** | `Record<string, { readable?: boolean; editable?: boolean }>` | optional | Field-level permissions keyed by field name |
1636+
| **fieldPermissions** | `Record<string, { readable?: boolean; editable?: boolean; when?: string }>` | optional | Field-level permissions keyed by field name |
16371637

16381638
### Nested Shape: `GetObjectPermissionsResponse.permissions`
16391639

@@ -1658,6 +1658,7 @@ One changed key path of the pending draft, and the last write that changed it.
16581658
| :--- | :--- | :--- | :--- |
16591659
| **readable** | `boolean` | optional (default: `true`) | Field read access |
16601660
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
1661+
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. |
16611662

16621663

16631664
---

‎content/docs/references/security/permission.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const result = AdminScopeSchema.parse(data);
6969
| :--- | :--- | :--- | :--- |
7070
| **readable** | `boolean` | optional (default: `true`) | Field read access |
7171
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
72+
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. |
7273

7374

7475
---
@@ -121,7 +122,7 @@ const result = AdminScopeSchema.parse(data);
121122
| **managedBy** | `never` | optional | [REMOVED] `permission.managedBy` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0086 D3) — nothing ever read the authored key: no seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned and `platform` or `user` never shielded one from an upgrade. Delete the key; a set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`, and a set authored in this system carries neither. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
122123
| **isDefault** | `boolean` | optional (default: `false`) | [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 |
123124
| **objects** | `Record<string, { allowCreate?: boolean; allowRead?: boolean; allowEdit?: boolean; allowDelete?: boolean; … }>` | ✅ | Entity permissions |
124-
| **fields** | `Record<string, { readable?: boolean; editable?: boolean }>` | optional | Field level security |
125+
| **fields** | `Record<string, { readable?: boolean; editable?: boolean; when?: string }>` | optional | Field level security |
125126
| **systemPermissions** | `string[]` | optional | System level capabilities |
126127
| **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) |
127128
| **rowLevelSecurity** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Row-level security policies (see rls.zod.ts for full spec) |
@@ -158,6 +159,7 @@ const result = AdminScopeSchema.parse(data);
158159
| :--- | :--- | :--- | :--- |
159160
| **readable** | `boolean` | optional (default: `true`) | Field read access |
160161
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
162+
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. |
161163

162164
### Nested Shape: `PermissionSet.rowLevelSecurity[number]`
163165

0 commit comments

Comments
 (0)