fix(sharing): 记录删除时撤销该记录的全部 sys_record_share(不分 source)+ boot 期按记录存在性的孤儿清扫 (#5103) - #5196
Merged
Merged
Conversation
…rants (#5103) A `sys_record_share` row says "principal P has level L on (object O, record R)". Delete R and the row describes nothing — yet it stayed in the table forever. #4779 (PR #5102) bound an `afterDelete` for this, but inside the sharing-RULE package, where two conditions fenced it in: it revokes only `source: 'rule'` rows, and `bindRuleHooks` binds only on objects that appear in `sys_sharing_rule`. So an object using nothing but MANUAL shares had no delete hook at all, and manual share + record delete = a permanent orphan. Harm is bounded today only because record ids are never reused — an assumption no gate enforces. A custom primary key, an import preserving ids, or any future recycling turns those rows into real escalation: a new record on a recycled id inherits the dead record's recipients. Maintainer ruling (2026-08-04, on the issue): option A. Option B (a platform polymorphic weak-reference cascade) is a separate engine-lane design card (#5180); when it lands these hooks collapse into it. - `record-share-cascade.ts` binds ONE global `beforeDelete`/`afterDelete` pair and judges the object's sharing posture from `sharingModel` metadata PER DELETE. Nothing is enumerated at boot, so nothing goes stale — an object that gains sharing at runtime is covered on its next delete with no rebind, which is a stronger answer to the ruling's hot-update requirement than a metadata subscription would have been. Bounded row sets are revoked synchronously and set-based; an unbounded delete queues an object-scoped orphan sweep rather than the rule path's revoke-then-regrant, which is unavailable here because nothing can re-create a manual share. System-context deletes cascade too. - `SharingService.sweepOrphanedRecordShares` is the record-existence twin of `sweepOrphanedRuleGrants` (#4433) — that one asks whether the RULE row still exists and therefore can never see a manual share. Runs on `kernel:bootstrapped`, keyset-paged with one batched existence probe per object per page and a scan cap that reports itself. An object whose probe FAILS keeps its rows: "could not ask" is not "the record is gone". - The `beforeDelete` row-set stash moves from `rule-hooks.ts` into `bulk-recompute.ts` beside its resolver, so both hook packages share one answer per write instead of resolving the same predicate twice. Rule recompute still never touches a manual share (#5102's pin, re-asserted in this branch's tests). Only the record's DELETION revokes it, and only because there is no longer anything to have access to. Fixes #5103 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
…ring-orphan-cascade
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
The rule path warns on its unbounded branch because recipients visibly lose access to records they still qualify for until the re-grant lands. Nothing equivalent happens here: the sweep only removes rows whose record is gone, so a deferred reclaim takes nothing from a surviving record and has no user-visible consequence. A warn on every predicate delete would only erode the level (AGENTS.md's own caution against over-applying it). The sweep still warns when it actually revokes rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
…5103) `cascadeDeleteRelations` removes a `deleteBehavior: 'cascade'` child through the public `delete()` rather than the driver, so a detail record swept away with its master reaches this hook like any other delete. That is the fact behind treating `controlled_by_parent` as sharing-capable: manual grants are refused there, but the rule evaluator can still materialise rows under system context, and this is the path that reclaims them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
os-zhuang
marked this pull request as ready for review
August 4, 2026 08:37
os-zhuang
enabled auto-merge
August 4, 2026 08:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5103
按维护者裁定(2026-08-04,issue 内)的方案 A:plugin-sharing 在所有启用 sharing 的对象上绑
afterDelete,撤销该记录的全部sys_record_share行(rule + manual),同批补 boot 期按「记录还在不在」的孤儿清扫。方案 B(平台级多态弱引用级联)是另一条车道的设计卡 #5180,本 PR 不建任何平台机制;B 落地之日,这里的钩子可以收敛进去。修的是什么
一条共享行说的是「主体 P 在(对象 O,记录 R)上有 L 级权限」。R 被删掉后这行什么也不描述了,但它会永远留在表里。
#4779(PR #5102)已经补过一个
afterDelete,但它长在规则包里,被两层条件夹住:只撤source: 'rule'的行;而且bindRuleHooks只在sys_sharing_rule里出现过的对象上绑。于是一个sharingModel: 'private'、只用手工共享、从没配过规则的对象,一个删除钩子都没有 —— 手工共享 + 记录删除 = 永久孤儿。今天危害有限,且只因为记录 id 永不复用 —— 这个假设没有任何门禁保护。自定义主键、保留原 id 的导入、或未来任何形式的 id 回收,都会让这些行立刻变成真实越权:新记录一落到旧 id 上,就直接继承了旧记录那批收件人。次要危害:
sys_record_share单调增长,Setup 的 Record Shares 列表展示指向不存在记录的行。怎么做的
1. 绑定模型:不枚举,所以不会过期
裁定要求「启用 sharing 的对象」由
sharingModel运行期元数据决定,并处理对象后来才开 sharing 的热更新情形。这里没有走「订阅
metadata:reloaded然后重绑」,而是走了一条更强的路:绑一对无 object 过滤的全局beforeDelete/afterDelete,把姿态判断放进 handler,在每次删除时现算。枚举这个动作根本没有发生,所以没有会过期的集合,也就没有需要记得重绑的时机 —— 一个运行期才打开sharingModel的对象,在它的下一次删除上就被覆盖了。(同仓先例:plugin-pinyin-search的 companion 钩子也是全局绑 + 廉价早退,正是因为按对象枚举会过期。)判定函数
objectCanCarryRecordShares单独导出、单独测试。它故意比assertSharingEnforced宽:那个门禁管的是「能不能新建一条手工共享」,而清扫要面对的是「表里可能存在哪些行」—— 包括对象改姿态之前写下的行,以及规则求值器以系统上下文直接 grant 出来、绕过了该门禁的行。清扫谓词比可能存在的行集窄,正是孤儿活下来的方式。明确写下的边界:没有声明
sharingModel的系统对象(解析为 public,没有任何门禁会读它上面的共享)不走级联,以免把这个钩子压到平台最热的删除路径上。规则理论上仍可能在这种对象上物化出行 —— 那点残留归 boot 清扫管,它是被写下来的边界,不是没人注意到的洞。2. 有界删除同步撤,无界删除交给清扫
谓词删除(
multi: true)不带 id,落地后行也找不回来了 —— 这正是 #4779 用beforeDelete暂存解决过的问题。本 PR 复用了它的解析器与HookContext键(把暂存从rule-hooks.ts搬到bulk-recompute.ts,挨着解析器放),两个钩子包里谁先跑谁解析,另一个直接读同一个答案 —— 一次写入只解析一次谓词,不是两次。$in/ 谓词命中 ≤ 1000 行)→ 同步、集合式撤销,按 200 分块。系统上下文的删除也级联 —— 与规则重算故意跳过
isSystem(交给 boot backfill)不同,这里没有对等的兜底:记录反正是没了,一次 seed 清理或平台任务的删除,制造孤儿的方式和交互式删除完全一样。3. boot 期孤儿清扫:谓词是「记录」,不是「规则」
sweepOrphanedRecordShares是sweepOrphanedRuleGrants(#4433)的孪生,形状照抄,谓词换了:那个问「规则行还在不在」,所以它结构上永远看不见一条手工共享;这个问「记录还在不在」,与 source 无关。它在kernel:bootstrapped上跑,覆盖三类:本 PR 之前的历史孤儿、钩子失败或进程中途死掉漏下的、以及级联故意跳过的那种姿态。按裁定要求做了限界:keyset 分页(不是 OFFSET —— 这个 walk 边读边删,offset 会让行滑过游标,#4363)、每页每对象一次批量
id IN (…)存在性探测(不是每行一次)、每次 boot 有扫描上限并且会报告自己被截断。探测失败的对象,它的行原样留着 —— 「问不到」不等于「记录没了」,把瞬时驱动故障读成后者会造成不可逆的权限丢失(#4757 的教训反过来用)。没有改的
规则重算依然永不触碰手工共享(#5102 钉住的不变量,本分支的测试里重新断言了一遍)。这条边界就是全部要点:只要记录还在,手工授权就是一个人做出的、规则求值无权推翻的决定。只有记录被删除才撤销它,而且仅仅因为已经没有任何东西可以被访问了。
测试
packages/plugins/plugin-sharing/src/record-share-cascade.test.ts,32 条,覆盖裁定点名的五项:假引擎钉在引擎自己导出的删除派发谓词上(
assertEngineDeleteDispatch,#4434/#4550),并复现了三个真实语义:before/after 共用同一个HookContext、无object选项的钩子对所有对象触发、谓词删除不填input.id。变异检验(两处,各自被杀)
source: 'rule'(即 #5102 的行为)source: 'rule'(即老清扫的盲区)两处还原后全绿。另有一条测试直接断言绑定没有 object 过滤,一条断言运行期新开
sharingModel的对象无需重绑即被覆盖 —— 任何回退到「boot 期按规则表枚举」的改动都会被这两条挡住。已在
origin/main上做过一次 merge(incoming 只动plugin-email/service-settings,与本 diff 无重叠),merge 后重跑测试与 typecheck 仍全绿。文件面
packages/plugins/plugin-sharing/**+ 测试 + changeset。packages/spec与packages/objectql零改动 —— 现有的钩子/注册表 seam(registry.getAllObjects已是EngineSchemaRegistryView声明过的契约面、triggerHooks的全局钩子匹配)够用,没有需要向下捅的地方。顺带发现(未在本 PR 修)
sys_share_link能力令牌仍然有效 —— 与 #5103 同族,但因为是无身份令牌所以更糟 #5190 ——sys_share_link是同一族孤儿,而且更糟:resolveShareLink逐项查 token/revoked/expires/audience/密码,唯独不查记录是否还存在,而分享链接是无身份的能力令牌,id 复用时谁持有谁生效。已按 PD chore: version packages #10 另开 issue(未认领),并写明它可以复用本 PR 建好的record-share-cascadeseam。Generated by Claude Code