Skip to content

fix(objectql)!: a non-system write may name, in a lookup or user field, only a record the caller can read - #30672

Closed
objectstack-fleet[bot] wants to merge 22 commits into
mainfrom
claude/project-thread-m0wypr
Closed

objectstack-fleet[bot] wants to merge 22 commits into
mainfrom
claude/project-thread-m0wypr

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#2 · os-bill · session_01Utp6CYQjMkN514R78sCrTT

维护者速读

  • 改了什么:非系统写在 lookup / user 字段里只能引用调用方自己读得到的记录,读不到的一律答 reference_not_found(与 master_detail 同一条规则),且先于记录校验规则;委派管理员的 adminScope 覆盖的业务单元子树,委派人可读这些单元,范围外一个不读。
  • 为什么改:裁决 6663849489(A 重申 + 委派读);检查清单该项按规则改写为第 7 版,formula/objectql: relationship traversal in predicates — a validation rule or visibility predicate reads one hop through a lookup (record.crm_account.type); replaces the removed os.lookup declaration (#18318, batch #148) #18682 的旧角色退役。
  • 风险与回滚:BREAKING 收窄。依赖「引用读不到的记录」的非系统写会被拒,系统写(种子、安装、启动)豁免且顺序与结果不变;委派读只对 sys_business_unit 的读、只在调用方自己的权限集不给读时生效。回滚即撤本 PR。
  • 先看:packages/objectql/src/engine.ts(三条写路径的顺序)、packages/plugins/plugin-security/src/delegated-admin-gate.ts 的 delegatedUnitReadScope、security-plugin-authz-middleware.ts 第 2/3 步。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-unreadable-reference-target.test.ts,预期 99/99 绿。
  • 需要你做的:无,席位落地。

Warning

BREAKING — accept-set narrowing at the write's reference check (@objectstack/objectql), served by every write door. After the upgrade, a write that is not a system write is refused 400 VALIDATION_FAILED with the field's localized reference_not_found when a lookup or user value it supplies names a record the caller cannot read.

Fixes #29029

Clause-②: yes (narrowing) — the write's reference check narrows: a non-system lookup or user value naming a record the caller cannot read is refused. The business-unit read widens: a delegated admin reads the business units inside the admin scope it holds, and no other.
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches a reference to a record its writer cannot read, or a delegate's business-unit read (a grep of skills/ for reference_not_found finds only the permission-set catalog refusal, unchanged); content/docs states the rule on four pages (below).

What changes

The rule. A write that is not a system write may name, in a lookup, user or master_detail field, only a record its caller's own read door returns. An unreadable reference target always answers reference_not_found, before the record's validation rules. A record the caller cannot read answers exactly as a record that exists nowhere, and so does one whose read by the caller fails. System writes (isSystem) stay exempt, keep the order they had and give the answer they gave; a flow run's elevated write keeps its existence check. A reference to an object the registry does not hold is not judged. tree keeps the existence check alone. A lookup or user value naming a user the caller cannot read answers reference_not_found and its ban is not asked; a user the caller can read is judged for its ban as before (#30205).

The delegate read. An admin scope over a business-unit subtree (ADR-0090 D12) grants read on the business units inside that subtree, and on no unit outside it. It applies to a read of sys_business_unit that the caller's own permission sets do not grant, on the caller's own behalf, bounded to the union of the subtrees the caller's held scopes cover. A caller with a sys_business_unit grant reads as that grant says, as before.

  • packages/objectql/src/engine-referential-integrity.ts — lookup and user join master_detail on the caller's read door; referencesCheckedBeforeRules, the one predicate every write door and the validate preview read for the order.
  • packages/objectql/src/engine.ts, engine-validate.ts — insert, update by id, bulk update and the validate preview check references before the record's validation rules unless the write is a system write.
  • packages/objectql/src/engine-references-and-master-detail.ts, engine-master-detail-related.ts — the caller-readable reference helpers (callerReadableReferenceExists, and callerReadableReferenceRow for the user reference whose ban is also asked), and the formula/objectql: relationship traversal in predicates — a validation rule or visibility predicate reads one hop through a lookup (record.crm_account.type); replaces the removed os.lookup declaration (#18318, batch #148) #18682 docblock now states the rule.
  • packages/plugins/plugin-security/src/delegated-admin-gate.ts, security-plugin-authz-middleware.ts — scopedBusinessUnitIds and delegatedUnitReadScope; the middleware admits the read and narrows it to id IN the scoped units.
  • docs/qa/platform-checklist/areas/records-forms.json — records-forms.predicate-relationship-traversal takes revision 7, written to the rule.
  • content/docs/api/error-catalog.mdx, content/docs/data-modeling/fields.mdx, content/docs/permissions/delegated-administration.mdx — each states the rule where a sentence would otherwise have gone false.
  • content/docs/permissions/system-context.mdx — row 23 says where a system write's reference check runs, and the generated site register names referencesCheckedBeforeRules.
  • .changeset/29029-lookup-user-caller-read.md — @objectstack/objectql and @objectstack/plugin-security, patch.

Tests

  • packages/objectql/src/engine-unreadable-reference-target.test.ts (99): lookup and user × insert / batch insert / update by id / bulk update — an unreadable target is refused as one that exists nowhere; a refused read of the target object answers the same; a multi-valued field with one unreadable member; a system write; a stored value carried back; the read door before the elevated probe. Added: on all four doors and for lookup, user and master_detail, an unreadable reference answers reference_not_found before the record's validation rules while a readable one reaches them; a reference to a record that exists nowhere answers the same before them; the validate preview agrees; controls — a system write and a flow run's system write keep the rules first, and a flow run's write keeps its reference check; a read of the target that fails is refused; a reference to an unregistered object is admitted.
  • packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts (33), three postures: a delegate reads exactly the units its scope covers (find, findOne, count); assigning a unit in scope is admitted; a unit outside the scope it cannot read is refused reference_not_found; controls — a writer with no scope, and an organization admin, unchanged.
  • packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts: the controls' callers read the related account; a new block drives the editor who cannot, and both doors answer the lookup's reference_not_found.
  • Forced edits, rule-preserving: six plugin-security suites provision sys_user with a row per user they name (__tests__/rig-users.testkit.ts) and give organization callers the org_user_ids the runtime fills from sys_member; predicate-related-read-tenant-scope counts the rule's related reads alone, and the peer its rule reads carries an expired ban, so the rule, not the ban check, answers it; four plugin-audit suites leave the write's id-only reference-check read out of their read counters.
  • packages/objectql/src/engine-banned-user-reference.test.ts (11): the rig's caller reads a user's id, as a member does; the ban is read elevated; added: a user the caller cannot read is reference_not_found, and its ban is not read.
  • packages/qa/dogfood/test/predicate-relationship-traversal.dogfood.test.ts: the member who cannot read the parent is answered reference_not_found on parent, and nothing is stored.
Ablations: each through scripts/ablation-replace.mjs, restored blob equal to HEAD, git diff HEAD empty

The objectql pins import ./engine.js from source and plugin-security's vitest aliases @objectstack/objectql to source, so each mutation is read from src/.

  1. delegatedUnitReadScope returns {} instead of { id: { $in: [...ids] } }: delegated-admin posture suite 6 failed | 27 passed — the exact-read pin and the outside-unit reference_not_found pin × 3 postures.
  2. The middleware's admission set to null: 21 failed | 12 passed; the no-scope and organization-admin controls green.
  3. referencesCheckedBeforeRules returns false: 17 failed | 82 passed — every before-the-rules pin on four doors × three field types, the exists-nowhere pins and the preview pin; controls green.
  4. The same predicate returns true: 4 failed | 95 passed — the flow-run system-write order control × four doors.
  5. A failed caller read returns the asked ids: 9 failed | 90 passed — the fails-closed pin and the refused-read pins.
  6. An unresolvable target returns false: 1 failed | 98 passed — the unregistered-object pin.
  7. After the feat(objectql,rest)!: refuse a new assignment to a banned user; count and hand over their work #30546 merge, the user reference's caller read door removed (the ban probe alone): 17 failed | 93 passed over engine-banned-user-reference and engine-unreadable-reference-target — the two ban pins on the caller's read and 15 lookup / user pins.

Deviations

Acceptance notes

  • packages/services/service-automation/src/runas-system-reference-check.integration.test.ts still names masterDetailHeaderExists in a comment, outside this card's surface.
  • The delegate read covers a caller's own read; a read on another's behalf (delegatorSets) is not widened. Explain and canReadObject do not reflect the delegate read.

Verification

All on head 75a7c44d96 (origin/main 14c67d446e merged at 93fce6c797; the dogfood closure was rebuilt after the merge; the one commit after it edits one plugin-security test).

Suites, typecheck, lint
  • @objectstack/objectql: Test Files 557 passed (557), Tests 11155 passed (11155).
  • @objectstack/plugin-security: Test Files 245 passed (245), Tests 5412 passed, 45 skipped (5457).
  • @objectstack/plugin-audit: Test Files 73 passed (73), Tests 1145 passed (1145).
  • pnpm --filter <package> typecheck for objectql, plugin-security (its test program included) and plugin-audit: exit 0.
  • pnpm lint: exit 0.
Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over the changed paths: 97 families, each run with its exit code captured before any pipe, all exit 0. --ran: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN.

The first pass, before the last fixes, had six non-zero exits:

  • check-tenant-audit-census and its self-test: the new testkit's sys_user insert counted as an application write call site. The testkit moved to src/__tests__/.
  • check:system-context-census: referencesCheckedBeforeRules reads isSystem and no row named it. Row 23 now does, and the register is regenerated.
  • check:query-options-erasure: three as any on engine options in a new posture test. They are typed now.
  • check:skill-examples and check:dual-build-cjs-loads: exit 3 until the workspace was built, exit 0 after.

Full dogfood battery on 75a7c44d96 under the verify lock: VERDICT command-exit 0, Test Files 420 passed, 3 skipped (423), Tests 3504 passed, 36 skipped (3540).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT

claude added 18 commits November 2, 2026 12:26
… the caller can read

The write's reference check now asks the caller's own read door for every
`lookup` and `user` value a non-system caller supplies, exactly as it already
did for a `master_detail` header: a target the read door does not return
answers `reference_not_found`, the answer of a target that exists nowhere.
System writes keep their exemption; a value the row already stores is not
judged again.

Claude-Session: https://claude.ai/code/session_01ADHkmSZhbvtSoFM7YTkq6B
Co-authored-by: Claude <noreply@anthropic.com>
…edits for the caller-read rule

Claude-Session: https://claude.ai/code/session_01ADHkmSZhbvtSoFM7YTkq6B
Co-authored-by: Claude <noreply@anthropic.com>
The parked implementation edited engine.ts bodies that main has since moved
into facets: the reference-check docblock now lives in
engine-referential-integrity.ts and the caller-readable reference helper in
engine-references-and-master-detail.ts. The resolution keeps main's layout
and applies the parked change where the code now lives.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…on rules

A reference a caller cannot read answers reference_not_found on every write
door (insert, update by id, bulk update, and the validate preview), whatever
a validation rule would answer. The one-hop predicate read's docblock states
the rule that replaces the retired write-without-reading-the-parent persona.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…scope covers

A delegation's admin scope over a business-unit subtree (ADR-0090 D12) also
grants read on the units inside that scope, so delegated administration can
name the units it administers. The read is admitted only for a read of
sys_business_unit the caller's sets do not grant, on the caller's own behalf,
and is bounded to the union of the subtrees its scopes cover.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…eference check

A write that is not a system write has its references checked before the
record's validation rules; a system write keeps the order it had, so its
answer is unchanged. One predicate, referencesCheckedBeforeRules, is read by
insert, update by id, bulk update and the validate preview.

Pins: each door answers reference_not_found for an unreadable reference a
rule would trip on, and for one that exists nowhere, while a readable one
still answers the rule; a system write and a flow run's system write keep
the rule's answer; a read of the target that fails is refused, and a
reference to an unregistered object is admitted.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
The engine's reference check now reads a user or lookup target through the
caller's own read door, so suites whose subject is another predicate on the
same write provision sys_user with a row for each user they name, and give
their callers the org_user_ids the runtime fills from sys_member.

The write-preview parity controls read the related account, and a new block
drives the editor who cannot: both doors answer the lookup's
reference_not_found, never the rule. The tenant-scope suite counts the rule's
related reads alone, and a user the caller cannot read is now answered on the
reference check.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
The records-forms predicate-relationship-traversal item takes revision 7: a
member who cannot read the lookup parent may not name it, and is answered
reference_not_found for a tripping and a non-tripping parent alike. The
dogfood case is rewritten to that rule, and the changeset states the refused
writes and the delegate's business-unit read.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…reference check

The write's reference check reads each lookup or user value it names through
the caller's read door, projected to id alone. The read-count pins measure
what the audit summary reads, so that read is left out of their counters.
The log line for a failed caller read names a referenced record rather than a
master-detail header, as the read now serves lookup and user values too.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…ds its scope's units

The error catalog's reference callout and the field reference's referential
integrity section state that a non-system write may name, in a lookup,
master_detail or user field, only a record its caller can read, before the
record's validation rules. The delegated administration page states that an
admin scope grants read on the business units inside its subtree.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…first

With the reference check before the record's validation rules, a non-system
write that names a master-detail header it cannot read, or one that exists
nowhere, answers the field's reference_not_found rather than the rule's
unevaluable refusal; a header the row already stores is still bound by the
rules, and a system write keeps the rules first. The header-read counts add
the reference check's read door, and the stored-metadata suites count only
reads that ask for more than id.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…nd the delegate-read pins type their options

The system-context page's reference-check row names referencesCheckedBeforeRules, and its site register is regenerated.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
The reference check composes #30205's ban check with the caller-read rule:
a `lookup` or `user` value naming a `sys_user` row asks the caller's read
door first (callerReadableReferenceRow), then the one elevated probe that
answers the row's existence and its ban. A user the caller cannot read
answers reference_not_found, and its ban is not read. The banned-user rig
gives its caller the read of a user's id a member has, and pins both.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
… rule is what answers it

A non-system write now meets the reference check, and with it the ban check,
before the record's validation rules; the control's peer keeps `banned`
true for the rule to read, with a ban no longer in force.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/plugin-security, @objectstack/verify, touching 26 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 80 pages), master_detail (literal, 40 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 221 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 35 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 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6eef4c978eb120244c4e43b1b0e825f3634f9ba — the merge of head 4ba91079fb120a94b2557e75eff3e84bfabb9659 into base 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a, 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 b6eef4c978eb120244c4e43b1b0e825f3634f9ba && git checkout b6eef4c978eb120244c4e43b1b0e825f3634f9ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a 4ba91079fb120a94b2557e75eff3e84bfabb9659 && git checkout -B drift-repro 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a && git merge --no-ff 4ba91079fb120a94b2557e75eff3e84bfabb9659

node scripts/docs-audit/affected-docs.mjs --json 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a

⚠️ 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 4bf9a2d7eeeec7c07971aba1a8734c227acaaa3a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Revision 7 of records-forms.predicate-relationship-traversal, the dogfood
case and the objectql pins say what the write answers: a reference the
caller cannot read is reference_not_found, before the record's validation
rules.

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

Copy link
Copy Markdown
Contributor Author

security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg

Security read at 75a7c44d96 (asked by domain:server#2 on mailbox #30424). This is not the contract review, which is still owed on its own.

  • The PR's two stated rules hold on every door it names: an unreadable reference target answers as one that exists nowhere, ahead of the record's rules, and fails closed when the caller's read fails; the delegate business-unit read stays inside the held scopes, the caller's own behalf and the caller's own organization, and a caller's own filter cannot widen it.
  • A security item is with the owner seat. Please hold this PR out of the queue until security#1 posts a clear here. No change to this PR is asked for now; if one is, it will come as a rule on this thread.

…writer reads

A grant's holder is a `user` value, so a non-system write naming it must
name a user its caller can read; the suite provisions its three users, as
the sibling grant suites do, so the set the row names is what it measures.

Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT
Co-authored-by: Claude <noreply@anthropic.com>
…e probe names

A write may name, in a lookup or user field, only a record the writer can
read (#29029). The verifier's permission sets now read every record of each
object a probed detail's required reference names outside its master chain,
and the fixture's manager position reads the people a contract's signer
names.

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

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Superseded · 2026-11-06T00:31Z

This PR's code continues on #30747 (branch claude/project-thread-2l4nno). Its head merges main onto 4ba91079fb, this PR's head, and the delta is unchanged (39 files, +1317/−246). The body is carried over and updated to the new head. Card #29029 is claimed by domain:server#4 (claim 6672100060). The contract review, the landing and the review conversation continue on #30747. Closing this PR without merging.

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/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant