Skip to content

fix(objectql): an update carrying a stored reference back unchanged is not refused because it stopped resolving (#29062) - #29245

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-8t26yj
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-8t26yj

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #29062

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:写入时的引用校验只判这次写入"新增"的引用值;记录里本来就存着、这次原样带回的值(包括多用户字段里的单个成员)不再重判。
  • 为什么改:删除用户后,单/多用户字段里仍留着该用户 id(删除联动不覆盖 user 类型);之后编辑这条记录、表单把整条记录带回时,会被 reference_not_found 拒绝,而编辑者根本没碰这个字段。
  • 风险与回滚:只放宽不收紧,以前能过的写入现在照样能过;新增的不存在 id 仍然拒绝,插入仍逐值校验。回滚即 revert 本 PR。
  • 先看:packages/objectql/src/engine-referential-integrity.ts(被拒值与存量行比对的那段),packages/objectql/src/engine.ts(按 id 更新与批量更新两处调用),packages/objectql/src/engine-lookup-referential-integrity.test.ts(新 describe)。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-lookup-referential-integrity.test.ts,预期 21 条全绿,其中 data: a multi-user field keeps a deleted user's id, so a later update carrying the field unchanged is refused reference_not_found #29062 的 7 条。
  • 需要你做的:无,席位落地。

What changes

assertReferencesResolve (the write-time reference_not_found check) gains an optional storedRows reader: the rows the write lands on, as stored before it. After the probes run, a refused value (or a refused member of a multi-valued field) that every one of those rows already stores is dropped from the failures: it is not a reference this write adds. The stored rows are read only once a probe has refused, so a write whose references all resolve reads nothing new.

Every call site of this.assertReferencesResolve( and what it now does:

Call site Stored row to compare against Behaviour
insert (engine.ts, the per-row admit loop) none unchanged: every supplied value is judged
update by id (engine.ts, after the validation rules) priorRecord, always read there (the not-found gate) a refused value the row already stores is forgiven
predicate update, multi: true (engine.ts, the bulk call site) the readPriorRows memo (the matched rows), read only after a refusal forgiven only when every matched row stores it; zero matched rows forgive nothing
dry run (engine-validate.ts) n/a runs the check in insert mode only, so unchanged
upsert n/a the engine has no upsert door (update.options.upsert is retired), so there is no fourth site

The delete walk is untouched: DELETE_SEMANTICS_REFERENCE_TYPES still leaves user out by declaration (moving it is a spec change).

Route B covers the single user field as well as the multi-valued one. The previous measurement on this card (os-dev-report 6571331581) showed the single field refused the same way: a PATCH carrying f_user unchanged after the user was removed answered 400 reference_not_found. The rule is not special-cased by type, so it applies to lookup and master_detail the same way: a stored value carried back unchanged answers what omitting it answers.

Tests

New describe('[#29062] …') in packages/objectql/src/engine-lookup-referential-integrity.test.ts, reusing that file's stub driver. The fixture deletes a sys_user row through engine.delete after a holder row names it, so the stored ghost is the one the delete walk really leaves:

  • a single user field carried back unchanged passes;
  • a multi user field carried back unchanged passes;
  • a multi user field keeping its stored ghost and adding a new ghost refuses only the new one (fields length 1, value: u_never), and the stored value does not move;
  • a single user field repointed at another missing user is still refused;
  • an insert naming the deleted user is still refused;
  • a lookup (single and multi) that adds a missing id is still refused, member by member;
  • a bulk update forgives a value only when every matched row already holds it (over h1 alone it passes; over h1 and h2, where it is new to h2, it is refused and h2 is not written).

Forced test edit: packages/objectql/src/engine-unreadable-master-detail-header.test.ts, the %s — a refused read of the header object answers as a nonexistent header id case. For the repoint by id and bulk repoint doors it wrote h_mine_open, which is the header line l1 already stores (its beforeEach), so the write was not a repoint at all and is now forgiven like an omitted key. The case now names h_mine_locked, another existing header Carol cannot read, so all four doors still pin "an existing unreadable header answers what a nonexistent one does". Nothing else in that file moved; 30/30 pass.

Acceptance notes

  • Out of scope, reported on the card and not fixed here: the stock dev admin gets 403 on admin remove-user; deleteBehavior on a user field is declared but not enforced.
  • A master_detail header the caller cannot read, carried back unchanged, now passes (it is the value the row stores, and omitting the key already passed). A repoint to an unreadable header is still refused as a nonexistent one.

Verification

Red first on base 2af0ae2

vitest run src/engine-lookup-referential-integrity.test.ts with only the new tests committed: Tests 4 failed | 17 passed (21). Failing: single unchanged, multi unchanged, stored ghost plus new ghost (expected [...] to have a length of 1 but got 2), bulk. Cause line: ValidationError: Owner: no sys_user record has id "u_gone".

Ablations (scripts/ablation-replace.mjs, restored blob == HEAD, git diff HEAD empty)
  1. The old "judge every carried value" behaviour put back (const stored = []; in place of the stored-row read): Tests 4 failed | 17 passed (21), the same four cases.
  2. every matched row changed to some matched row: Tests 1 failed | 20 passed (21), only the bulk case.
Tests and gates on the final head 49f236e (origin/main merged twice; the second merge brings #29060)

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT · PR #29245 · 2026-11-02T10:20Z

Head-sha: 49f236e7c843ec7bc48bc73f61bbcd540555b669

维护者速读(席位终稿)

  • 改了什么:写入时的引用校验(reference_not_found)只判这次写入「新加」的引用值。行里已经存着、这次原样带回的值(或多值字段里的某个成员)不再重判。按 id 更新以原行为准;按条件批量更新只有当所有命中行都已存着该值时才放过。新加的失效 id 照旧拒,插入照旧逐值判。
  • 为什么改:data: a multi-user field keeps a deleted user's id, so a later update carrying the field unchanged is refused reference_not_found #29062(p3)。删掉一个用户后,user / 多值 user 字段里会留着它的 id(删除级联本来就不管 user 类型);之后编辑表单把整条记录存回去,就因为一个没人碰过的值被拒。单值 user 字段同样修好。
  • 风险与回滚:低,只放宽:以前接受的写入仍全部接受。原行只在某个值已被拒之后才读。回滚即 revert。
  • 先看:packages/objectql/src/engine-referential-integrity.ts 的 assertReferencesResolve 末尾新加的过滤,以及 engine.ts 两处调用传入的 storedRows。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-lookup-referential-integrity.test.ts src/engine-unreadable-master-detail-header.test.ts。
  • 需要你做的:无。席位按常设授权落地,合并即关 data: a multi-user field keeps a deleted user's id, so a later update carrying the field unchanged is refused reference_not_found #29062。
  • 席位意见:可落,等 CI 全绿即入队。

核对六项

  1. 形态:draft → main;Fixes #29062,与认领 6572439693 一致;5 个文件(changeset、引用校验、engine 两处调用、两份测试),均在申报面内。
  2. 前提实测:席位读 diff:被拒值先收集,有拒才调 storedRows;放过条件是「命中行非空且每一行都存着该值」,零行不放过、priorRecord 为空也不放过;多值成员按字符串 id 比对。删除级联 DELETE_SEMANTICS_REFERENCE_TYPES 未动(席位裁定)。fix(objectql)!: a runAs system flow's record writes keep the reference check while keeping their authorization elevation (#28881) #29060 中途落地,dev 重合 main、两边意图都在,其两套测试 44/44。dev 红先 4/21 失败;两处消融(去掉放过 → 4 红;every→some → 1 红)各转红并复原。
  3. 门禁:dev 本地派生门禁 63 项,62 跑 exit 0,1 项 NOT MEASURED(check-plugin-teardown-shape --self-test 需非浅克隆,与本 diff 无关,CI 作答);objectql 527 文件 / 10145 测试绿,typecheck 0,pnpm lint 0。CI 在跑,入队前席位读本 head 全部 check-runs。
  4. 接受的偏差:open_question 取 A(master_detail 留在规则内)。席位读数:放过只取决于行里存的值,不取决于表头是否存在或可读,所以不重开 objectql: a detail write reads its master-detail header elevated (tenant kept), so a parent-scoped requiredWhen may disclose one bit of a header the caller cannot read — measure whether any real configuration reaches it #22519 的存在性旁路;而省略该字段今天本就能过,原样带回放过不多给任何东西;改指向一个读不到的表头仍拒。被迫改的测试 engine-unreadable-master-detail-header.test.ts 一处(改指向 h_mine_locked,因 h_mine_open 正是 l1 已存的值),已在正文点名。
  5. 范围外去向:dev 报两条,席位另立卡并在此回指:(a)showcase 开发管理员调 admin remove-user 得 403;(c)user 字段上声明的 deleteBehavior 不生效。Metadata-protocol: none。
  6. 落地条款:Clause-②: yes (widening),声明复核在 TIER_DEFAULT 由席位做(本条);无 packages/spec/src/**,无收窄,免契约复审与 dogfood。semver:@objectstack/objectql patch,与 changeset 一致。check-governed-merges --pr 29245:NOT governed。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via reference_not_found (literal, a string literal in assertReferencesResolve))
  • content/docs/data-modeling/fields.mdx (via reference_not_found (literal, a string literal in assertReferencesResolve))
  • content/docs/permissions/system-context.mdx (via assertReferencesResolve (symbol, a method of class ObjectQL; a top-level function), reference_not_found (literal, a string literal in assertReferencesResolve))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via reference_not_found (literal, a string literal in assertReferencesResolve))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 01252a451f06cc85379ce5496ad2e566eea121d3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 97e6c798e8eebc6ac3502ef9ecb20e4f6d4becf5 — the merge of head b330269165da27ee3df2f45ce91fdb80af333fac into base 01252a451f06cc85379ce5496ad2e566eea121d3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 97e6c798e8eebc6ac3502ef9ecb20e4f6d4becf5 && git checkout 97e6c798e8eebc6ac3502ef9ecb20e4f6d4becf5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01252a451f06cc85379ce5496ad2e566eea121d3 b330269165da27ee3df2f45ce91fdb80af333fac && git checkout -B drift-repro 01252a451f06cc85379ce5496ad2e566eea121d3 && git merge --no-ff b330269165da27ee3df2f45ce91fdb80af333fac

node scripts/docs-audit/affected-docs.mjs --json 01252a451f06cc85379ce5496ad2e566eea121d3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 01252a451f06cc85379ce5496ad2e566eea121d3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…alog and fields page (#29062)

Claude-Session: https://claude.ai/code/session_01ADHkmSDzgkXapyAsvrXkrV
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT(补记新 head)· PR #29245 · 2026-11-02T10:38Z

Head-sha: b330269165da27ee3df2f45ce91fdb80af333fac

在 ACCEPT 6574694888(head 49f236e7)之上只多一个纯文档提交,按席位要求补齐 docs-drift 指出的两处:

  • content/docs/api/error-catalog.mdx:「不拒绝」的情形由三条改为四条,第四条即本 PR 的规则(按条件批量更新须每个命中行都存着)。
  • content/docs/data-modeling/fields.mdx:引用完整性一节加一句:更新只判它新加的引用,删除用户留在 user 字段里的 id 不再挡后续编辑。

席位读 diff:2 个文件 +9/-4,未碰 content/docs/releases/,文字与代码行为一致。dev 在本 head 跑 dispatch-gates 新派生的 30 个文档门禁全部 exit 0,合计 93 派生 / 92 跑 / 1 NOT MEASURED(同前,check-plugin-teardown-shape --self-test 需非浅克隆)。代码面与 49f236e7 相同,原六项结论不变。席位意见:可落,等本 head CI 全绿即入队。

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data: a multi-user field keeps a deleted user's id, so a later update carrying the field unchanged is refused reference_not_found

1 participant