Repository navigation
[Decision] explain: does an asker whose own read is REFUSED (a declared 4xx) count as an asker who cannot read, under ruling A of #22795? #22851
Description
Ruled: 6110031776 · letter C · 2026-10-11T14:26Z
This card carries the refused-asker half of implementing ruling A of #22795 on #22792 (PR #22847). #22792 keeps the absent-asker half, built literally to the ruling's mechanism and held out of the queue until this card is answered.
Filed by the domain:services seat 2 (seat post #21118, session_013LbZ9MhPp1iriZEtgJqioA) from the open question in #22792's dev report. security: classes and positions only, ⛔ no reproduction recipe on any public surface.
One-sentence problem
An administrator who is not allowed to read an object at all can still use "why can this user access?" to tell which record ids exist on that object, although the object's own read door tells that administrator nothing.
Background (measured)
- Ruling A of [Decision] explain 的
read判定:调用方读不到的那一行,要不要答得和一个不存在的 id 完全一样(#21771 裁决 A 是否延伸到 explain 的 read) #22795 (6107185582): whether explain may say a record exists is decided by the ASKER's read access. PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847 builds it: the asker check isrecordAbsentToCallerasked with the caller's context. - That question answers "absent" only when the read returns no row. A read the door REFUSES with a declared 4xx (for example, the asker holds no read grant on the object) answers "not absent" by design. On the write path it means "the write keeps the answer it has today" (
absentUnderCallerRead,security-plugin.ts). - So under the ruled mechanism a refused asker counts as one who CAN read and gets the explained principal's full record story. Its own read door answers the same refusal for a stored row and for an id no row carries. The dev measured this on PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847's rig with a temporary, uncommitted cell (a
manage_usersasker without the object's read grant): explain told the two ids apart, and the door did not. maintoday is weaker still: every asker gets the principal's story. PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847 closes the absent-asker case and leaves only the refused one.
Governing text
- Ruling A, headline (
6107185582): "An asker who cannot read the record gets, for every operation, exactly what an id no row carries gets". The D12 bullet: "A D12 delegated administrator who cannot read the row gets the nonexistent-id shape." - Ruling A, sub-question 4: "The asker's by-id read through the read door, every data middleware included:
recordAbsentToCallerasked withcallerContext… ⛔ No second notion of "can read"." - The two disagree only for a refused read: the headline counts that asker as unable to read, and the named mechanism counts it as able.
- PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847's own
explain.zod.tstext says "an id the ASKER cannot read (the caller of explain, whose own read does not return the row …)" and "Whether a row exists is never disclosed to an asker who cannot read it." As built, that is not enforced for a refused asker.
Protocol declaration
No schema shape changes under any option. The text of explain.zod.ts (the record description and the recordIds "Missing records" clause) must match whichever option is ruled.
Premises, each with a re-check
- The write path's notion counts a refusal as "not absent":
git grep -n "return !answer.refused && answer.value == null" origin/main -- packages/plugins/plugin-security/src/security-plugin.ts→ 1 hit (control:async function readUnlessRefusedin the same file → 1 hit). - PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847's asker gate uses that notion:
git show 842f1ca03:packages/plugins/plugin-security/src/explain-engine.ts | grep -c "askerCannotRead"→ non-zero (control:explainAccessForAskerin the same file). - The edit-without-read side effect under option A is the dev's reading, NOT MEASURED by the seat: a self-explaining principal whose read is refused but who holds the write grant meets a by-id write door that keeps its own answer.
Options × real cost × workload
| Option | What it does | What a customer sees | Workload |
|---|---|---|---|
| A | A refused read counts as "cannot read" for every asker, the explained principal itself included | The probe closes. A user with edit-but-no-read rights who explains a write to themselves is told "no such record", while that write would actually succeed | domain:services, one patch round on PR #22847, about 10 lines plus pins (dev's estimate); precedent PR #22847 (+792/−57) |
| B | Keep the ruled mechanism; narrow the spec text to "an asker whose own read finds no row"; record the refused case as accepted | An administrator without read access to an object can still tell which ids exist there through the access panel | domain:services, one text-only patch round on PR #22847 |
| C | A refused read counts as "cannot read" only when the asker is NOT the explained principal (an administrator or delegate explaining someone else); a self-explanation keeps the write path's notion | The probe closes for the access panel's askers. A user explaining their own access sees exactly what their own doors would show | domain:services, one patch round on PR #22847, about 15 lines plus pins (seat's estimate, no precedent) |
Business translation
- A: a locked filing room answers "no such file" to anyone without the key, including the clerk who may add pages to that file but not read it.
- B: the receptionist may not open the cabinet, yet the visitor log still shows which files are in it.
- C: anyone checking someone else's access is told only what they themselves could read; a person checking their own access is told what their own doors would do.
Four axes (from the business side)
- 项目长远合理性: 主流平台在诊断别人的权限时把门槛放在提问者身上(GitHub 对非成员的私有仓库答 404;AWS IAM 策略模拟器是特权调用)。C 与 A 都让「披露与否」只看提问者的读门答案,终态一致;C 不把自解释和门的结果拆开,A 会在「能写不能读」的权限组合上让解释与门不一致,B 留下一个已知探针并写进协议。
- 实际业务需求: 拉动是实测的:已上线的 Studio「为什么这个用户能访问」面板,提问者正是
manage_users管理员与 D12 委派管理员,他们的数据可达范围是自己的(裁决 A 的前提)。被拒读的提问者是这块面板上的真实情形,不是假设。 - 防 AI 犯错: 出错时谁看到什么:B 下,一个没有读权的管理员(或代它调用的 agent)静默拿到「这个 id 存在」的信号,没有任何拒绝;A、C 下它拿到与不存在的 id 完全一样的答案,不泄露。A 的副作用是解释对「能写不能读」的用户说「没有这条记录」,而门实际放行:那是解释说错,会把排障的人带偏。
- 创业阶段不扩散: 三者都不加新面、不加门禁;B 最省,但把一个探针写成协议;A、C 是同一 PR 里十几行代码。
Recommendation, fallback, confidence gap
- Recommended: C. Fallback: A.
- On ① alone, C: two years on, the explain door should be "the asker learns only what its own doors would tell it", as on the platforms above. C is that rule exactly. A is that rule for third-party askers, but over-applies it to self-explanation, and B is not that rule.
- Confidence gap: the edit-without-read side effect that separates A from C is the dev's reading, not measured by this seat. If no permission set can grant a by-id write without read, A and C give the same answers, and A is simpler.
- 自检:只看①选 C;②③④ 是否翻转:否(② 支持关闭探针,不区分 A/C;③ 偏 C,A 有解释说错的副作用;④ 三者成本都小)。
After the ruling
- A or C: PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847 gets one patch round (the asker gate classifies a refused read per the ruled option; pins for a refused third-party asker, and under C a refused self-explanation as a control). Its
explain.zod.tstext already says it. Then the seat review and the at-tier contract review, then the queue. - B: PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847 gets a text-only patch round: the
explain.zod.tslines and the changeset say "an asker whose own read finds no row", and the refused case is recorded as accepted in the PR. Then the same review path. - In every case security(explain): explain's
readverdict for a row the caller cannot read disagrees with the read door, and differs from its answer for an id no row carries (a controlled_by_parent detail under an unreadable master, measured) #22792 closes with PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847, and this card closes as its decision record.
os-decision-facets
① 项目长远合理性:C/A 让披露规则只有一条(提问者的读门答没答出这一行),缩小特例;B 把一个已知探针写进协议,扩大特例。
② 实际业务拉动:已上线的 Studio 访问说明面板,提问者正是可能没有对象读权的管理员与委派管理员;今天就撞得上。
③ 防 AI 犯错:A/C 下无读权的提问者得到与不存在 id 一模一样的答案(无泄露);B 下静默泄露存在性;A 另有解释说错的副作用。
④ 创业阶段不扩散:三者都不加键、不加门禁;A/C 约十几行加 pin,B 只改文字但留下永久的已知例外。
Prior rulings read: "explain asker", "recordAbsentToCaller refused", "existence signal explain" → 0 relevant hits; #22795 A 6107185582; #21771 A 5985885287; absentUnderCallerRead / readUnlessRefused docblocks (the #7505 three-outcome rule); thread: #22792 through 6108485513 and the dev report.
Dedupe (MCP search_issues, closed cards included): "explain asker refused read grant existence signal record story administrator explaining another user" → 0 hits; "recordAbsentToCaller declared 4xx refusal counts as can read explain disclosure" → 12 hits, none this question.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 11, 2026
Ruling: batch S2-1 item 1 · letter C · maintainer 「同意C」 2026-10-11T14:25Z
domain:servicesseat 2 (#21118) ·session_013LbZ9MhPp1iriZEtgJqioA, writing asobjectstack-fleet[bot]via the relay. Provenance: the maintainer's word, verbatim 「同意C」, in this seat's session chat, answering batch S2-1 (this card alone), which the seat presented in the director's decision format at the maintainer's request (「22851 按总监决裁格式呈给我」). Freshness gate: the body and its 0 comments were re-read before this record. Thread-read: none on this card; #22792 through6109818307.The ruling
C. A refused read counts as "cannot read" only when the asker is not the explained principal. When an administrator (
manage_users) or a D12 delegated administrator explains another user, an asker whose own by-id read of the record is refused (a declared 4xx, for example no read grant on the object) gets, for every operation, exactly what an id no row carries gets, as an asker the read door returns no row to does. A principal explaining itself keeps the write path's notion (recordAbsentToCaller: absent means no row), so a self-explanation shows what that principal's own doors would do.read判定:调用方读不到的那一行,要不要答得和一个不存在的 id 完全一样(#21771 裁决 A 是否延伸到 explain 的 read) #22795 (6107185582) for the one case its sub-question 4 left open. The read asked is the same caller-context by-id read; only the classification of a refusal differs, and only for a third-party asker.domain:services, one patch round on PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847, about 15 lines plus pins; no precedent.Execution
readverdict for a row the caller cannot read disagrees with the read door, and differs from its answer for an id no row carries (a controlled_by_parent detail under an unreadable master, measured) #22792 leavespm:blockedforpm:dispatchedin this round; the patch round goes to the same dev on PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847: the asker gate classifies a refused read as "cannot read" for a third-party asker, with pins (a refusedmanage_usersasker, a refused D12 delegate, each against a nonexistent id; CONTROL a refused self-explanation keeps today's answer), and theexplain.zod.tstext and the changeset say exactly this.CONTRACT_REVIEW_TIERreview of record, and the queue. security(explain): explain'sreadverdict for a row the caller cannot read disagrees with the read door, and differs from its answer for an id no row carries (a controlled_by_parent detail under an unreadable master, measured) #22792 closes with PR fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's #22847.completedin this act as the decision record;needs-user-decisioncomes off.