Skip to content

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

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

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
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