Skip to content

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

Closed

Description

@os-zhuang

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. ⚠️ This is a maintainer act with no closing event — no card closing will fire it, and no scan can detect it.
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. (⚠️ #8757 additionally returns 404 on both the issues and pulls endpoints today — deleted or transferred. Either way it is not open, so leaving the clause would have kept firing it.)

⚠️ Also outstanding on this card, and not the code fix: the secondary condition fired 2026-08-14 when v17.0.0 went GA, obligating a known-limitations note that app-authored wideners do not function on 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; ⚠️ line anchors from 2026-08-10 — locate by symbol):

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:

  1. 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 private object 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.
  2. 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

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    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?

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. claude commented on Aug 11, 2026

    @claude
    Contributor

    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 private OWD 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

  3. claude commented on Aug 14, 2026

    @claude
    Contributor

    次要条件已触发 —— 标签不动,但有一项工作现在到期了。

    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 01JaVVMrSxt7Tgi1uwEuDtH7


    Generated by Claude Code

  4. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Triage routing repair (2026-08-14, triage Routine). This card carried pm:on-hold and no domain:* — 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). Routed domain:identity: the gate is packages/plugins/plugin-security/src/security-plugin.ts and the read filter is plugin-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_parent detail — there the ownership floor is undroppable because SharingService.checkEdit returns abstain on the public-mapped model above the modifyAllRecords branch; here the row is admitted by the composed write filter but invisible to the gate's caller-scoped probe read on private.

    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

  5. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor

    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 the controlled_by_parent arm only; this card's arm (an app-authored widener still dead on private because 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:identity seat 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

  6. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    ContributorAuthor

    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

  7. 14 remaining items

  8. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    维护者裁定:读法 1 —— 看不到的记录不能改。本卡转为文档任务:pm:on-hold → pm:queue · priority:p3

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T07:59Z。维护者 2026-09-23 在分诊会话里逐张复核 pm:on-hold 卡,对第二组的回复原文:「8006 保留,其他同意」。本卡在那一组里呈的是三个选项,推荐的是「甲」,维护者同意了。

    裁定内容

    May a caller write a row they cannot read, when an app-authored declaration admits it by predicate? —— 不能。

    「看得到才能改」是底线。应用作者如果要让某个角色修改别人在 private 对象上的记录,必须同时给这个角色读的权限(select 策略、viewAllRecords 或共享)。⇒ plugin-security 预检门用调用方自己的读范围来解析这一行,这个行为是有意的,保持不变;本卡不改任何代码。

    呈给维护者时说明过的背景:2026-08-11T02:55Z 的裁定曾要求开一次专门的设计会再定,而那次会一直没开;期间本卡两次被误放进排队,两次都撤回了(5351109659、5761985500)。这些情况维护者都知道,今天直接给了裁定。

    文档任务的范围(本地 main 上核对过)

    现在的文档写的恰好是相反的说法,作者照着写,在 private 对象上会撞到 403:

    1. content/docs/permissions/rls.mdx 第 77–80 行附近 —— 「Authoring a write-class policy switches the derivation off for that class: an authored update predicate then decides update alone and widens exactly as written」。要补上:在 private OWD 上,写入的目标行仍然必须在调用方的可读范围内;一条 update 放宽策略,只能到达调用方本来就读得到的行。
    2. content/docs/permissions/sharing-rules.mdx 第 77 行起的配方「owners edit their own records, supervisors edit all」 —— 在 private 对象上,这个配方要配套一个读授权才成立。把这一点写进配方,并给出配套写法。
    3. 顺手还掉 2026-08-11T02:55Z 裁定里欠下的「已知边界」说明(app-authored wideners do not function on private OWD without a matching read grant)。⛔ 写进上面两页文档,不要写进发版说明 —— 发版说明按仓规在发版时集中撰写。

    ⛔ 不在范围内:

    路由保持 domain:services(与本卡现有标签一致)。⛔ 本席不派发、不认领。


    Generated by Claude Code

  9. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    文档任务追加第 4 项 —— 来自 #8241(维护者同意并入)

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T08:13Z。维护者 2026-09-23 对第三组的回复原文:「第三组 同意」。其中 #8241 那一条的建议就是:关闭 #8241,并把它剩下的一处文档错误并进本卡的文档任务。本卡的其余范围不变。

    第 4 项

    content/docs/permissions/permissions-matrix.mdx 第 40–43 行:

    The bypass is posture-gated for row-level security (ADR-0066 D2 ①): RLS policies are short-circuited only on objects whose posture permits it — access: { default: 'private' }, tenancy.enabled: false (platform-global), or better-auth-managed.

    这段只讲了对象姿态,漏掉了部署姿态:在无墙部署(默认的 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

  10. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Claim: domain:services PM seat · 2026-09-23T14:18Z
    Seat: 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/spec in this lane.


    Generated by Claude Code

  11. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    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"
      ]
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions