feat(objectql): 只报告不拦截的悬空引用巡检 —— #4441 留下的 isSystem 残留 (#4551) - #4559
Closed
os-zhuang wants to merge 2 commits into
Closed
feat(objectql): 只报告不拦截的悬空引用巡检 —— #4441 留下的 isSystem 残留 (#4551)#4559os-zhuang wants to merge 2 commits into
isSystem 残留 (#4551)#4559os-zhuang wants to merge 2 commits into
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
marked this pull request as ready for review
August 2, 2026 07:43
os-zhuang
enabled auto-merge
August 2, 2026 07:43
Fixes #4551
根因与形态
#4441(PR #4511)让引擎在写入路径上强制 lookup 引用完整性,但刻意豁免了
isSystem写入:种子重放、包安装、启动期供给合法地以「批次完成后才自洽」的顺序写入,让它们 fail-closed 会把一个排序细节变成启动失败。这个豁免是对的。它留下的是一个只有平台自己能走的门:平台仍可写入指向虚无的引用,而没有任何东西会说出来。PR #4511 明确记录了这处残留、未静默接受;本 PR 是它的落地跟进——只报告,不改写。
形态刻意沿用 #4469 的
inspectStrandedRequests()(PR #4502),因为那是同构问题:发现一类平台自身造成的坏数据,只报告不改写。改动
1.
ObjectQL.inspectDanglingReferences()(packages/objectql/src/engine.ts)走遍每个已注册对象的非
readonly引用字段,报出每一个解析不到行的存储 id:对象、记录 id、字段、目标对象、id 本身、以及持有行的租户。this.referenceExists——assertReferencesResolve调用的同一个私有方法。巡检与闸门因此在结构上不可能对「什么叫存在」产生分歧。undetermined,持有对象读不到则进skipped。数据源故障永远不会被读成一张健康证明。readonly引用跳过sys_metadata_history.recorded_by往lookup('sys_user')里填actor ?? 'system'(#4511 的 dogfood 实测发现)。每小时报一次它就是一条永久误报。null/''/[]复用写入路径的isEmptyReferenceValue;已展开的{id, …}也跳过,与写入路径一致。REFERENCE_SCAN_PRIORITY_OBJECTS。那里的一条悬空行是指向虚无的安全面记录,而受众锚点闸门恰恰要解析那个目标才能评估授权。只是排序,不是过滤——其余对象照扫。目标+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)新增
DanglingReference、DanglingReferenceReport、REFERENCE_SCAN_PRIORITY_OBJECTS、DANGLING_SCAN_ROWS_PER_OBJECT、DANGLING_SCAN_MAX_ROWS。没有删除或重命名任何东西,#4441 的强制逻辑一行未动。真实启动验证 —— 巡检第一次跑就抓到了真东西
pnpm dev -- --fresh -p 38109启动 showcase。服务器确实监听:curl /api/v1/health→ HTTP 200,启动日志 33 行含➜ API: http://localhost:38109/。等过 lifecycle 时钟 60s 的首跑延迟后,日志第 2895 行(原样粘贴,未编辑):63 个对象 / 182 行 / 295 个引用值,
undetermined: 0、truncated: false(即这一轮确实全查完了,不是提前停下的空报告),报出 2 条。用完已kill $(lsof -ti tcp:38109),端口已确认释放。这两条是真的:
showcase_field_zoo.f_owner是Field.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直接返回空报告 + 从时钟上摘掉)findOne对任何东西都返回一行)probeFailures失效)关于 #4550:本套用例的坏数据全部经
context: { isSystem: true }种入——也就是平台自己那扇门,这才使它是 #4551 的用例而不是 #4441 的复测;非 system 写入同一行会被拒(已 pin 在底部)。探测器没有被伪造(就是真实那一个);内存 driver 实现了limit与fields投影,因为truncated是被断言的,一个忽略limit的替身会让那条断言变成空话。门禁
pnpm --filter @objectstack/objectql testpnpm typecheckpnpm lint(eslint)pnpm check:nul-bytespnpm check:slot-lookuppnpm check:doc-authoringpnpm check:role-word.changeset/dangling-reference-sweep.md-p 38109)边界:没有做、需要拍板的事
WARN [dangling-refs] …,与 [approvals] 存量僵尸请求无人认领:已终态但 run 悬空的请求落在 releaseDeadRunRequests 的盲区里 #4469 的inspectStrandedRequests一致)。若希望它进一步落到某个sys_*对象或 admin 只读端点,那是一次面的新增,需要单独定夺。current_userframework token as a literal column DEFAULT, so the database itself writes a non-id into alookup('sys_user')#4560(本 PR 实测发现,unassigned):current_user令牌被 SQL driver 当成字面列 DEFAULT 下发。修法涉及 DDL 发出逻辑与既有列迁移,不在本 PR 范围。Generated by Claude Code