You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 56076fd
Browse filesBrowse the repository at this point in the historyBrowse files
docs(spec): the field-permission when description and the authorization guide name the View All / Modify All field vote (#29599)
<!-- claude-projects-attribution: {"github_login":"os-warren"} -->
_Requested by **Warren** · [project
thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ875yZfH2NNQuxrKN8gzJqox)_Fixes#29543
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:只改文字。`FieldPermission.when` 的 `.describe`、`FieldPermissionSchema`
的 TSDoc 注释、`authorization.mdx` 的「FLS posture (ADR-0066
⑧)」段,都补上超级用户投票:持「查看全部」的权限集对该对象每个字段投可读,持「修改全部」的投可读+可编辑,「查看全部」从不给编辑,其余照旧是无
deny 的并集。参考页按生成器重生成。
- 为什么改:PR #29387 已让运行时这样合并字段权限,但这三处仍写「只有声明了的集合投票」,读者会以为应用集的 `readable:
false` 还能挡住管理员。
- 风险与回滚:无 schema 形状变化,无接受集变化。回滚 = revert 本 PR。
- 先看:`packages/spec/src/security/permission.zod.ts`(`when` 的 describe 与
L928 起的 docblock)→ `content/docs/permissions/authorization.mdx` 的 FLS
posture 段 → 重生成的 `content/docs/references/security/permission.mdx`。
- 两分钟验证:`git diff origin/main -- content/docs/references`,预期只有 3 行
`when` 表格行变化,内容与 describe 一致。
- 需要你做的:无,席位落地。
## What changes
- **`FieldPermission.when` `.describe`**
(`packages/spec/src/security/permission.zod.ts`). The sentence "the
most-permissive merge across the caller's sets and the rule that only
declaring sets vote are unchanged" now reads that the merge is unchanged
and a co-held set that says nothing about the field still lifts nothing,
unless it holds the super-user bypass on the object (its own entry for
the object, otherwise its `'*'` wildcard). Such a set votes on every
field of the object, on every row, whatever `when` says:
`viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes
`readable: true` and `editable: true`, so a row-conditioned lock never
binds a Modify All holder. View All never grants an edit. Otherwise
union without deny is unchanged. The rest of the description is
untouched.
- **`FieldPermissionSchema` TSDoc docblock** (same file, from L928). It
carried the same stale "declaring sets vote" sentence; it now names the
super-user vote and cites the ADR-0066 ⑧ note of 2026-11-02 and ruling
of record 6573563853. This hunk is outside the original claim surface;
the seat added it by claim amendment 6586583239 (os-dev rule 3 bounded
fix: same defect class, mechanical prose, same file, same gate family).
- **`content/docs/permissions/authorization.mdx`**, the "FLS posture
(ADR-0066 ⑧)" callout. It now names the View All / Modify All vote, says
that no set's `readable: false` hides a field from a caller who holds a
View All or Modify All set on its object, that a field's
`requiredPermissions` capability gate still applies to every caller, and
that a set which must withhold a field from its own holders grants the
object without those two bits.
- **Regenerated** (`pnpm --filter @objectstack/spec check:generated
--fix`, which proved only `check:docs` stale):
`content/docs/references/security/permission.mdx` (2 `when` rows) and
`content/docs/references/api/protocol.mdx` (1 `when` row, line 1670).
The protocol.mdx row is a generated artifact; ⛔ no protocol source
(`packages/spec/src/api/protocol.zod.ts`, spec `identity/protocol.ts`,
metadata-protocol `protocol.ts`) is edited, per the freeze that holds
until 2026-11-03 06:00Z.
- **Changeset** `.changeset/29543-field-permission-superuser-vote.md`:
`patch` for `@objectstack/spec`. The new text ships in `dist/` (`grep
-rl "never binds a Modify All holder" packages/spec/dist` hits two
chunks; the old "only declaring sets vote" text hits none) and in the
published `src/**/*.zod.ts`.
## Why
PR #29387 (#29106, ruling of record 6573563853, letter A) shipped the
super-user field vote, and the ADR-0066 D2 ⑧ note of 2026-11-02 (PR
#29459) records it. Its contract review 6579089871 named both texts as
should-fix follow-ups. An author, AI author or reviewer who reads the
old text believes an app set's `readable: false` still hides a field
from an admin.
**Measured against the merged code** (origin/main ff827bc):
- `holdsSuperuserBypass`
(`packages/core/src/security/effective-object-permissions.ts`): reads
`objects[objectName] || objects['*']`; `view` = `viewAllRecords ||
modifyAllRecords`, `modify` = `modifyAllRecords` only. Hence "its own
entry for the object, otherwise its `'*'` wildcard" and "View All never
grants an edit".
- `superuserFieldVote`: `{ readable: true, editable: true }` for a
Modify All holder, `{ readable: true, editable: false }` for View All,
`null` otherwise.
- `PermissionEvaluator.getFieldPermissions`
(`packages/plugins/plugin-security/src/permission-evaluator.ts`) and
`buildEffectiveFieldPermissions` (core) apply the vote AFTER the
declared-entry votes, whatever each entry's `when` verdict was
(`matched`, `unjudged` or `unmatched`). So the vote is row-blind: a
`when` lock (`editable: false` on matched rows, or the lock a row-blind
reader reports) is overridden to `editable: true` for a Modify All
holder. An `unmatched` entry is absent, and an absent field is
unrestricted anyway. Hence "on every row, whatever `when` says" and "a
row-conditioned lock never binds a Modify All holder". The write gate
(`computeForbiddenFieldWrites`) and the record-scoped field projection
read `getFieldPermissions` with a judge, so the description's later
sentences on those readers stay true.
- `requiredPermissions`: `foldFieldRequiredPermissions` runs after the
merge in every caller (review 6579089871 ④), hence "still applies to
every caller".
## Tests
No test pinned the old wording (`git grep` of "only declaring",
"declaring sets vote" and "co-held set" outside
`content/docs/references` finds only the edited source lines). No
behaviour changes, so no new pin and no ablation.
- `pnpm --filter @objectstack/spec test`, under the verify lock: green
on de2dfac and again on the merge head a4a9545 (counts under
Verification).
- `pnpm --filter @objectstack/spec build && pnpm --filter
@objectstack/spec check:generated`: before the fix `✗ check:docs` only
(1 of 14 stale); after `check:generated --fix`, `git status` shows
exactly the two reference pages.
## Acceptance notes
- Observation, not filed: `os explain`'s FLS layer (`explain-engine.ts`,
the `conditionedSentence`) lists every row-conditioned entry as "locked
when …", and says it is "applied as the lock everywhere else". For a
caller who holds a Modify All set on the object that lock is overridden
by the super-user vote, so the sentence overstates the lock for that
caller. This is report prose, not a gate; it is out of this card's
surface and reach is not measured. Left to the seat to judge.
## Verification
All readings below are on head `a4a95452` (main merged at e832ddb after
the edit commit de2dfac), worktree `objectstack-issue-29543`.
- `pnpm --filter @objectstack/spec check:generated`: `✓ All 14 generated
artifacts are up to date` after a rebuild of the lint / objectql /
formula / client-react / spec closures (`Tasks: 38 successful, 38
total`).
- `pnpm --filter @objectstack/spec test`: `Test Files 800 passed (800)`,
`Tests 23664 passed | 1 todo (23665)`, lock `VERDICT command-exit 0`.
(On de2dfac before the merge: 799/799 files, 23662 tests.)
- `node scripts/pm/dispatch-gates.mjs --commands` derived 97 gate
families at a4a9545; each was run and its exit code captured before any
pipe. `--ran` reconciliation: `97 derived, 96 run, 1 NOT-MEASURED, 0
UNRUN`, exit 0.
- 96 exit 0.
- NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 `PREREQUISITE
NOT MET` (it needs every package's `dist/`; 34 packages unbuilt in this
worktree). Left to CI. A describe string cannot change what a CJS entry
loads.
- References diff vs main: exactly three `when` table rows
(`references/security/permission.mdx` ×2, `references/api/protocol.mdx`
×1), each equal to the new describe.
- Freeze check: `git diff origin/main...HEAD --name-only` lists no
`protocol.zod.ts`, `identity/protocol.ts` or metadata-protocol
`protocol.ts`.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(Describe text only.)
- [x] 2. Every name I added uses the casing for its kind. (No name
added.)
- [x] 3. No formatter ran; `pnpm lint` passes. (Lint runs in CI.)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request. (The branch is the card thread's platform branch, per the
claim.)
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01JQwxbXETMiU6EPkjZvr57y
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JQwxbXETMiU6EPkjZvr57y)_
Co-authored-by: Claude <noreply@anthropic.com>
docs(spec): the `FieldPermission.when` description names the View All / Modify All field vote
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
Description text only: no key, accepted value or default of any schema changes. The published `when` description (and the `FieldPermissionSchema` docblock) said that only the sets declaring a field vote on it. Since `@objectstack/plugin-security` and `@objectstack/core` shipped the super-user field vote, a set holding the super-user bypass on an object (its own entry for the object, otherwise its `'*'` wildcard) votes on every field of that object, on every row: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The description now says so, and the generated JSON Schema and reference pages carry it. Nothing to migrate.
Copy file name to clipboardExpand all lines: content/docs/references/api/protocol.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1667,7 +1667,7 @@ One changed key path of the pending draft, and the last write that changed it.
1667
1667
| :--- | :--- | :--- | :--- |
1668
1668
|**readable**|`boolean`| optional (default: `true`) | Field read access |
1669
1669
|**editable**|`boolean`| optional (default: `false`) | Field edit access |
1670
-
| **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 object-wide 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. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
1670
+
| **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 is 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, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. 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 object-wide 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. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
Copy file name to clipboardExpand all lines: content/docs/references/security/permission.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -69,7 +69,7 @@ const result = AdminScopeSchema.parse(data);
69
69
| :--- | :--- | :--- | :--- |
70
70
|**readable**|`boolean`| optional (default: `true`) | Field read access |
71
71
|**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 object-wide 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. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
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 is 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, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. 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 object-wide 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. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
73
73
74
74
75
75
---
@@ -159,7 +159,7 @@ const result = AdminScopeSchema.parse(data);
159
159
| :--- | :--- | :--- | :--- |
160
160
|**readable**|`boolean`| optional (default: `true`) | Field read access |
161
161
|**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 object-wide 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. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
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 is 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, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. 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 object-wide 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. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
0 commit comments