Repository navigation
fix(objectql)!: an update compares a value the caller sends only against the stored row as the caller reads it - #31063
objectstack-fleet[bot] wants to merge 38 commits into
Conversation
… 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>
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>
…e record 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>
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>
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>
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>
Claude-Session: https://claude.ai/code/session_01Utp6CYQjMkN514R78sCrTT Co-authored-by: Claude <noreply@anthropic.com>
…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>
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…d values the caller may read A refused reference value an update carries back is forgiven only when every row the write lands on stores it as the caller's own read door serves that row. A column the caller cannot read holds no stored value for it, so it forgives nothing and the value is judged like any other. A caller who reads the column keeps the forgiveness; a system write compares the rows as stored, as before. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…mpares only stored values the caller may read For master_detail, lookup and user, by id and in bulk: a column the caller may edit but not read answers a value the rows store exactly as a value they do not. Controls: a caller who reads the column keeps the forgiveness, the same grant edits another column, and a system write keeps its exemption. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…ed values the caller may read The changeset and the two pages that describe the forgiveness say that a column the caller may edit but not read holds no stored value for it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…s the forgiveness's system-write read Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…y service loads at module top Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…d row as the caller reads it A non-system write's secret reference is kept only where the caller's own read door serves the column as stored, and a master-detail repoint's prior header is read off the stored foreign key only as the caller reads it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
… a stored value the caller cannot read Pins the secret-reference keep and the master-detail repoint pre-check, by id and in bulk, with the real security plugin; rewrites the own-reference suites to the rule that a secret column reads masked. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
# Conflicts: # packages/plugins/plugin-security/src/reference-forgiveness-readable-column.test.ts
…not served as the write binds it When the patch names the master-detail FK over a stored FK the caller's read door did not serve as stored, the preview binds the prior header as unresolved, as the write does, so preview and write give one verdict. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…answer Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7f145756918dedec6df82e70116380331bdbf6d2 && git checkout 7f145756918dedec6df82e70116380331bdbf6d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 022b4e7eef42c54c3489175af3f3be870a994a9d 82a9a6bbdad7b753e92d22009da814e295f8d138 && git checkout -B drift-repro 022b4e7eef42c54c3489175af3f3be870a994a9d && git merge --no-ff 82a9a6bbdad7b753e92d22009da814e295f8d138
node scripts/docs-audit/affected-docs.mjs --json 022b4e7eef42c54c3489175af3f3be870a994a9d
|
|
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Contract reviewServed-tier: Reviewed at 2026-11-06T23:51Z, read-only: the PR body, its file list, the three-dot diff against the merge base Check-runs on the head as they stand. 15 completed green, among them Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, Test UI, Spec property liveness, PR-body guards and Lint · PM dispatch-gates; 21 still running: Build Core, Build Docs, Test Core 1/8 to 8/8, Type Check · workspace / source gates / debt ledger / consumer gates, Lint · ESLint / repo & tooling gates / docs & code guards / CI, release & engine gates / scoped · slot lookup & corpus / scoped · query-options erasure, and Live E2E UI; 12 skipped by their own conditions (UI-only and opt-in lanes). The queue guard reads this record together with those; nothing here is a verdict on a run that has not finished. ① Derived judgmentsThe PR narrows two accept sets of S1 — a
S2 — a master-detail repoint pre-check binds the prior header as unresolved when the caller is not served the stored FK; the validate preview binds it the same way. Right, with one edge noted under ③.
Tests: the four suites pin the narrowed answer on the real security plugin by id and in bulk, with the mask echo, a new value, a reading caller and a system caller as controls, and the dogfood suite pins it over REST. Nothing in the diff is prose-only; the docs row 23e matches the code. ② Semver level
Clause-②: yes (narrowing) ③ Boundary flags
④ Design judgmentThe narrowing is the published rule made real, not a capability taken away at the consumer. The published spec already says a non-system write may seal a new value or keep its own record's reference, and the read door already serves a Attempts to break it, all read against the cited code: a payload FK whose value the strip takes out (caller may not edit) does not name the master, so the hidden-column branch is never entered for it; a system caller is returned unprojected rows at the top of the projection and keeps both pass-throughs; a Must-fix: none. Implemented-by: VERDICT: PASS |
|
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc 席位核对ACCEPT · 2026-11-06T23:53Z · head
Generated by Claude Code |
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Clause-②: yes (narrowing) — a non-system update that sends a
secret:reference is refused even when the record already holds it, and a non-system update naming the master-detail FK of a detail with a parent-scopedrequiredWhen, in a FK column it may edit but not read, no longer lets a row that already lacks a required value rest under the header it names.Metadata-protocol: none
维护者速读
secret:引用一律 400(含记录自己的引用);FK 不可读者点名表头时,缺必填值的遗留行不再“留存”。回滚即还原两处 engine 代码。packages/objectql/src/engine.ts按 id 与批量两处priorMasterIdsCallerReads调用;packages/objectql/src/engine-secret-internal-fields.ts的保留分支;packages/objectql/src/engine-master-detail-related.ts新增的priorMasterIdsCallerReads与预览previousParents的同步。pnpm --filter @objectstack/plugin-security exec vitest run src/reference-forgiveness-readable-column.test.ts,预期 44 个全绿。What changes
An update compares a value the caller sends with the stored row in two more places than the reference check, and both now read the stored row only as the caller's own read door serves it (
storedReferencesCallerReads, the one projection the reference check already uses), by id and in bulk. A system write is unchanged at both.secretreference keep (encryptSecretFields). A non-system write keeps asecret:reference only where every row it lands on serves exactly that reference to the caller's read door. The read path serves asecretcolumn masked to every caller, so no reference is kept: every non-system reference is refused400 VALIDATION_ERROR, one answer whatever it names and whatever the column holds. Before, the record's own stored reference was accepted and kept. The echoed mask, a new value andnullbehave as before; the system principal keeps the pass-through. The refusal sentence now says a reference is kept only where the caller reads it in the column, and names omitting the field or sending back the mask as the way to leave it unchanged.update, by id and bulk). On a detail with a parent-scopedrequiredWhen, when the write names the FK, the header the ADR-0113 pre-check reads aspreviousParentcomes from the stored FK only as the caller's read door serves it (priorMasterIdsCallerReads). A FK the caller cannot read as stored leaves the prior header unknown, bound as the unresolved header, so whether a second header is read, and which, does not depend on the stored value. A FK the caller reads is judged as before, and a write that does not name the FK reads nothing more.resolvePreviewParents, reached only fromvalidate(), whose stored rows are already projected throughservedAsStored) now binds the prior header the way the write does: when the patch names the FK over a stored FK the caller was not served as stored,previousParents[i]is the unresolved header (null). Preview and write now give one verdict for that caller, whatever header the row hung off.content/docs/permissions/system-context.mdxrow 23e states the new answer.Tests
packages/plugins/plugin-security/src/reference-forgiveness-readable-column.test.ts, two new top-level describes on the real security plugin:{ readable: false, editable: true }, naming a header answers identically whether the line hangs off that header or another, by id and in bulk, and nothing is written; controls: an edit that does not name the FK lands, a caller who reads the FK keeps the legacy rest and the repoint refusal, a system write is judged as before.packages/objectql/src/secret-field-own-ref-write.test.tspinned the old keep of a record's own reference. It now pins the rule above: the record's own reference is refused exactly as another's, by id and on a predicate update, and abeforeUpdatehook that spreads the stored row back is judged on the reference it carries (one that omits thesecretcolumn lands).packages/qa/dogfood/test/secret-field-own-ref-write.dogfood.test.tspinned the same keep over REST and now pins the same refusal.packages/objectql/src/validate-preview-parent.test.ts, a new top-level describe: for a caller not served the FK, preview and write give the samerequiredverdict whatever header the line hangs off; control: a caller served the FK keeps the legacy rest and the repoint refusal, in both.Acceptance notes
secret:reference from a non-system write: every in-repo writer of asecretcolumn (webhooks, SSO client secret, connector connections and credentials, flow credentials) writes under a system context, the CLI ownership apply works below the engine, and no shipped hook writesctx.previousback into an update payload. The one in-repo reliance was the two suites above.beforeUpdatehook that spreadsctx.previousinto the payload on an object with asecretfield is now refused for a non-system caller; the changeset names the fix (omit thesecretcolumns).nullis judged as before.Verification
Ablations (each committed first, mutated through scripts/ablation-replace.mjs, restored to HEAD and verified empty)
git diff HEADempty.Suites and gates
Code readings are on
f49cf38154; the final head82a9a6bbdaadds only one changeset sentence, and the changeset gates were re-run on it.pnpm --filter @objectstack/objectql test: Test Files 563 passed (563), Tests 11361 passed (11361).typecheckandcheck:test-typecheckOK.pnpm --filter @objectstack/plugin-security test: Test Files 248 passed (248), Tests 5773 passed, 45 skipped.typecheckandcheck:test-typecheckOK.pnpm --filter @objectstack/dogfood testunder the verify lock: VERDICT command-exit 0, Test Files 430 passed, 3 skipped (433); Tests 3572 passed, 41 skipped (3613).dispatch-gates --commands, 10 paths): 97 derived, 95 exit 0.check:type-check-debtfirst exited 3 (prerequisite) and passed on a rerun after its own full build. Two stay NOT MEASURED locally, exit 3, prerequisite not met (a full build):spec check:skill-examplesandcheck:dual-build-cjs-loads; CI runs both.dispatch-gates --ranwith exit codes: 97 accounted, 0 unrun.82a9a6bbda:check-adr-0087-registration(the changeset reads as breaking, clause-② narrowing, not-required no-migration-prescription),check-changeset-no-major,check-empty-changeset,check:nul-bytes,check:doc-authoring, all exit 0.Generated by Claude Code