Skip to content

feat(objectql): 只报告不拦截的悬空引用巡检 —— #4441 留下的 isSystem 残留 (#4551) - #4559

Closed
os-zhuang wants to merge 2 commits into
mainfrom
claude/dangling-reference-sweep
Closed

feat(objectql): 只报告不拦截的悬空引用巡检 —— #4441 留下的 isSystem 残留 (#4551)#4559
os-zhuang wants to merge 2 commits into
mainfrom
claude/dangling-reference-sweep

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #4551

根因与形态

#4441(PR #4511)让引擎在写入路径上强制 lookup 引用完整性,但刻意豁免了 isSystem 写入:种子重放、包安装、启动期供给合法地以「批次完成后才自洽」的顺序写入,让它们 fail-closed 会把一个排序细节变成启动失败。

这个豁免是对的。它留下的是一个只有平台自己能走的门:平台仍可写入指向虚无的引用,而没有任何东西会说出来。PR #4511 明确记录了这处残留、未静默接受;本 PR 是它的落地跟进——只报告,不改写

形态刻意沿用 #4469inspectStrandedRequests()(PR #4502),因为那是同构问题:发现一类平台自身造成的坏数据,只报告不改写。

改动

1. ObjectQL.inspectDanglingReferences()packages/objectql/src/engine.ts

走遍每个已注册对象的readonly 引用字段,报出每一个解析不到行的存储 id:对象、记录 id、字段、目标对象、id 本身、以及持有行的租户。

判断 依据
绝不改写 数据是真实写入的。把悬空链接置空会让存储数据与事实不符;修复到底是补建目标、重跑种子、还是删掉持有行,是平台做不了的判断。
探测器就是强制路径那一个 用的是 this.referenceExists —— assertReferencesResolve 调用的同一个私有方法。巡检与闸门因此在结构上不可能对「什么叫存在」产生分歧。
不可知 ≠ 健康 探测跑过且没找到才记为悬空;目标未注册 / 无 driver / 探测抛错一律计入 undetermined,持有对象读不到则进 skipped。数据源故障永远不会被读成一张健康证明。
readonly 引用跳过 与写入路径同一理由:值是平台铸造的,且其中至少有一个按设计就是哨兵——sys_metadata_history.recorded_bylookup('sys_user') 里填 actor ?? 'system'#4511 的 dogfood 实测发现)。每小时报一次它就是一条永久误报。
空值不是引用 null / '' / [] 复用写入路径的 isEmptyReferenceValue;已展开的 {id, …} 也跳过,与写入路径一致。
RBAC 关联表优先 REFERENCE_SCAN_PRIORITY_OBJECTS。那里的一条悬空行是指向虚无的安全面记录,而受众锚点闸门恰恰要解析那个目标才能评估授权。只是排序,不是过滤——其余对象照扫。
有界 每对象 1000 行 / 每轮 5000 行;同一 目标+id 每轮只探测一次。撞到上限置 truncated——一份提前停下的报告不许读成「全都查过了」,与 undetermined 是同一条诚实规则。

2. 挂在既有清扫时钟上(lifecycle-service.ts / plugin.ts

不新起时钟,挂 ADR-0057 的 lifecycle 时钟:每小时一次,首跑延迟到启动之后——正好是种子写入的引用变得可检查的那一刻。

刻意没有塞进 LifecycleService.sweep()sweep() 会被 db:clean、dogfood growth gate 直接调用,它们要的就是策略执行本身,必须继续只回答这个。两者共享的是时钟,不是方法。新增 tick() 依次跑「先 sweep,后 inspect」,两条腿各自 try/catch 互不影响。

顺序而非并发:并发会与 reaper 的删除竞态(目标被扫中途 reap 掉,会读成一条崭新的悬空引用),且会让两次全表游走同时在飞。

3. 导出(index.ts

新增 DanglingReferenceDanglingReferenceReportREFERENCE_SCAN_PRIORITY_OBJECTSDANGLING_SCAN_ROWS_PER_OBJECTDANGLING_SCAN_MAX_ROWS没有删除或重命名任何东西,#4441 的强制逻辑一行未动。

真实启动验证 —— 巡检第一次跑就抓到了真东西

pnpm dev -- --fresh -p 38109 启动 showcase。服务器确实监听:curl /api/v1/healthHTTP 200,启动日志 33 行含 ➜ API: http://localhost:38109/。等过 lifecycle 时钟 60s 的首跑延迟后,日志第 2895 行(原样粘贴,未编辑):

2026-08-02T03:08:28.553Z WARN [dangling-refs] stored references that resolve to nothing (reported, never rewritten)
{"scannedObjects":63,"scannedRows":182,"scannedReferences":295,"dangling":2,"undetermined":0,"truncated":false,
 "references":["showcase_field_zoo.f_owner[IFMh0g-ouRI8ZOQp] → sys_user:current_user",
               "showcase_field_zoo.f_owner[TP2KMqigH9TAFu0J] → sys_user:current_user"]}

63 个对象 / 182 行 / 295 个引用值,undetermined: 0truncated: false(即这一轮确实全查完了,不是提前停下的空报告),报出 2 条。用完已 kill $(lsof -ti tcp:38109),端口已确认释放。

这两条是真的showcase_field_zoo.f_ownerField.user({ defaultValue: 'current_user' }),而种子没设这个字段。引擎侧是对的(applyFieldDefaults 在无登录用户时刻意留空、不乱盖 owner),值是 SQL 列 DEFAULT 写的——SqlDriver 把任意非对象 defaultValue 原样下发为列默认值(sql-driver.ts:5952),'NOW()' 在紧邻的上一个分支被特判翻译了,'current_user' 从来没有。于是数据库自己往 lookup('sys_user') 里写了字面串。

#4441 抓不到它,因为写入路径根本没看见这个值——它在检查的定义域之外,只能被事后巡检发现。这正是本 issue 存在的理由。按 PD #10 已单独立 issue #4560(unassigned,未在本 PR 修:修法要动 driver 的 DDL 发出逻辑 + 既有列的迁移)。

回归测试撤销后失败条数(实测,非声称)

新增 engine-dangling-reference-sweep.test.ts,14 条全绿。三个实验各自真跑过:

实验 结果
撤掉巡检inspectDanglingReferences 直接返回空报告 + 从时钟上摘掉) 11 / 14 变红。剩下 3 条全是断言沉默的(空值、readonly 哨兵、以及 #4441 写入路径的 pin)——正好是该活下来的那一组。
放松测试替身findOne 对任何东西都返回一行) 5 条变红,其中包括底部那条 #4441 写入路径 pin。这就是「替身比真实实现宽松」逃不过去的那道闸。
让探测不再抛错probeFailures 失效) 「counts an unprobeable target as undetermined」单独变红

关于 #4550:本套用例的坏数据全部经 context: { isSystem: true } 种入——也就是平台自己那扇门,这才使它是 #4551 的用例而不是 #4441 的复测;非 system 写入同一行会被拒(已 pin 在底部)。探测器没有被伪造(就是真实那一个);内存 driver 实现了 limitfields 投影,因为 truncated 是被断言的,一个忽略 limit 的替身会让那条断言变成空话。

门禁

