Repository navigation
fix(objectql)!: a predicate rule's read through a master-detail FK asks the caller's read door for the header first — an unreadable header answers as a nonexistent one (#22892) - #22956
Conversation
…ks the caller's read door for the header first An unreadable header is now unavailable to the rule exactly as a header that exists nowhere is (`unresolved`), whatever it holds. The elevated, tenant-kept read follows only for the headers the door returned; system callers and a plain lookup traversal are unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…K — an unreadable header answers as a nonexistent one Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
… for the narrowing Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…edicate-related-master-detail
📓 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 — 17 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 18fe10eeca2bb522520ca3cbc057d78fad81041d && git checkout 18fe10eeca2bb522520ca3cbc057d78fad81041d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 51b43a8dc45fef9d86229509ab6b2679ffa9d501 6586f417fb3e86495afc657428ba703071d9e226 && git checkout -B drift-repro 51b43a8dc45fef9d86229509ab6b2679ffa9d501 && git merge --no-ff 6586f417fb3e86495afc657428ba703071d9e226
node scripts/docs-audit/affected-docs.mjs --json 51b43a8dc45fef9d86229509ab6b2679ffa9d501
|
Contract reviewServed-tier: Inputs: card #22892 (body and all four comments: triage entry check 6113638846, ruling 6115662038 which binds, claim 6116757077, os-dev-report 6118217501); card #22519's ruling 6107211425 and PR #22891's contract record 6113514249 as the precedent this card extends; PR #22956 (body, its one comment — the docs-drift advisory — its file list, and the net diff of Check-runs at the final read: 34 runs, no ① Derived judgmentsThe accept-set change the diff implies — one narrowing, for non-system callers, on a predicate rule's one-hop read through a One answer for nowhere, unreadable-open and unreadable-locked, on every door and the preview — right. The change sits inside the one resolver every door calls: the A refused door read binds as nothing returned — right, and in the fail-closed direction. The elevated read runs only for headers the door returned — right. System callers unchanged — right. Plain Unwalled related objects unchanged — right. The own-read branch runs first and is byte-identical apart from assigning #22891's three seams unchanged — right. A readable header still judged on stored values, a hidden or masked state column included — right. The door is projected to Docblock and row 29b — right. The "Read under SYSTEM authority, like Public surface — none changed. No ADR-0055 §3 — not touched and not contradicted. Its master-edit gate covers Cost, noted and not a contract matter. One id-projected caller read per master-detail reference field a rule reads through, once per write batch, for a non-system caller — the shape #22891 already pays on the header binding. ② Semver levelChangeset: The ③ Boundary flags
Deviations (seven), each answered:
Out-of-scope finding (one) — answered, not escalated. Governance: no governed-surface path in the file list; Implemented-by: VERDICT: PASS Adopted by |
Fixes #22892
Clause-②: no (narrowing)
This implements ruling A (6115662038). The ruling extends ruling A on #22519 (6107211425) to a predicate rule's one-hop read through a master-detail FK: a header the caller cannot read is unavailable to the rule exactly as a header that exists nowhere is.
The defect, reproduced before the fix
ObjectQL.resolvePredicateRelated(#18682) resolves the related rows that ascript/cross_fieldrule reads through a reference field (record.FIELD.COLUMN). For a related object with a tenant column, such as a tenanted master, it read the row elevated with no caller row bound. Validation rules run before the reference check. So for a non-system caller who may write a detail but cannot read its header, the rule judged the header's stored state.The probe ran on
main018b077fbfas a scratch file, not committed. Its rig: one detail object whose rule refuses a line under a locked invoice, and a synthetic read scope in which the caller reads only the headers it owns.400 VALIDATION_FAILED,_recordrule_violation: "... the related record was not found"open400 VALIDATION_FAILED,invoicereference_not_foundlocked400 VALIDATION_FAILED,_recordrule_violation: the rule's own messageThe fix
The change is in
packages/objectql/src/engine.ts,resolvePredicateRelated. When the traversed field ismaster_detailand the caller is not SYSTEM, the related read takes the write's two-read shape. It reusesmasterDetailHeadersCallerReads, PR #22891's read door; no second helper is added.id. A header it does not return gets no row and no own-read set, so its binding isunresolved. That is exactly the binding a header that exists nowhere gets. A failed door read returns no header, so every header is missing, as fix(objectql)!: a detail write asks the caller’s read door for its master-detail header — an unreadable header answers as a nonexistent one (#22519) #22891 rules for the write's own header read.Unchanged:
lookuptraversal is unchanged; the ruling does not cover it.unreadablefor every miss, stored or not, so its answers were already identical.Docs:
parent" rationale is rewritten to state the rule.content/docs/permissions/system-context.mdxrow 29b states the new bound. The census counts do not move, because noisSystemread was added: the new branch keys on the function's existingbound(check-system-context-census: OK).Pins
The new file
packages/objectql/src/engine-predicate-related-master-detail-header.test.tshas 22 tests on #22891's rig. Refusals assert the ADR-0112 envelope (status400,codeVALIDATION_FAILED), the field codes, and the message with the header id removed.validate()preview answers as the write does.insertMany: unreadable rows fail as the nonexistent row does, and readable rows are judged on their stored state.lookuptraversal to the same object is unchanged: it judges the stored state and asks no read door.Ablation (one-off, not committed)
0931f18a94, withnode scripts/ablation-replace.mjs(trap-restored).| undefined)?.type === 'master_detail') {becamemaster_detail_ablated, which switches the new branch off. Anchor count went x1 to x0; blob2a14a9f089a9became0d452143b11f. The subject is imported fromsrcby a relative path, so no build was involved.record.crm_account.type); replaces the removedos.lookupdeclaration (#18318, batch #148) #18682 engine file: 13 failed / 81 passed. Every unreadable-equals-nonexistent pin turned red, as did the mechanism pin. All 9 controls, fix(objectql)!: a detail write asks the caller’s read door for its master-detail header — an unreadable header answers as a nonexistent one (#22519) #22891's 30 pins 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 removedos.lookupdeclaration (#18318, batch #148) #18682 suite stayed green.2a14a9f089a9equals HEAD, andgit diff HEADis empty.Verification
fa29574fe1(os-verify-lockVERDICT command-exit 0):@objectstack/objectqlvitest run --project localgave Test Files 403 passed (403), Tests 7875 passed (7875).origin/main(6586f417fb):pnpm installwas not needed: the lockfile did not move.pnpm --filter @objectstack/objectql typecheckpassed:tsc --noEmitpluscheck:test-typecheckOK. The new test file is in thetsconfig.test.jsonprogram (--listFilesOnlycount 1).engine-predicate-relationship,engine-reference-tenant-scope,engine-required-when-parent,validate-preview-parent,rule-relationship-traversal,federated-injected-column-readers,engine-field-predicate-faultandengine-write-failure-log-level. Test Files 10 passed, Tests 233 passed.@objectstack/objectqltosrc):predicate-related-read-tenant-scope,delete-reference-cleanup-system-identity,write-preview-field-gate-parityandsecurity-plugin. Test Files 4 passed, Tests 318 passed.packages/objectqlchange, so the full objectql suite was not re-run on the merge commit.eslint --no-inline-config --format jsonover the 2 changed.tsfiles.eslint.config.mjs's base block covers every**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}file outsidepackages/spec.parserOptions.project, as the config itself states), so this diff cannot change the verdict on any untouched file.pnpm lintis left to CI.6586f417fb: all 98 commands derived bynode scripts/pm/dispatch-gates.mjs --commandsexited 0, exit codes captured before any pipe.--ranreconciled them: 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN. An earlier union atfa29574fe1had twoPREREQUISITE NOT METrefusals (exit 3:check:skill-examplesandcheck:dual-build-cjs-loads, for unbuilt packages). Both went green after a full build, and the union at the final head ran on a built tree.Acceptance notes
packages/objectql/src/validation/rule-validator.tsis outside the claim's file surface and was not edited. Two of its docblocks no longer describe everything:RelatedFieldBindingdocblock lists the related read's bounds (the projection, and the own read on an unwalled object) but not the master-detail read door.unresolvedreason reads "a reference is stored but the related record was not found", which now also covers an unreadable header.Neither text is false; both are incomplete. carrier: none.
For an unreadable open header, the answer moves from the FK field (
invoicereference_not_found) to the record level (_recordrule_violation). That is the answer a nonexistent header already got on this object, which is the ruled outcome.Cost: one extra id-projected read per master-detail field a rule reads through, once per write batch, for a non-system caller.
Generated by Claude Code