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-zhuangos-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 typecheck122/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

vercelBot commented Aug 2, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 2, 2026 3:13am

Request Review

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

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
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

关闭:与已合入的 #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
测试条数1621integrity/ 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

auto-merge was automatically disabled August 2, 2026 08:51

Pull request was closed

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 3, 2026
…tstack-ai#4559 重复开发的三重堵漏 (objectstack-ai#4588) (objectstack-ai#4589)
* ci: fail a PR at open time when an earlier open PR already fixes the same issue (objectstack-ai#4588)
GitHub lets any number of open PRs declare Fixes #N for the same issue;
objectstack-ai#4555 and objectstack-ai#4559 proved the cost this morning. Three changes, one per hole:
- duplicate-fix-guard.yml: same-repo closing keywords are checked against
every other open PR; the LATER PR goes red with a pointer to the earlier
one (first come, first served, matching the pm-dispatch claim convention).
Re-runs on 'edited'/'synchronize' so resolving the conflict either way
clears the red. Branch names that carry no declared issue number get a
warning, never a failure.
- Claim comments now require a session ID (pm-dispatch template, AGENTS.md,
CLAUDE.md): under the shared GitHub identity the assignee field cannot
answer "is this claim mine?" — the comment's session line is what can.
- Branch naming claude/issue-<n>-<slug> documented in AGENTS.md so
git ls-remote | grep issue-<n> is a one-command pre-check.
Script validated with node --check under the full injected-identifier
wrapper; keyword regex exercised against 10 samples including the
prefixes-#N false-positive and cross-repo exclusions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
* fix(ci): duplicate-fix-guard ignores closing keywords inside code spans and fenced blocks
The guard's first real run — on its own PR — counted the PR body's
backticked DISCUSSION of 'Fixesobjectstack-ai#4551' as a declaration. GitHub's own
closing-keyword parser ignores code formatting; a guard stricter than
the linker it protects turns prose mentions of other PRs' fix lines
into spurious reds. Strip ```fences``` and `spans` before matching.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-zhuang@claude