门禁 结果
pnpm --filter @objectstack/objectql test ✅ 97 files / 1584 tests
pnpm typecheck 122/122 tasks
Dogfood shard 1/2 ✅ 39 files / 241 passed, 1 skipped
Dogfood shard 2/2 ✅ 37 passed + 1 skipped files / 197 passed, 2 skipped
pnpm lint (eslint) ✅ clean
pnpm check:nul-bytes ✅ 2856 files, no raw NUL
pnpm check:slot-lookup ✅ ratchet holds, none new
pnpm check:doc-authoring ✅ 215 files clean
pnpm check:role-word ✅ no new occurrences
changeset .changeset/dangling-reference-sweep.md
真实启动(-p 38109 ✅ 见上,报文原样

首次推送的 42b1c3af0 修的是 check:nul-bytes#3127):探测缓存的 key 分隔符我写成了裸 U+0000 字节,那会让 grep/ripgrep 把整个 engine.ts 当成二进制、静默返回零匹配。已改成 unicode 转义写法(源码里写 \u0000),运行时字节完全相同。

边界:没有做、需要拍板的事


Generated by Claude Code

…4551)

#4441 enforces lookup referential integrity on the write path but exempts
isSystem writes, so seed replay / package install / boot provisioning can
still store a reference that points at nothing with nothing saying so.
PR #4511 recorded the residual; this closes it by reporting.

inspectDanglingReferences() walks non-readonly reference fields, probes each
stored id with the SAME private probe the write-path guard uses, and reports
object/record/field/target/id. Reports; never rewrites. Unprobeable targets
count into undetermined and unreadable objects into skipped, so an outage can
never read as a clean bill of health. Bounded per object and per pass, RBAC
link tables first; hitting a cap sets truncated.

Rides the existing ADR-0057 lifecycle clock (hourly, first run past boot)
rather than arming a second one. LifecycleService.sweep() is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 2, 2026 3:13am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…UL byte

check:nul-bytes (#3127) is right to fail this: one literal U+0000 makes
grep/ripgrep treat engine.ts as binary and silently return ZERO matches, so
the file drops out of code search and out of every grep-based lint. The
escape is byte-identical at runtime.

Also records the measured revert numbers in the suite's header comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 07:43
@os-zhuang
os-zhuang enabled auto-merge August 2, 2026 07:43

Copy link
Copy Markdown
Contributor Author

关闭:与已合入的 #4555 重复

本 PR 与 #4555d6bd5a1d5,已于 03:29 合入 main 并关闭 #4551)是同一张卡的两份并行实现。两者同为 #4551、同一标题、同一署名——两个会话同时领了这张卡,#4555 先落地。

冲突不是要解的,是重复的症状:main 上已经有这套逻辑了。

逐条对比后的结论:main 的那份是严格超集

维度 本 PR(#4559 已合入的 #4555
代码位置 261 行塞进 engine.ts 抽成 integrity/dangling-reference-audit.ts 独立模块
关停开关 referenceAudit.enabled: false 单独摘掉这条腿
探测缓存 key 分隔符 (第二次推送才修对) ,一次写对
行数上限 硬编码 DEFAULT_ROWS_PER_OBJECT / DEFAULT_MAX_ROWS 导出 + 可传入
RBAC 关联表优先 SECURITY_SURFACE_OBJECTS
readonly 跳过 / 空值 / undetermined
两条腿互不影响 ✅ 同样 try/catch + logger.warn
测试条数 16 21integrity/ 15 + engine- 6),另有 lifecycle 侧 7 处断言

main 的版本还多覆盖了三种本 PR 没有的情形:探测抛错与探测探不到分别成立、槽位里已展开的记录是读形不是 id、以及无引用字段的对象根本不去读

没有一处是本 PR 独有的。把它解完冲突再合进去,只会让同一个功能在仓库里存在两份。

本 PR 唯一不可替代的产出已经单独留存

真实启动(pnpm dev --fresh -p 38109)跑出的那两条真悬空引用——showcase_field_zoo.f_owner → sys_user:current_user,根因是 SqlDrivercurrent_user 当字面列 DEFAULT 下发(sql-driver.ts:5952 只特判了 NOW())——已按 PD #10 立为 #4560,unassigned,不随本 PR 关闭而丢失。

据此关闭,不合并。#4551 已由 #4555 收口。


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 size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

isSystem 写入仍可产生悬空 lookup 引用——需要一条只报告不拦截的巡检(#4441 残留)

2 participants