feat(objectql): 只报告不拦截的悬空引用巡检 —— #4441 留下的 isSystem 残留 (#4551) - #4559
Draft
os-zhuang wants to merge 2 commits into
Draft
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
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 #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