docs(adr-0055): amend the single-level limit and the transitive-chain non-goal — chains compose since 17.3.0 - #16606
Conversation
…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
维护者速读(终稿 · 席位对照自己读的 diff 校正后)⛔ 受管面 PR,等你亲手合并。本席未批准、未入队、未挂 auto-merge,也不会。 改了什么只动一个文件
⛔ 两条原文一行都没删。 为什么改这两行记录的强制边界比运行时实际执行的更窄。这个方向的错误对用户安全、对审阅者不安全:后来审这块面的人会从 ADR 推出「单层」这个不变量,把链式遍历读成漂移,以为自己在修 bug 而把单层行为恢复回去——那会重新打开 #11082 修掉的越权读写。 ⭐ 而 PR 里多写了一段我认为很有价值、卡片没要求的东西:它说清了 v1 的限制是缺陷而不是未完成的功能。第二级不是"被窄化地强制",而是根本没有强制——两条独立路径都会 fall open。我核了它引的代码: 风险与代价(含回滚)纯文档,零运行时风险,不发布任何包内容 ⇒ 无 changeset,用 真正的代价只有措辞一项,而这一项处理得对:深度界极易被写成「支持最多 8 层链」,而代码明确不主张这件事。PR 直接引用常量自己的表述,我核过是逐字的——
⭐ 顺带一提,那段头注比 PR 引用的还多说了一件事:它记录了 8 是怎么选出来的(仓内每个已声明的链都是 1 跳,#11082 的消费方需要 2 跳,8 是最深需求的四倍)。PR 说"值不可从树上推导、常量头注是论证它的地方"——准确。 席位意见建议合并,措辞我核过没有问题。 三条我实测的依据:
你要做的一件事:读措辞,然后手动合并。
Generated by Claude Code |
Fixes #11188
ADR-0055 recorded
controlled_by_parentas single-level-only in two places. PR #11183 merged tomainon 2026-08-23 (61713314e) and shipped in@objectstack/plugin-security17.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:
## Consequences→- **Negative / limits (honest).**→ "Single-level only in v1."## Non-goals→ "Transitive nested master-detail chains"⛔ 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.
## Phasingis untouched: its P0/P1/P2 entries mention transitive chains nowhere (re-verified).The facts the amendment states, each re-measured on
origin/mainthis roundThe merged diff of #11183 was re-read from git rather than any summary of it.
computeControlledByParentFilterAND-composes the master's owncontrolled_by_parentderivation as a third layer, resolved through that same method, so the recursive answer cannot drift from the top-level one.assertControlledByParentWritewalks the chain hop by hop, running the same three master-edit legs — extracted verbatim asassertMasterRowEditable— until it reaches a master that governs its own rows. Refusals keep the403 PERMISSION_DENIEDenvelope named for the caller's own object and operation.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.controlled_by_parentderivation does not compose across a chain — a child whose master is itselfcontrolled_by_parentis readable and writable org-wide #11082 fixed.Every citation is a symbol anchor, never a line number
security-plugin.ts:360forCBP_MAX_CHAIN_DEPTH; on today'smainthe 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 thepath#symbolform, and all resolve asdeclaration:CBP_MAX_CHAIN_DEPTH·computeControlledByParentFilter·assertControlledByParentWrite·assertMasterRowEditable(inpackages/plugins/plugin-security/src/security-plugin.ts) andeffectiveSharingModel·buildReadFilter·checkEdit(inpackages/plugins/plugin-sharing/src/sharing-service.ts).check-adr-symbol-anchorsconfirms 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-changesetinsteadThe 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 statedskip-changesetcase, and the gate derivation agrees —dispatch-gates.mjsreports 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) withnode 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 (--ranreconciliation, asserted with--repo objectstack-ai/objectstack). The four an ADR edit breaks, quoting each gate's own verdict line:One family first returned exit 3 — PREREQUISITE NOT MET, which is NOT a finding:
pnpm --filter @objectstack/lint run check:doc-formula-expressionsneeds@objectstack/formulaand@objectstack/lintbuilt. Afterturbo run buildfor 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 jsonover the changed file reports 1 file, 0 errors, and the message "File ignored because no matching configuration was supplied" — everyfiles:glob ineslint.config.mjsnames TS/JS extensions only, none names.md. Invariance for untouched files: type-aware linting is not enabled in this config (noparserOptions.project, no typed@typescript-eslintrules — 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.验收备注
- **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 distinctcontrolled_by_parentobject 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.content/docs/**claims single-level forcontrolled_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.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