Skip to content

docs(adr-0055): amend the single-level limit and the transitive-chain non-goal — chains compose since 17.3.0 - #16606

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11188-adr-0055-transitive-chain-amendment
Sep 7, 2026
Merged

docs(adr-0055): amend the single-level limit and the transitive-chain non-goal — chains compose since 17.3.0#16606
os-zhuang merged 1 commit into
mainfrom
claude/issue-11188-adr-0055-transitive-chain-amendment

Conversation

@os-trump

@os-trump os-trump commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #11188

ADR-0055 recorded controlled_by_parent as single-level-only in two places. PR #11183 merged to main on 2026-08-23 (61713314e) and shipped in @objectstack/plugin-security 17.3.0, so both statements have been false since that day. This amends the record. Docs-only: one file, docs/adr/0055-master-detail-controlled-by-parent.md.

维护者速读(草稿)

改了什么。 只动 docs/adr/0055-master-detail-controlled-by-parent.md 一个文件:Status 行加一条 **Amended** 说明;Consequences 里 "Single-level only in v1" 那条原样保留、下面就地标注「限制何时被什么解除」;Non-goals 里 "Transitive nested master-detail chains" 那条原样保留、就地标为已被推翻;文末新增一节 Amendment,把运行时现在的行为、两个守卫、以及深度界的正确读法写清楚。⛔ 没有删除任何一行原文,没有改任何代码,没有碰其它 ADR 或 content/docs/**

为什么改。 这两行不是"文档小瑕疵"。它们记录的强制边界比运行时实际执行的更窄。这个方向的错误对用户是安全的(用户不会因此多拿到权限),但对审阅者是不安全的:以后有人来审这块面,会从 ADR 推出"单层"这个不变量,把链式遍历读成漂移,然后以为自己在修 bug 而把单层行为恢复回去 —— 那会重新打开 #11082 修掉的越权读写(两级链在第二级是 org-wide 可读可写)。所以这不是补文档,是堵一条会被后人踩的坑。

风险与代价(含回滚)。 纯文档,零运行时风险,不发布任何包内容,所以不带 changeset(改用 skip-changeset 标签)。真正的代价只有一项:措辞本身。深度界 CBP_MAX_CHAIN_DEPTH 极易被写成"支持最多 8 层链",而代码明确不主张这件事 —— 本 PR 直接引用常量自己的表述("a COST ceiling, not a semantic rule, and it is deliberately not a 'supported chain length'"),并写明到界时两侧都是拒绝(读侧空 master 集、写侧 deny)且记录被拒的链。回滚成本为零:git revert 一笔即可,不牵连任何代码或发布物。

席位意见。 (留空 —— 受管面,由维护者填写。)

你要做的。 一件事:读措辞,然后手动合并docs/adr/** 是受管面,本 PR 保持 draft,⛔ 未挂 auto-merge、⛔ 未入队、⛔ 无席位批准 —— 人工合并本身就是验收记录。若合并日期与 Amendment 里写的 2026-09-07 相差较大,可顺手把该日期改成实际合并日(记录里 2026-08-23(运行时变更落地日)与 17.3.0 是独立事实,不随之改动)。

What was false, and why each line got a different edit

The card's central point is that the two statements sit in different contexts and are therefore not the same edit twice:

Where What kind of statement How it is amended
## Consequences- **Negative / limits (honest).** → "Single-level only in v1." a statement of the enforced limit kept, and marked in place with when and by what the limit was lifted
## Non-goals → "Transitive nested master-detail chains" a record of a decision that was taken (it sits beside "A permission-model rewrite — explicitly rejected") kept, and marked as reversed — a decision that gets overturned is worth reading as an overturn

⛔ Neither line was deleted. "Single-level only in v1" was true and correctly recorded for v1; what changed is the version it describes. Deleting the lines would erase that the limit existed and that the non-goal was once chosen — the part of a decision record that cannot be reconstructed from the code.

## Phasing is untouched: its P0/P1/P2 entries mention transitive chains nowhere (re-verified).

The facts the amendment states, each re-measured on origin/main this round

The merged diff of #11183 was re-read from git rather than any summary of it.

  • Read half. computeControlledByParentFilter AND-composes the master's own controlled_by_parent derivation as a third layer, resolved through that same method, so the recursive answer cannot drift from the top-level one.
  • Write half. assertControlledByParentWrite walks the chain hop by hop, running the same three master-edit legs — extracted verbatim as assertMasterRowEditable — until it reaches a master that governs its own rows. Refusals keep the 403 PERMISSION_DENIED envelope named for the caller's own object and operation.
  • The depth bound is CBP_MAX_CHAIN_DEPTH = 8, and it is a COST ceiling, not a supported chain length. The amendment quotes the constant's own header rather than inventing a framing: "This is a COST ceiling, not a semantic rule, and it is deliberately not a 'supported chain length'." At the bound the read derivation returns the empty master set and the write gate denies, each logging the refused chain.
  • Termination does not depend on the bound — the visited set grows strictly over a finite schema registry; the cycle guard carries the objects already resolving on the branch.
  • Both guards fail closed. ⛔ Never degrades to "no restriction" — that is precisely the defect controlled_by_parent derivation does not compose across a chain — a child whose master is itself controlled_by_parent is readable and writable org-wide #11082 fixed.
  • Not a blanket refusal. The single-level case is byte-for-byte unchanged; a detail whose whole chain is reachable stays reachable.

Every citation is a symbol anchor, never a line number

⚠️ The card itself cited security-plugin.ts:360 for CBP_MAX_CHAIN_DEPTH; on today's main the constant is at line 385. This card exists because a citation stopped resolving, so the amendment creates no fresh one. Seven new anchors were added, all in the path#symbol form, and all resolve as declaration:

CBP_MAX_CHAIN_DEPTH · computeControlledByParentFilter · assertControlledByParentWrite · assertMasterRowEditable (in packages/plugins/plugin-security/src/security-plugin.ts) and effectiveSharingModel · buildReadFilter · checkEdit (in packages/plugins/plugin-sharing/src/sharing-service.ts).

check-adr-symbol-anchors confirms the arithmetic: the corpus moved 2056 → 2063 anchors and 270 → 277 symbol anchors (declaration 244 → 251), with 0 line anchors surviving.

Changeset judgement: none, skip-changeset instead

The diff is one file under docs/adr/. It publishes nothing: no package's shipped content changes, so no version of any package is affected and a changeset would produce a release entry describing a change no consumer can observe. That is the repo's stated skip-changeset case, and the gate derivation agrees — dispatch-gates.mjs reports 13 changeset-dependent families as "derived against a path that does not exist yet", i.e. inapplicable. The label is applied on this PR.

Verification

Gate family derived mechanically on the final head (7ad4a055e) with node scripts/pm/dispatch-gates.mjs --commands, each exit code captured immediately after its own single redirection, never through a pipe. 17 derived, 17 run, 0 NOT-MEASURED, 0 UNRUN (--ran reconciliation, asserted with --repo objectstack-ai/objectstack). The four an ADR edit breaks, quoting each gate's own verdict line:

check-adr-symbol-anchors  exit 0  ✅ 2063 anchors across 139 records resolve — 277 symbol
                                     (251 declaration, 26 literal) … 0 line anchors survive.
  --self-test             exit 0  ✅ every finding class provoked, healthy anchors silent,
                                     population live, wiring pinned (2063 live anchors)
pnpm check:adr-anchors    exit 0  check-adr-anchors: OK (53 anchored file(s) … 33752
                                  citation(s) across 4277 file(s) resolve)
check-adr-links           exit 0  ✅ 679 relative link destination(s) under docs/adr/ resolve
  --self-test             exit 0  ✅ discrimination, census, ADR-0046 pin and baseline
                                     staleness all verified
pnpm check:doc-authoring  exit 0  ✓ 398 files clean — no bare metadata literals
pnpm check:nul-bytes      exit 0  OK (scanned 8173 text file(s); no raw ASCII control bytes)

One family first returned exit 3 — PREREQUISITE NOT MET, which is NOT a finding: pnpm --filter @objectstack/lint run check:doc-formula-expressions needs @objectstack/formula and @objectstack/lint built. After turbo run build for those two it re-ran to exit 0. The other eleven were green on the first pass.

Repo-wide lint is CI's run, and the narrowing here is measured rather than assumed. The receiving population is read from eslint's own config, not guessed: eslint --no-inline-config --format json over the changed file reports 1 file, 0 errors, and the message "File ignored because no matching configuration was supplied" — every files: glob in eslint.config.mjs names TS/JS extensions only, none names .md. Invariance for untouched files: type-aware linting is not enabled in this config (no parserOptions.project, no typed @typescript-eslint rules — the config states this itself), so a diff confined to a file eslint has no configuration for cannot move the verdict on any file it does lint.

验收备注

  • Noted, not filed. The - **Per-request resolution cost.** bullet under Consequences reads "One extra master-id query per controlled_by_parent object per request". It is not false after fix(plugin-security): compose controlled_by_parent across a chain (#11082) #11183: each hop is itself a distinct controlled_by_parent object contributing one query, and the constant's own header says this is "the per-request cost ADR-0055 already books as a known limit". The amendment makes the per-hop reading explicit without editing that bullet.
  • Noted, not filed. No hand-written page under content/docs/** claims single-level for controlled_by_parent — grepped. The statements there ("access follows the master record", "derived from the master record") are level-agnostic and stay true. Nothing to amend outside this file.
  • The "Set-size ceiling" limit and the "Large-scale share-table/join" non-goal both still hold; this PR moves one bullet in each list, not both.

Governed surface — landing is the maintainer's

🔴 docs/adr/** is a governed surface (AGENTS.md Prime Directive #14). This PR stays draft. ⛔ No auto-merge armed, ⛔ not enqueued, ⛔ no seat approval. It is left visibly awaiting the maintainer's hand-merge, which this repo treats as the acceptance act.


Generated by Claude Code

…w compose

ADR-0055 recorded `controlled_by_parent` as single-level-only in two places:
the "Single-level only in v1" bullet under Consequences (a statement of the
ENFORCED limit) and the "Transitive nested master-detail chains" entry under
Non-goals (a record of a DECISION taken). #11183 landed on 2026-08-23 and
shipped in @objectstack/plugin-security 17.3.0, making both false: the read
derivation AND-composes the master's own controlled_by_parent filter as a
third layer, and the write gate walks the chain hop by hop.

Neither line is deleted. The limit was true and correctly recorded FOR v1 —
what changed is the version it describes — so it is marked in place with when
and by what it was lifted; the Non-goal is marked as REVERSED, because a
decision that gets overturned is worth reading as an overturn.

The amendment states the runtime in the code's own framing: CBP_MAX_CHAIN_DEPTH
is a COST ceiling, not a "supported chain length"; at the bound both walks fail
CLOSED (empty master set / deny) and log the refused chain; termination does not
depend on the bound. Every code citation is a symbol anchor, never a line
number — a rotted line citation is what this card exists for.

Docs-only: no published package content changes, so no changeset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions github-actions Bot added the size/s label Sep 7, 2026
@os-trump os-trump added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/s labels Sep 7, 2026 — with Claude
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 7, 2026

os-trump commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

维护者速读(终稿 · 席位对照自己读的 diff 校正后)

⛔ 受管面 PR,等你亲手合并。本席未批准、未入队、未挂 auto-merge,也不会。

改了什么

只动一个文件 docs/adr/0055-master-detail-controlled-by-parent.md+59 / −2。我逐行读过整份 diff:

两条原文一行都没删。 ## Phasing 未动(我核过,它的 P0/P1/P2 压根没提传递链)。

为什么改

这两行记录的强制边界比运行时实际执行的更窄。这个方向的错误对用户安全、对审阅者不安全:后来审这块面的人会从 ADR 推出「单层」这个不变量,把链式遍历读成漂移,以为自己在修 bug 而把单层行为恢复回去——那会重新打开 #11082 修掉的越权读写。

⭐ 而 PR 里多写了一段我认为很有价值、卡片没要求的东西:它说清了 v1 的限制是缺陷而不是未完成的功能。第二级不是"被窄化地强制",而是根本没有强制——两条独立路径都会 fall open。我核了它引的代码:effectiveSharingModelcontrolled_by_parent 映射成 public:105),buildReadFilter 对非 private 一律返回 null:511),读侧于是以 system 身份跑空谓词、返回每一行 master;写侧 checkEdit 对 public 答 abstain:742),而 abstain 不是 deny,于是每一行 master 都答 true。三处引用全部属实。

风险与代价(含回滚)

纯文档,零运行时风险,不发布任何包内容 ⇒ 无 changeset,用 skip-changeset 标签。回滚是一笔 git revert,不牵连任何代码或发布物。

真正的代价只有措辞一项,而这一项处理得对:深度界极易被写成「支持最多 8 层链」,而代码明确不主张这件事。PR 直接引用常量自己的表述,我核过是逐字的——security-plugin.ts:361

⚠️ This is a COST ceiling, not a semantic rule, and it is deliberately not a "supported chain length".

⚠️ 一处我自己的更正,因为它正是这张卡的主题:我第一次核这句引文时用了太小的搜索窗口(常量在 :385,而这段头注从 :360 开始),得到"查无此句"。那是 NOT MEASURED,不是不存在——我差点在一张关于"引用失效"的卡上报出一条假发现。用对照重测后确认逐字属实。

⭐ 顺带一提,那段头注比 PR 引用的还多说了一件事:它记录了 8 是怎么选出来的(仓内每个已声明的链都是 1 跳,#11082 的消费方需要 2 跳,8 是最深需求的四倍)。PR 说"值不可从树上推导、常量头注是论证它的地方"——准确。

席位意见

建议合并,措辞我核过没有问题。 三条我实测的依据:

  1. 零行号引用,七个新引用全是 path#symbol 形式,且 check-adr-symbol-anchors 的算术对得上(语料 2056 → 2063,符号锚 270 → 277,0 个行号锚存活)。这很重要——这张卡的存在原因就是一条引用停止解析,而卡片自己引的 security-plugin.ts:360 今天已漂到 :385。用符号锚是唯一不会重犯的写法。
  2. 该 ADR 会破的四道门禁全绿,且报的是各自的判据行而不是裸退出码:check-adr-symbol-anchors(+ self-test)、check:adr-anchorscheck-adr-links(+ self-test)、check:doc-authoring。17 个门族全跑、0 NOT-MEASURED。
  3. 一个门族先答 exit 3 PREREQUISITE NOT MET被记为「不是判据」而不是当成通过,补了构建后重跑到 exit 0。

你要做的

一件事:读措辞,然后手动合并。

⚠️ 一个可选的顺手动作:Amendment 标题里的日期写的是 2026-09-07(撰写日)。若你实际合并日相差较大,可顺手改成实际合并日——但 2026-08-23(运行时变更落地日)与 17.3.0 是独立事实,不要跟着改


Generated by Claude Code

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 needs-user-decision skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants