Repository navigation
The by-id write pre-image gate resolves the row under the caller's own read scope, so an app-authored widener is still dead on private even once checkAuthoredRowWrite admits it #7401
Description
Restart-when: a dedicated design session rules the public-contract question below ("may a caller write a row they cannot read, when an app-authored declaration admits it by predicate?"), with #5493 context on the table.
Restart-touch: packages/plugins/plugin-security/src/security-plugin.ts
⛔ The former trigger closed #8757 was struck 2026-09-01 — it is SPENT, and it already misfired once. #8757 closed completed 2026-08-15 via merged PR #8869, but it closed hollow for this card: #8869 ruled only the controlled_by_parent arm, not this card's private-OWD arm (measured by triage 2026-08-17). The spent trigger nonetheless caused an erroneous promotion to pm:queue on 2026-08-19, which the services seat dissented against and triage reverted on 2026-08-20. The corrected exit lived only in that comment — and because the unlock scan reads issue.body only, the stale line stayed the one a scan saw. It is now in the body, where the scan reads it. (
private OWD today. Flagged twice, never written. That belongs to the release process — ⛔ never a rider on a code PR.
Measured while implementing #7281's maintainer ruling (PR #7400). Filed unassigned, out of that PR's ruled scope, for triage.
Related: #7281 (the probe half, ruled and fixed), #5493 (the widener surface this exists for). Not blocked by anything — #7400 is independently correct — but the two together decide whether the by-id widener functions on private at all.
What was measured
#7281's ruling moved checkAuthoredRowWrite's probe read to an elevated scope, so on a private-OWD object the verdict for a cross-owner row the declaration admits went abstain -> admit. The end-to-end by-id write did not change: it is still refused, 403, row unchanged.
Real stack (bootStack + real SecurityPlugin + real SharingServicePlugin + real ObjectQL engine), two objects identical in every respect except their OWD, same app-authored widener (operation: 'update', using: "stage == 'open'"), same principal, same cross-owner row shape:
| object | OWD | verdict after #7400 | by-id PATCH |
|---|---|---|---|
wscope_note |
public_read |
admit |
200, row changes |
wscope_secret |
private |
admit |
403, row unchanged |
The refusal on private, verbatim:
403 {"error":"[Security] Access denied: not permitted to update this 'wscope_secret' record (row-level security)",
"code":"PERMISSION_DENIED","object":"wscope_secret"}
Mechanism
The error shape is the tell, and it is not the one #7281's card assumed. That sentence belongs to plugin-security's by-id write pre-image gate, not to the sharing middleware (whose refusal reads FORBIDDEN: insufficient privileges to update …). The security middleware runs first, so on this posture it refuses before the sharing middleware is reached at all — which means the #5493 deferral that consumes the now-corrected verdict never runs on private.
The gate, at packages/plugins/plugin-security/src/security-plugin.ts:1301-1306 (verified at d53bd0ba9;
visible = await this.ql.findOne(opCtx.object, {
where: { $and: [{ id: targetId }, ...writeParts] },
context: opCtx.context,
});
opCtx.context is the caller's own execution context, so that findOne re-enters the middleware chain and picks up plugin-sharing's READ filter — which on a private OWD scopes to owner-match OR shares. The composed writeParts already admit the row (the authored policy OR-combines past the platform ownership floor); the row is simply invisible to the read that asks. Structurally the same shape #7281 named one layer up.
Why this is not simply "the rest of #7281"
The gate's caller-scoped read is deliberate and documented in place:
A read denial (e.g. no read permission) is itself a "cannot touch this row" signal — fall through to the deny below.
and the neighbouring owner-echo pre-image states the same stance with a second reason (it closes an owner-enumeration oracle a system read would open). So this is not an oversight to be corrected the way #7281's was; changing it answers a public-contract question the #7281 ruling did not put to the maintainer:
May a caller write a row they cannot read, when an app-authored declaration admits it by predicate?
Two readings, offered without choosing:
- Intended. "You may not write what you cannot see" is a floor, and an app author who wants a cross-owner update-widener on a
privateobject must widen READ to match (viewAllRecords, a read policy, or a share). Then the gap is documentation — and app authors need telling, because nothing in the widener's own vocabulary mentions the OWD. - Unintended. The declaration names the row by predicate, the composed write filter already admits it, and the only thing refusing is a read scope that the write question never asked about. Then the gate's probe read wants the same treatment checkAuthoredRowWrite abstains on every
private-OWD cross-owner row, so #5493's by-id widener deferral is inert on the posture it was filed for — and its unit test cannot see it (fake engine bypasses middleware) #7281 gave the verdict — with the owner-enumeration concern answered separately (the gate returns a boolean, not the row).
The cost of reading 1 is that the by-id widener surface — everything #5493, #6735 and #7281 built — remains inert on private, the posture #5493 was filed for. The cost of reading 2 is a genuine widening of write reach on read-closed objects, which is exactly the kind of change that needs to be ruled rather than inferred.
Repro
packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts (landed by PR #7400) pins today's behaviour on both postures, including this refusal and its exact envelope, in case ⚠️ [E2E private]. Nothing new needs writing to reproduce it.
Activity
os-zhuang commentedon Aug 10, 2026
Triage:
needs-user-decision— the card's own ask is a public-contract ruling: may a caller WRITE a row it cannot READ, when an app-authored declaration admits it by predicate?origin/main@73f69dc: the pre-image gate's caller-scopedfindOneand the deliberate "A read denial … fall through to the deny below" stance are both live atpackages/plugins/plugin-security/src/security-plugin.ts:1333/:1338(the card cited:1301-1306atd53bd0ba9; the block moved down, content unchanged).pm:on-hold,domain:identity) is the BULK-path half of the same widener-inertness family (buildWriteFilterANDs wideners away); this card is the by-id pre-image gate — distinct mechanism and site, so cross-linked, not merged. checkAuthoredRowWrite abstains on everyprivate-OWD cross-owner row, so #5493's by-id widener deferral is inert on the posture it was filed for — and its unit test cannot see it (fake engine bypasses middleware) #7281 (probe verdict, ruled, PR fix(plugin-security): checkAuthoredRowWrite answers the declaration, not the caller's read scope (#7281) #7400) and On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493 (the widener surface) are ancestry, not duplicates.plugin-security(domain:identity) — routing now would prejudge the disposition.private-OWD cross-owner row, so #5493's by-id widener deferral is inert on the posture it was filed for — and its unit test cannot see it (fake engine bypasses middleware) #7281's investment) inert onprivate-OWD objects — the posture On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493 was filed for; reading 2 is a genuine widening of write reach on read-closed objects. Behaviour on both postures is already pinned by PR fix(plugin-security): checkAuthoredRowWrite answers the declaration, not the caller's read scope (#7281) #7400's dogfood test, so the ruling can be executed either way without re-measurement.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 11, 2026
Maintainer ruling — 2026-08-11. From the four-lens decision review (platform long-term coherence / measured business pull / AI-agent error-resistance / startup scope discipline); the maintainer accepted the recommendation set in full.
Hold —
pm:on-hold, 2026-08-11. Deliberately NOT ruled quickly: this is the semantic core of the #5493 widener surface (does a widener admit imply write-path row visibility; which scope does the by-id pre-image gate resolve under). A one-sentence ruling here would silently decide #5493's design.Restart condition: a dedicated design session with #5493 context on the table. Independent secondary condition: if v17 ships before that session happens, add a documented known-boundary note to the widener docs ("app-authored wideners do not function on
privateOWD today") — that half is mechanical and may be filed as its own docs card at that point. State:needs-user-decision→pm:on-hold.Recorded by the triage seat Routine (#5474 pilot) on the maintainer's direct instruction — ruling record, not a claim.
Generated by Claude Code
claude commentedon Aug 14, 2026
次要条件已触发 —— 标签不动,但有一项工作现在到期了。
v17.0.0 是 GA 了。 2026-08-14 于 npm 实测:
@objectstack/spec/@objectstack/cli/@objectstack/core/create-objectstack的latest均为17.0.0,发布时间 2026-08-14T07:59Z。本卡记录的条件有两条,主条件未触发:"a dedicated design session with #5493 context on the table" —— 该设计会议尚未发生。
但次要条件已经触发:"Independent secondary condition: if v17 ships before that session happens, add a documented known-boundary note…"。v17 已经 ship 了,所以这条「已知边界」文档说明现在是到期未做的工作。
⛔
pm:on-hold保持不变 —— 主条件仍未达成,本卡的实质问题(公共契约裁定:调用方能否写入一行它读不到的记录)还没到可裁定的时候。📌 到期的只是文档动作,而且它落在发版说明面上。按仓规,发版说明是发版时集中撰写的,不能由代码 PR 逐条追加 —— 所以这一项应当进入 v17 发版说明的 known-limitations 段落,由发版流程消费,而不是在这里开一张代码卡。这条留给维护者/发版流程。
Authorization: maintainer directive (this session) —「先做 23 张 blocked 解锁 10 张 on-hold 按各自自述处理」· PM session
01JaVVMrSxt7Tgi1uwEuDtH7Generated by Claude Code
hotlong commentedon Aug 14, 2026
Triage routing repair (2026-08-14, triage Routine). This card carried
pm:on-holdand nodomain:*— a shape that is invisible to every lane view and that the sweep's disjunction does not catch (it looks for domain without pm-state, not pm-state without domain). Routeddomain:identity: the gate ispackages/plugins/plugin-security/src/security-plugin.tsand the read filter isplugin-sharing. No grading change — the hold stands.Why it surfaced now: a sibling card arrived at the same gate through a different door. #8757 (filed 18:55Z, escalated to the decision box this round) measures the step 2.7 pre-image gate refusing a by-id write on a
controlled_by_parentdetail — there the ownership floor is undroppable becauseSharingService.checkEditreturnsabstainon thepublic-mapped model above themodifyAllRecordsbranch; here the row is admitted by the composed write filter but invisible to the gate's caller-scoped probe read onprivate.Same gate, same class, and this card already states the general question in the form a ruling would answer: may a caller write a row they cannot read, when an app-authored declaration admits it by predicate? #8757's three directions are that question re-asked for details. Recommend ruling them together; #8757 carries the four-facet block and the cross-link back here.
Nothing else changed on this card.
Generated by Claude Code
os-zhuang commentedon Aug 17, 2026
Daily executable-criteria batch (triage seat):
Restart-when:leg 1 fired — #8757 closed completed via merged PR #8869. But the joint-ruling exit did not materialize as written: #8869 ruled and implemented master-gate-sole-authority for thecontrolled_by_parentarm only; this card's arm (an app-authored widener still dead onprivatebecause the by-id pre-image gate resolves the row under the caller's own read scope) was not covered by that ruling.Remaining exit: the dedicated design session with #5493 context. The class is now half-ruled, which strengthens the case for finishing it — flagged to the
domain:identityseat in this round's brief: the next step is preparing the decision card (four-prism block, with #8869's landed shape as precedent) for the maintainer inbox. Hold stands until that card exists.Generated by Claude Code
fix(plugin-security): require by-id write targets to be within the ca…
os-zhuang commentedon Aug 19, 2026
On-hold daily batch (triage seat, round of ~14:47Z fire): Restart-when fired — #8757 closed completed 08-15 (PR #8869 merged), so the "rule the two together" precondition holds.
pm:on-hold→pm:queue. Claimer must re-read #8757's landed shape first and rule this card's gate against it, not against the pre-#8869 tree.Generated by Claude Code
14 remaining items
objectstack-fleet commentedon Sep 23, 2026
维护者裁定:读法 1 —— 看不到的记录不能改。本卡转为文档任务:
pm:on-hold→pm:queue·priority:p3分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T07:59Z。维护者 2026-09-23 在分诊会话里逐张复核pm:on-hold卡,对第二组的回复原文:「8006 保留,其他同意」。本卡在那一组里呈的是三个选项,推荐的是「甲」,维护者同意了。裁定内容
「看得到才能改」是底线。应用作者如果要让某个角色修改别人在
private对象上的记录,必须同时给这个角色读的权限(select策略、viewAllRecords或共享)。⇒plugin-security预检门用调用方自己的读范围来解析这一行,这个行为是有意的,保持不变;本卡不改任何代码。呈给维护者时说明过的背景:2026-08-11T02:55Z 的裁定曾要求开一次专门的设计会再定,而那次会一直没开;期间本卡两次被误放进排队,两次都撤回了(
5351109659、5761985500)。这些情况维护者都知道,今天直接给了裁定。文档任务的范围(本地
main上核对过)现在的文档写的恰好是相反的说法,作者照着写,在
private对象上会撞到 403:content/docs/permissions/rls.mdx第 77–80 行附近 —— 「Authoring a write-class policy switches the derivation off for that class: an authoredupdatepredicate then decidesupdatealone and widens exactly as written」。要补上:在privateOWD 上,写入的目标行仍然必须在调用方的可读范围内;一条update放宽策略,只能到达调用方本来就读得到的行。content/docs/permissions/sharing-rules.mdx第 77 行起的配方「owners edit their own records, supervisors edit all」 —— 在private对象上,这个配方要配套一个读授权才成立。把这一点写进配方,并给出配套写法。privateOWD without a matching read grant)。⛔ 写进上面两页文档,不要写进发版说明 —— 发版说明按仓规在发版时集中撰写。⛔ 不在范围内:
路由保持
domain:services(与本卡现有标签一致)。⛔ 本席不派发、不认领。Generated by Claude Code
objectstack-fleet commentedon Sep 23, 2026
文档任务追加第 4 项 —— 来自 #8241(维护者同意并入)
分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T08:13Z。维护者 2026-09-23 对第三组的回复原文:「第三组 同意」。其中 #8241 那一条的建议就是:关闭 #8241,并把它剩下的一处文档错误并进本卡的文档任务。本卡的其余范围不变。第 4 项
content/docs/permissions/permissions-matrix.mdx第 40–43 行:这段只讲了对象姿态,漏掉了部署姿态:在无墙部署(默认的
single)上,ADR-0105 D4 根本不发 bypass 位 ——auto-org-admin-grant给的是organization_admin_no_bypass。所以在 better-auth 管理的对象上,管理员不一定有短路保护。要补一句,说明这一点。它和本卡前 3 项是同一个主题:「一条授权在私有/无墙场景下到底能不能到达那一行」,放在同一个 PR 里改最顺。代码侧的说明已经在
packages/plugins/plugin-security/src/objects/default-permission-sets.ts约第 334–352 行,可以直接引用。⛔ 不改状态、不改标签。
Generated by Claude Code
objectstack-fleet commentedon Sep 23, 2026
Claim:
domain:servicesPM seat · 2026-09-23T14:18ZSeat: domain:services#1
Session: session_01AhQASwqJr2Z7XfGWUdvnbF
Branch: claude/issue-7401-read-to-write-docs
Thread-read: 5791407913
Clause-②: no
Declared file face (docs only; ⛔ zero code changes):
content/docs/permissions/rls.mdx,content/docs/permissions/sharing-rules.mdx,content/docs/permissions/permissions-matrix.mdx. These are the four items from rulings 5791225941 and 5791407913.Clause-② reading, with its citation:
SKILL.md:525「条款②只指已发布契约面,拉回已声明契约不触它」. The docs are brought back onto the behaviour the maintainer ruled intended: a caller cannot write a row they cannot read, the pre-image gate keeps resolving under the caller's own read scope, and no code changes.content/docs/**is a review surface, so a contract review is owed at the served tier.Take basis:
priority:p3, a documentation task under a maintainer ruling. The docs currently state the opposite of the ruled behaviour, so authors who follow them hit a 403. That is neither a tooling card nor a contract-hygiene card, so North Star clause 3 does not reach it.⛔ Out of scope, per the ruling: the batch-write half (#6736, which stays on hold), #7497, and ⛔
content/docs/releases/**. The known-limitations note goes into the two docs pages, never into release notes.⛔ Zero
packages/specin this lane.Generated by Claude Code
objectstack-fleet commentedon Sep 23, 2026
os-dev-report
{ "issue": 7401, "status": "done", "branch": "claude/issue-7401-read-to-write-docs", "pr": "https://github.com/objectstack-ai/objectstack/pull/19880", "session": "session_01AhQASwqJr2Z7XfGWUdvnbF", "premise_still_valid": true, "summary": "Docs-only PR, no code changes and zero packages/spec edits. It brings the three permission pages onto the ruled behaviour: a caller cannot write a row they cannot read. (1) rls.mdx: 'widens exactly as written' is now scoped to the update filter. A new paragraph and a warn callout say a single-record update/delete re-reads its target under the caller's own read scope, so on private OWD a widener reaches only readable rows. (2) sharing-rules.mdx: the supervisor recipe now says it needs a matching read grant on private objects and shows the paired spelling (viewAllRecords on the object plus the update RLS policy in one set, or a read-level sharing rule or record share). (3) The known-limitation note is in both pages, not in release notes. (4) permissions-matrix.mdx: the bypass is also deployment-gated. Under the default single posture, auto-org-admin-grant gives organization_admin_no_bypass (ADR-0105 D4), so an org admin is not short-circuited even on better-auth-managed objects. One deviation from the ruling's example list: on a private object a select policy is NOT a read grant. Sharing's buildReadFilter and RLS both AND into the query, and nothing defers the sharing read filter to an authored select policy. So the docs name viewAllRecords, readScope depth or sharing, and say explicitly that an extra select policy does not help. The PR body flags this for confirmation. The claim's newest Claim: names this branch, and the assignee was not touched. The harness-provided worktree was used in place, on a new branch cut from origin/main b940f32a56.", "tests": "Gates derived by 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' at HEAD 3fc2c9acc9: 44 commands. All 44 were run, and the final exit is 0 on every one. check:docs-transcript-drift and spec check:skill-examples first exited 3 (PREREQUISITE NOT MET: lint dist / client-react d.ts absent). That is not a measurement. Both were re-run green after 'os-verify-lock.sh -- pnpm --workspace-concurrency=2 --filter @objectstack/lint... build' (VERDICT command-exit 0, 216s) and '--filter @objectstack/client-react... build' (VERDICT command-exit 0, 390s). Excerpts: check:doc-security-posture '27 ObjectSchema.create example(s) in 227 marked block(s) ... os validate-clean'; spec check:docs '225 generated files in sync'; check:nul-bytes 'OK (scanned 9321 text file(s) ... no raw ASCII control bytes)'; transcript-drift '4 declared transcript value(s) across 405 page(s) ... equal'; skill-examples '259 prose examples type-check'. The --ran reconciliation reads '44 derived, 44 run, 0 NOT-MEASURED, 0 UNRUN'. The ran file lists bare commands, and the exit codes live in the scratch results files. NOT MEASURED locally: Build Docs (ci.yml) and the type-check lanes. These are CI-only steps and no package source was touched. No ablation applies to a docs-only diff.", "mcp_calls": "0", "api_writes": "3 REST writes, all via the fleet-write relay as objectstack-fleet[bot]: POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft forced, run 35876037628); POST /repos/objectstack-ai/objectstack/issues/19880/labels (skip-changeset via label-write.mjs, read back as documentation, size/s, skip-changeset, run 35876185208); POST /repos/objectstack-ai/objectstack/issues/7401/comments (this os-dev-report). Plus 2 git pushes, which are not REST: the empty-branch probe and commit 3fc2c9acc9. The PR body was read back byte-identical, 5757 chars.", "open_questions": [ { "question": "The ruling (5791225941) lists a `select` policy among the read grants that pair with a widener on private OWD. The source says a select policy cannot widen reads past the private sharing filter. May the docs deviate and omit it?", "options": [ "A: keep the PR as written. Name viewAllRecords / readScope depth / read-level sharing, and state that an extra select policy is not a read grant.", "B: list select as the ruling worded it. Authors following it would hit the same 403." ], "recommendation": "A. It is what the runtime does today (sharing-service.ts buildReadFilter ANDed by sharing-plugin.ts read branch; rls.mdx already says RLS never widens). It keeps the ruling's substance, which is to widen read to match." } ], "out_of_scope_findings": [ "class: a · permissions-matrix.mdx super-user callout says 'The built-in admin_full_access / organization_admin sets therefore carry allowExport: true'. packages/plugins/plugin-security/src/objects/default-permission-sets.ts says neither grants export any more ('no allowExport on the wildcard' on admin_full_access and '[#8681] this set no longer GRANTS it' on organization_admin). A reader who relies on it expects admin export and gets refused. · dedupe words: allowExport, admin_full_access, organization_admin, permissions-matrix, export opt-in", "carrier: none (承接者:无) · noted in PR 19880 Acceptance notes, not filed" ] }feat(security): record platform-admin standing on the audit ledger at…
fix(security): explain answers 404 OBJECT_NOT_FOUND for an object tha…
docs(permissions): a write widener reaches only rows the caller can r…