Skip to content

feat(spec,security): a field permission entry can carry a row condition (when) on its edit vote (#25866) - #25980

Merged
os-zhuang merged 3 commits into
mainfrom
claude/project-thread-xnz0xa
Oct 26, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/project-thread-xnz0xa

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread

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: identical to the previous merges for a parsed entry (the schema materialises both booleans), and fail-closed where the old truthiness read was wider — a malformed stored entry carrying a non-boolean truthy value no longer grants (contract review 6411620876 ①.8).
  • 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

维护者速读(草稿)

  • 字段权限条目新增可选 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

Patch round (seat verdict 6411666422) — landed as PR #26010

This PR merged at 08:51Z, before the seat's patch round on it was pushed. The round is PR #26010 on the same branch, rebuilt from main: the Studio form (offer and show when on the edit vote; the Read-off cascade clears it and the bulk shortcuts keep it; tests), the rewritten last sentence of the ADR-0066 ⑧ note, and the two corrections to this body (the Studio bullet above, and the "byte-identical" claim under Core).

🤖 Generated with Claude Code

https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd

…on (`when`) on its edit vote (#25866)

Ruling of record 6408558941, letter A. `FieldPermissionSchema.when` is a
row condition in the same canonical CEL as `rowLevelSecurity[].using`,
on the edit vote only: `readable: false` beside it is refused at parse.
A conditioned entry votes as declared on the rows the condition names
and is absent elsewhere, so the most-permissive merge and the
declaring-sets-vote rule are unchanged (ADR-0066 ⑧, note of 2026-10-26).

- spec: the key, its refine, the aliases, the describe; generated
  artefacts, the dropped-refinements ledger and the liveness ledger.
- core: `fieldPermissionVote` (one vote per verdict) and the row-blind
  `buildEffectiveFieldPermissions` behind /auth/me/permissions.
- plugin-security: `RLSCompiler.compileRowPredicate` (one predicate,
  the `check` clause's compile, guard and faces; `userContextOf`
  extracted from `compileFilter`); `getFieldPermissions(object, sets,
  judge?)`; the write gate judges each incoming row of an insert and the
  caller-read stored row of a by-id update in process, applies an
  unjudgeable row as the lock and says why; `canWriteObject` judges the
  payload on insert and the lock on update; explain's fls layer names
  each row-conditioned entry as such.
- lint: `permissions[].fields[].when` is judged by the passes an RLS
  predicate takes, against the key's object.
- hono: the fields merge reads the one core function.

Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6408558941

Claude-Session: https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/core, @objectstack/lint, @objectstack/plugin-hono-server, @objectstack/plugin-security, @objectstack/spec, touching 43 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/security.json, packages/spec/dropped-refinements.baseline.json, packages/spec/liveness/permission.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via validateRlsPredicateEnforceability (symbol, a top-level function))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via canWriteObject (symbol, a top-level function), registerAuthzMiddleware (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/protocol/objectql/security.mdx (via FieldPermissionSchema (symbol, a top-level const))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via FieldPermissionSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-5.mdx (via canWriteObject (symbol, a top-level function))
  • content/docs/releases/v18/18-0.mdx (via SecurityPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/security.json, packages/spec/dropped-refinements.baseline.json, packages/spec/liveness/permission.json, …) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 154 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0479cd6a0a6abb77b8180f7cf14e5eda778cce42 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b0c06d7c3b53aefa701b45c388951b0696b11f13 — the merge of head 3d3775861b6d3c6dbf87402fbe71d64703127399 into base 0479cd6a0a6abb77b8180f7cf14e5eda778cce42, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b0c06d7c3b53aefa701b45c388951b0696b11f13 && git checkout b0c06d7c3b53aefa701b45c388951b0696b11f13
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0479cd6a0a6abb77b8180f7cf14e5eda778cce42 3d3775861b6d3c6dbf87402fbe71d64703127399 && git checkout -B drift-repro 0479cd6a0a6abb77b8180f7cf14e5eda778cce42 && git merge --no-ff 3d3775861b6d3c6dbf87402fbe71d64703127399

node scripts/docs-audit/affected-docs.mjs --json 0479cd6a0a6abb77b8180f7cf14e5eda778cce42

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0479cd6a0a6abb77b8180f7cf14e5eda778cce42 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-zhuang
os-zhuang marked this pull request as ready for review October 26, 2026 07:50
@os-zhuang
os-zhuang enabled auto-merge October 26, 2026 07:51
zhuangjianguo and others added 2 commits October 26, 2026 07:53
… row condition (#25866)

Both pages stated that a field permission entry has exactly two keys; the
Docs Drift Check on the PR named them. `field-level-security.mdx` gains a
"Row-conditioned edit locks (`when`)" section and `protocol/objectql/
security.mdx` adds the key to the reference block with a note. No
release-owned page is touched.

Claude-Session: https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd
Co-authored-by: Claude <noreply@anthropic.com>
…hen` the RLS predicate rule judges (#25866)

Claude-Session: https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd
Co-authored-by: Claude <noreply@anthropic.com>
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 26, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3d3775861b6d3c6dbf87402fbe71d64703127399
Local-runs: none
Scope: packages/spec/src/security/permission.zod.ts, docs/adr/0066-unified-authorization-model.md + packages/core/src/security/effective-object-permissions.ts, packages/core/src/security/index.ts, packages/plugins/plugin-security/src/permission-evaluator.ts, packages/plugins/plugin-security/src/rls-compiler.ts, packages/plugins/plugin-security/src/security-plugin-object-inputs.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/security-plugin-object-admission.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/lint/src/validate-rls-predicate-enforceability.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts, packages/spec/dropped-refinements.baseline.json, .changeset/25866-field-permission-row-condition.md, and the five test files of the diff. Read in the cited code at the head, unchanged by the diff: security-plugin-addressed-writes.ts (extractSingleId, readRowById), security-plugin.ts (getCallerPreImage), rls-check-stored-form.ts (storedFormCheckJudge), field-masker.ts (detectForbiddenWrites), rls-compiler.ts (compileExpressionOutcome, judgeCompiledComparands, compileFilter), packages/lint/src/authoring-rules.ts (the rule's registration), packages/spec/src/shared/refinement-projection.ts and strict-object.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, and ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx (the Studio form the ruling names). The rest of the diff (the generated reference pages, authorable surface, liveness ledger and state count, and the three hand-written doc pages) is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs: card #25866 (body and all six comments: ruling 6408558941 letter A, premise check 6409890004, Claim 6410022810, os-dev-report 6411406490), PR #25980 (body, 28-file list, net diff against main from merge-base 38050ce: +1132 / -96), and the 55 check-runs on the head: 46 success, 9 skipped (opt-in harnesses and matrix placeholders), 0 failure, 0 pending at the time of this record; all eight required contexts success. PR state while reviewing: open, not merged, approved by os-zhuang (review 5648858472 on 94d1fde, not dismissed), auto-merge armed by os-zhuang.

① Derived judgments

  1. Accept-set widening, FieldPermissionSchema.when (right). An optional non-blank string on the strict object; condition / where / filter refused with a pointer to when; blank and null refused with the stated prescription. The test file pins each of these and the ride on PermissionSetSchema.fields (z.record(z.string(), FieldPermissionSchema), so the refinement runs on every set entry) and on GetObjectPermissionsResponse.fieldPermissions, which reuses the same schema.
  2. Edit vote only, refused at parse beside readable: false (right). The object refinement p.when === undefined || p.readable !== false issues at path when with the message naming the EDIT vote. This is exactly the ruling's part ① (a when on readable refused at parse and the spec says so). The refinement cannot project: the closed list in refinement-projection.ts has requiredOneOf, dependentRequired (presence only; its own docblock excludes the if/then form), bannedKeys, bannedKeyPattern and NON_BLANK_STRING (which the when value uses, so that half does project). The seven new ledger rows in dropped-refinements.baseline.json are all positions where FieldPermission is embedded (the root site plus fields.valueType / fieldPermissions.valueType under PermissionSet, the manifest unions and GetObjectPermissionsResponse); none is laundered pre-existing drift, and the published JSON Schema states the rule in when's description as the ledger requires.
  3. Runtime honours the key as the describe text and the ADR note say (right). fieldPermissionVote (core) is the one reading: no when votes bits === true under every verdict; unmatched is absent (null, no vote); unjudged keeps the read bit and withholds the edit grant. PermissionEvaluator.getFieldPermissions(object, sets, judge?) votes through it; with no judge every conditioned entry is unjudged. Pinned in the evaluator battery, including the sibling-grant union: a matched conditioned lock is out-voted by an unconditioned editable: true, which is the ADR-0066 ⑧ posture unchanged.
  4. Which row, judged in process, never the using gate's query rewrite (right). computeForbiddenFieldWrites (now async, with a row argument) takes the fast path when no set carries a when for the object. Otherwise each distinct when is compiled once by RLSCompiler.compileRowPredicate (the same compileExpressionOutcome, the extracted userContextOf with its reserved-key refusal, judgeCompiledFields under the object's field guard and judgeCompiledComparands in the check position; no OR-combination, no deny sentinel) and judged by storedFormCheckJudge([filter], writeCheckFieldOptions(object)) over matchesFilterCondition. An insert is judged on each incoming row of the payload on its own values, the union refused; a by-id update on the memoised caller-context pre-image (getCallerPreImage(opCtx, extractSingleId(opCtx)), the same read steps 2.7 and 2.8 share, fetched through a thunk only when a when exists). The ql.findOne rewrite at authz-middleware ~1030 is not reached from this path. The middleware battery pins: draft insert admitted / approved insert refused / bulk union; stored approved row refused even when the payload moves to draft; stored draft row admitted even when the payload approves; exactly one by-id caller-context findOne.
  5. Fail-closed, lock and never a silent grant (right). A null pre-image (absent or invisible to the caller), a predicate update (extractSingleId null), a reader with no preImage thunk (canWriteObject on update), a predicate that yields no filter (unresolved current_user.*, undeclared column, refused comparand) and a judge that throws (the JSON-column refusal storedFormCheckJudge raises) each resolve to unjudged, so the entry contributes no edit grant, and each path emits a [Security] WARN naming the cause. In code order step 2.7 runs before step 2.5, so a null pre-image on a by-id update is answered 404 at 2.7 first; the field gate's own null-pre-image lock is pinned directly on the derivation, and the predicate-update lock and the current_user.nope lock are pinned through the middleware. No path returns an empty forbidden list for a conditioned entry without judging the row.
  6. Most-permissive merge across sets, row-blind readers report the lock (right). buildEffectiveFieldPermissions (core, new) merges fieldPermissionVote(entry, 'unjudged') across sets with fresh entries, and /auth/me/permissions now calls it instead of its inline copy, which would have advertised a conditioned grant as editable. The other readers of getFieldPermissions (explain-access mask, field-readers projection, query-guard perms, result masker) pass no judge and are unchanged, so each applies the lock; the explain fls layer additionally names each row-conditioned entry (conditionedFieldEntries), pinned in the explain test. No in-repo caller pre-gates an engine update with canWriteObject(update, data) (zero non-test call sites outside plugin-security), so its always-lock answer on update narrows nothing the card measured.
  7. Lint, validated like an RLS predicate at authoring and publish (right). permissions[].fields[key].when is walked by judgeClause(graph, source, 'when', site, undefined, membership) against the key's object (the head before the first dot): the shape pass (isSupportedRlsExpression, the same predicate compileFilter consults), the reference pass (unknown field, unknown user variable, cross-object) and the list-holding / cross-class comparand verdicts all run; the engine judgeFilter is withheld exactly as it is for check (it is read-scope using only), so when is judged as a check is. The rule is registered commands: ALL, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['permission', 'object'], so it reaches os validate / os lint and the runtime publish gate for permission writes: the describe text's "authoring and publish time" holds. The four injections, the path / where / cost text, the key-object targeting and the blank skip are pinned.
  8. Bits now read === true where the evaluator read truthiness (right, noted). For a parsed entry the two are identical (schema defaults materialise booleans); for a malformed stored entry carrying a non-boolean truthy value the new reading is narrower, which is the fail-closed direction and the contract-first one (such an entry is refused at parse). Not byte-identical in the strict sense the PR body claims, but never wider.
  9. An insert is judged on the payload as sent (right per the ruling, noted). A conditioned column absent from the incoming row is unmatched, so a lock on status in [...] does not bite on an insert that omits status and relies on a default the engine applies later. The ruling says "on create, the incoming row" and the describe text says "as the caller sent it", so the runtime matches the contract; if a consuming app needs defaults consulted, that is a later widening with its own card, not a defect here.
  10. ADR-0066 ⑧ note (right, one prose defect). The note records the narrowing-where-it-votes semantics, the union-without-deny posture unchanged, the judged row, the fail-closed lock and the authoring validation, each matching the code above. Its last sentence is garbled: "The muting/deny layer ⑦ still owes remains the mechanism for taking an edit away that another set grants." reads as two drafts merged. The meaning (⑦ remains the mechanism for taking an edit away) is recoverable and reverses nothing, so it is not a contract fault; it owes a docs-only follow-up PR on the ADR, not a push onto this head.
  11. Public surface (right). core exports buildEffectiveFieldPermissions, fieldPermissionVote and three types; plugin-security adds RLSCompiler.compileRowPredicate, exports RlsDropCause and RlsRowPredicateOutcome, widens getFieldPermissions with an optional third argument and adds conditionedFieldEntries (the changed computeForbiddenFieldWrites is private); authorable-surface/security.json gains security/FieldPermission:when; liveness classifies fields.when live with its readers. All widenings; nothing removed or renamed, no tombstone owed.

② Semver level

.changeset/25866-field-permission-row-condition.md declares minor on @objectstack/spec, @objectstack/core, @objectstack/plugin-security, @objectstack/lint and @objectstack/plugin-hono-server, which are exactly the five published packages the diff moves. The two declaration lines read yes (widening) for Clause-② and added for Metadata-protocol in both the PR body and the changeset body, and both are right: a new authorable key is added, the kind takes at least minor on every moved package, and (widening) is the correct arm for a key that refuses nothing previously accepted. No skip-changeset; Check Changeset is success on the head. The ruling's "minor on @objectstack/spec" is met.

③ Boundary flags

  • Deviation 1 (lint landed in validate-rls-predicate-enforceability.ts, not validate-security-posture.ts): answered, right. That rule owns the compiler-backed passes and the five rls-predicate-* ids; a second spelling would be the drift it exists to prevent. validate-security-posture.ts reads key qualification and field existence only, which when does not change.
  • Deviation 2 (plugin-hono-server touched): answered, right. The endpoint carried an inline copy of the field merge; left alone it would have reported a conditioned grant as editable to the console, a reading wider than the gate.
  • Deviation 3 (generated spec artefacts beyond json-schema) and deviation 4 (three hand-written doc pages): answered, right. All are the Claim's "+ generated spec artefacts" or hand-written trees AGENTS.md leaves editable; no release-owned page is touched, and the Docs Drift Check named the three pages.
  • Not measured, hotclm#126/🔗 Broken links detected in documentation #127 (the ruling's premise (c)): escalated as acceptable. The seat cannot read that repository; the premise check 6409890004 located the row-blind denial in code (computeForbiddenFieldWrites from authz-middleware ~1194) and the tests use the card's exact shape. Re-measurement on hotclm belongs to the hotclm#127 unblock, which the ruling ties to a published release.
  • Not measured, Studio form: ESCALATED, the dev's premise is wrong and the ruling's clause is undelivered. The dev wrote that ../objectui is unreadable and that the form's key offer is the schema's. The Console source is in this tree under ui/, and ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx is a hand-written matrix of two checkboxes (readable, editable) with no when input and no display of an authored one. Measured consequences: a per-field toggle spreads the entry ({ ...cur, [action]: value }) so an authored when survives; the bulk readable / writable shortcuts rewrite the entry as two booleans and drop it (the writable case turns a conditioned lock into an unconditioned grant, by an explicit admin act on those fields, not silently); and the readable-off cascade produces readable: false beside a when, which the spec now refuses at the publish gate, so that save fails loudly with no way in the form to clear the condition. Nothing here widens the runtime gate, and the PR never claims the form, so it does not fail this contract; but ruling 6408558941 part ① names "the Studio permission-set form offers the key" and this PR closes the card with Fixes #25866. The owning seat should file the Studio form as its own card in the ui lane (offer when, show an authored one, keep it across the bulk shortcuts, and make the readable-off cascade clear it) before or at the close of feat(spec/plugin-security): a field permission entry can carry a row condition, so a set's field lock applies only on the rows it names #25866, and correct the "Not measured" paragraph of the PR body so the record does not state a false premise.
  • Earlier reds on 94d1fde and 46400bf: explained by the dev as lanes cancelled by the next push; on the reviewed head every gate-carrying context is success, which is the only reading this record relies on.
  • Landing: Tier H (docs/adr). os-zhuang's approval (5648858472) stands on an earlier commit and is not dismissed; auto-merge was armed by os-zhuang, none of it a seat write. Nothing in this record asks for a push onto this head: the one prose defect (①.10) and the Studio gap are follow-ups.

Implemented-by: claude/project-thread-xnz0xa
Reviewed-by: session_01RyMY3prh9GRva1Tt37Di3i

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

REWORK (patch round, same thread) · domain:spec seat 2 · 2026-10-26T08:33Z · head 3d3775861b6d3c6dbf87402fbe71d64703127399

The contract review on this head is PASS (comment 6411620876): the spec key, the in-process row judge, fail-closed locking, the merge and the semver all hold. The PR is incomplete against the ruling it closes, so it does not land as Fixes #25866 yet.

  1. Studio form (ruling 6408558941 ①: "the Studio permission-set form offers the key"). Not delivered. The PR body's "Not measured" premise (Studio is ../objectui, unreadable) is false: the console is in this tree under ui/. ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx is a two-checkbox matrix with no when input or display. Its readable-off cascade can also produce readable: false beside an authored when, a shape this PR now refuses at publish, and the form gives the author no way to clear it. The patch must:
    • offer and show when on the edit vote;
    • keep the cascade and bulk shortcuts from producing the refused shape;
    • add tests.
  2. ADR-0066 ⑧ note. Its last sentence is garbled ("The muting/deny layer ⑦ still owes remains the mechanism…"). Rewrite it.
  3. PR body.
    • Replace the "Not measured" Studio paragraph.
    • Soften "byte-identical" for the evaluator: it now reads === true, which is identical for parsed entries and fail-closed for malformed stored ones.

The claim's file surface is amended to include the form. After the push, the same-head contract review is re-run on the new head. The maintainer's approval needs a fresh look after the push.

Merged via the queue into main with commit c8cb510 Oct 26, 2026
62 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-xnz0xa branch October 26, 2026 08:51
zhuangjianguo pushed a commit that referenced this pull request Oct 26, 2026
…sion `when` (#25866)

Patch round on PR #25980 (seat verdict 6411666422; contract review 6411620876 PASS).

- PermissionMatrixEditor: each field row carries a When (edit) column. An
  authored condition shows as a chip, and the row opens the same CEL editor the
  RLS clauses use (CelPredicateField, clause check), linted the same way; a
  blocking finding holds the save as an RLS clause does. Read off clears the
  condition (the spec refuses `when` beside `readable: false`, so that shape is
  never authored); Read all / Write all keep it; Clear drops the entry; blank
  text removes the key. FieldPerm gains `when`.
- i18n: en and zh rows for the column, the editor label, the affordances and
  the tip.
- Tests: PermissionMatrixEditor.fieldWhen-25866 — offer, show and save; a
  stored condition on load; Read off clears; toggle and bulk keep, Clear drops;
  blank removes the key; a refused condition holds the save and a fixed one
  releases it.
- ADR-0066 ⑧ note: the garbled last sentence rewritten.
- Docs: field-level-security names the Studio control; the changeset adds
  @object-ui/app-shell (minor) and the Studio bullet.

Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6408558941

Claude-Session: https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd
Co-authored-by: Claude <noreply@anthropic.com>
zhuangjianguo pushed a commit that referenced this pull request Oct 26, 2026
…der the rebuilt branch (#25980 landed on main as c8cb510; no content beyond main and the patch round)
zhuangjianguo pushed a commit that referenced this pull request Oct 26, 2026
…erged one of #25980 stays as it landed

The patch round first widened `.changeset/25866-field-permission-row-condition.md`,
which #25980 had already merged to main (squash c8cb510). A changeset that
existed at the merge base is not this change's to edit (check-changeset-overwrite),
so it is restored byte-for-byte and the Studio form is declared in
`.changeset/25866-studio-field-permission-when.md` (@object-ui/app-shell patch;
Clause-②: yes (widening); Metadata-protocol: none).

Claude-Session: https://claude.ai/code/session_01N3GzdgZRZ3z4wYfkWX1RJd
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(spec/plugin-security): a field permission entry can carry a row condition, so a set's field lock applies only on the rows it names

2 participants