Repository navigation
fix(objectql): a delete refused by a traversing rule on its reference cleanup names the delete, the reference and the repair - #20021
Conversation
…raversing rule should carry (failing) Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
… names the delete, the reference and the repair The cleanup UPDATE a delete issues to clear a set_null reference resolves no related record, so a rule reading through a reference faults there and refuses the delete. The refusal stands; its text named the wrong object (declare the field on the object carrying the rule). It now names the blocked delete, the cleared reference, the rule and its object, and the two repairs measured to let the delete through: a conditional guard on the reference, or deleteBehavior. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…or, and the restrict repair; add changeset Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 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:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 263c6c0f53b8d24faf6c4f5137b7c944beebdbfc && git checkout 263c6c0f53b8d24faf6c4f5137b7c944beebdbfc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d81af714f4938f368909322ac8eb2c24276598a b22174cd4da742de304d22523c36462870f64ce8 && git checkout -B drift-repro 9d81af714f4938f368909322ac8eb2c24276598a && git merge --no-ff b22174cd4da742de304d22523c36462870f64ce8
node scripts/docs-audit/affected-docs.mjs --json 9d81af714f4938f368909322ac8eb2c24276598a
|
Contract reviewServed-tier: Scope read:
Run in the reviewer's own detached worktree at head, which was removed afterwards:
① Derived judgments
② Semver level
③ Boundary flagsChangeset: every sentence is TRUE, including the example block, which equals the generated message at head, with ONE exception. Bullet 3, "Unchanged: a rule whose fault is in its own columns … keep today's text byte for byte", is FALSE in a measured shape (F1). Shipped refusal text at head: every sentence of the single-value and multi-value forms is TRUE. The guard sentence is true as a repair for the delete, but silent about its cost (F2). F1: attribution by key name misfires on a name collision.
F2: the guard silently stops enforcing a rule that reads ANOTHER reference.
PR body: TRUE where measured, including the option-D argument, the untouched Out-of-scope finding: REAL. Through Implemented-by: VERDICT: FAIL What must change (small, and both in files the claim already owns):
Isolated reviewer: a contract-review-tier subagent, fed only the card, the #18682 records, the PR and AGENTS.md; adopted by the seat. |
…olumn's name, and the guard withheld from a rule on another reference (failing) Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…he key cannot fault on the rule's own columns; offer the guard only to a rule reading the cleared reference A key name is not a location: a rule reading an undeclared record.status beside record.account.status faults on its own column on every write, and keeps the generic text. A rule reading only another reference is offered deleteBehavior alone: a guard on the cleared reference would stop judging it on every record whose reference is empty. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope read:
Run in the reviewer's own detached worktree at head, which was removed afterwards:
① Derived judgments
② Semver level
③ Boundary flagsChangeset: every sentence is TRUE, including the example block, the guard bullet, the other-reference bullet and the multi-value bullet, EXCEPT the "Unchanged" bullet's sentence: "a rule whose fault is its own keeps today's text byte for byte. That covers a key the rule reads from Shipped refusal texts at head: every sentence is TRUE, in the single-value form (cleared reference), the other-reference form and the multi-value form. F3: attribution still keys on the ONE key CEL reports.
PR body:
Implemented-by: VERDICT: FAIL What must change (one item, in files the claim already owns):
Isolated reviewer: a contract-review-tier subagent, second read on this PR, fed only the card, the round-0 record, the PR and the two rule files; adopted by the seat. |
… / ||, and on previous, beside a traversal CEL reports instead (failing) Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…ults, not only the key CEL reports When both operands of && / || fault, CEL reports the right one's key, so record.kind == 'x' && record.account.status == 'closed' on an object declaring no kind reported the traversal's status and was blamed on the cleanup. Every bare record/previous read, every read through a non-reference field, and every previous hop is now checked against what the record holds. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…code checks Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope read:
Run in the reviewer's own detached worktree at head, which was removed afterwards:
① Derived judgments
② Semver level
③ Boundary flagsChangeset: every sentence at head is TRUE, including:
Shipped refusal texts at head: every sentence of the cleared-reference single-value text, the other-reference text and the multi-value text is TRUE (measured). PR body, "Patch round 2": TRUE where measured. "The message differs from base only in the 5 cleanup shapes" is the author's probe count (18 over this review's 78, all the same class). The ablation, suite and gate counts are UNMEASURED here; CI is green. "Accepted as is: The rewritten "How" bullet: TRUE, except that condition (3), "finds no own read of the rule that faults", is INACCURATE as a description. The function counts an own read of a key its holder lacks whether or not that read faults: Implemented-by: VERDICT: PASS Isolated reviewer: a contract-review-tier subagent, third read on this PR, fed only the card, the two prior records, the PR and AGENTS.md; adopted by the seat. |
Fixes #20006
Clause-②: no
What this changes
Deleting a record clears every
set_nullreference to it (the default for an optionallookup).ObjectQL.cascadeDeleteRelationsdoes this with an UPDATE of each referencing record. That cleanup resolves no related record for a validation rule, by design (#18682: resolving one would let a rule's verdict leak one bit of a related record to a deleter who cannot read it). So ascript/cross_fieldrule on the referencing object that reads through a reference faults there. It refuses the cleanup, and the delete with it.The refusal stays. #18682 ruled it fail-closed, and this PR does not touch it. Only the text changes, per round-11 Q1 option D, 「让外键清空的拒绝文字指对对象」.
Before, measured at the merge base
fc6ddb87a: the deleter got the generic fault text about the rule's own object:The person who deleted the record did not write that rule. Following the advice adds a bogus
statuscolumn tocrm_deal, the wrong object.After:
It names all four things the triage asked for: the object and rule, the reference being cleared, the parent delete that is blocked, and the repairs. Each repair was measured to work, and each is pinned:
conditionalguard makes the same delete succeed and clears the reference. It is offered only to a rule that reads through the reference the cleanup empties. Such a rule already refuses every write that leaves that reference empty (no single related record), so the guard only lets those writes through. The guarded rule is still judged on an ordinary write while the reference is set, and a non-vacuity pin shows it refusing with the authored message. On a multi-value reference the other members stay, so the guard would still run the rule (measured: still refused). There the text offers onlydeleteBehavior.record.crm_account.type); replaces the removedos.lookupdeclaration (#18318, batch #148) #18682 plugin-security fixture's own shape) is offered onlydeleteBehavior. A guard on the cleared reference would stop judging that rule on every record whose cleared reference is empty, on every insert and update (patch round 1, F2).deleteBehavioris a real, authorableFieldSchemakey ('set_null' | 'cascade' | 'restrict'). With'cascade'the delete succeeds and the referencing records are deleted with it; measured on a single-value and a multi-value lookup. With'restrict'the delete is refused up front:DELETE_RESTRICTED, 409, nothing changed.Unchanged, byte for byte: the error (
VALIDATION_FAILED), the field error (rule_violation,_record), the wholeconstraint(reason: 'unevaluable',fault,missingKey), and the set of deletes refused. The new text is chosen only AFTER the predicate has already faulted. A rule that has a verdict without reading through the reference still lets the delete through, as before. That is pinned withrecord.amount > 100 && record.account.status == 'closed'; CEL'sfalse && FAULTisfalse. Any other refusal keeps today's text, and two controls pin it byte for byte:How
rule-validator.ts. On a CEL fault,checkPredicateasksreferentialClearRefusalwhether this is the cleanup. The answer is yes only when three things hold:relatedbinding was minted byreferentialClearBinding;readsAnOwnColumnItLacksfinds no own read of a key its holder does not hold. Optional spellings (has(record.K),record.?K) count too, although they do not fault, so such a rule keeps the generic text: a conservative miss that changes no verdict.That last check covers EVERY own read, not only the key CEL reports; when both operands of
&&/||fault, CEL reports the right-hand key. The own reads are:record.Kthe record does not hold;record.F.Kthrough a field that is not a reference, whose value lacksK;previous.Kthe previous row does not hold;previous.F.Kthrough ANY field, a reference included, whose value lacksK.previousis never hydrated, so a hop through a reference there meets its bare id.A declared column is materialised on
recordandprevious, is always held, and never counts. If any own read faults, or the missing key is not read through a reference, the generic text stands byte for byte.referenceGuardRepair(field)holds the guard wording once.engine.ts,cascadeDeleteRelations. Beside__referentialFieldClear, the cleanup UPDATE's context gets__referentialFieldClearCause:{ object, id, referencingObject, referencingId, field }. It uses the operation-private__prefix, so every consumer that forwards a context already strips it. It authorizes nothing, and the referencing row's id never reaches the text.engine.ts,resolvePredicateRelated. This is the__referentialFieldClearshort-circuit, the⛔ A referential FK clear resolves NOTHINGblock. It still resolves nothing. For the one record the cleanup was issued for (same object, same row id), it now hands back an EMPTY binding that carries the cause. A write that only inherits the envelope keeps today's text. That is a hook's write to another object or another row during the cleanup, and it is pinned.No public surface moves.
RelatedRecordBinding,RelatedFieldBinding,RelatedUnavailableReasonandEvaluateRulesOptionsare unchanged. The cause rides a module-privateWeakMapkeyed by the minted binding. Measured in the rebuiltdist/*.d.ts,referentialClearBindingandReferentialClearCausehave 0 hits inindex.d.ts,core.d.tsand theutilchunk; the controlRelatedRecordBindinghas 1, 1 and 3 hits. There is no new error code and no ledger change. NeitherOperationContextnorupdate()is touched (draft PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012), and nor arepackages/formulaorpackages/spec.For #20007
#20007 rewrites the neighbouring
no single related recordprescription. It should reuse this sentence, with its own reference name:In
rule-validator.tsthe clause after the colon isreferenceGuardRepair(field), a module-local function, so #20007 can call it rather than respell it. This PR does not edit #20007's prescriptions. #20007 is not addressed here.Mechanism hypotheses (dispatch §2), measured at
fc6ddb87a{ ...(context ?? {}), __referentialFieldClear: true }, andresolvePredicateRelatedreturnsunboundfor it.no single related recordarm. When the rule reads the cleared reference,null.statusgivesruntime: No such key: status. When it reads another reference, the bare id'reg_1'.kindgivesNo such key: kind. Both fall tounevaluableRuleError. Theno-referencearm fires only on an ordinary write that empties the reference, and that text is unchanged here.OperationContextinterface, and there is no public option.conditionalguard makes the delete succeed, anddeleteBehavioris a real key. Both are named exactly, with what each does.Deviations, declared
RelatedUnavailableReasonmember". That would refuse BEFORE evaluation, which grows the refused set. Measured at the merge base, two rules pass the cleanup today:record.amount > 100 && record.account.status == 'closed'with amount 50, andhas(record.account.status) && .... A pre-evaluation refusal would refuse both deletes. That is a narrowing, against the claim'sClause-②: no, and the new union member is a public type the dispatch rules out. So the text is chosen on the fault path, and the refused set does not move (pinned).engine.tsedit spans two methods. BesidescascadeDeleteRelations(the stamp), it edits the__referentialFieldClearshort-circuit inresolvePredicateRelated(the read), plus one named import. There is no other seam where the cleanup's context reaches the validator without editingupdate(). Neither hunk overlaps fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012's (OperationContext~2235–2257,update()~13420–13669).Patch round 2 (
b22174cd4): contract review 5822275989 (FAIL at9a31fc992), item F3&&/||fault, CEL reports the right-hand key. So at9a31fc992,record.kind == 'x' && record.account.status == 'closed'on acrm_dealdeclaring nokindgot the cleanup text with the guard, although the rule is broken on every write. So did its||form andprevious.kind == 'x' && ….readsKeyOffTheTraversalis replaced by the key-independentreadsAnOwnColumnItLacks, which checks EVERY own read (see "How"). Those three shapes now keep today's generic text byte for byte, which is base's text for them. The round-1 refinement stands: a declared column never counts.7a57a1092(3 failed / 67 passed, the three F3 cleanup legs), then green atb22174cd4(70/70). The ordinary-write control, where the rule refuses on its ownkind, is green at both heads.previousroot unchecked: 2 red.--project local: 311 files / 5262 tests;typecheckexits 0, with the debt unchanged;dispatch-gatesgives the same 65 families (63 exit 0, 2 NOT MEASURED, dist-reading families re-run after the rebuild);check-issue-citationsexits 0.engine.tsis untouched this round.record.account.nope, with nonopeon crm_account).nope.Patch round 1 (
9a31fc992): contract review 5821350148 (FAIL atb1de69f91), items F1 and F2b1de69f91,record.status == 'x' && record.account.status == 'closed'on acrm_dealthat declares nostatusgot the cleanup text, although that rule is broken on every write. The attribution now also requires that the key cannot fault on the rule's own columns (readsKeyOffTheTraversal, module-local). That shape keeps today's generic text byte for byte, and so does aprevious.KEYcollision.status,record.status == 'x' || record.account.status == 'closed'would then say "which this object does not declare", which is false: the declared column reads null and cannot fault.conditionalguard is offered only to a rule that reads through the reference the cleanup empties. That rule already refuses every write leaving the reference empty (no single related record), so the guard gives up nothing it enforced (pinned).deleteBehavioralone. A guard there would stop judging it on every record whose cleared reference is empty.deleteBehavior'restrict' / 'cascade'.98cb79454(4 failed / 61 passed, the new F1/F2 pins against round 0's implementation), then green at9a31fc992(66/66).statusleg.delete-reference-cleanup-system-identity.test.ts: 18/18;vitest run --project local: 311 files / 5258 tests;typecheckexits 0, with the debt unchanged;dispatch-gatesderives the same 65 families: 63 exit 0, and 2 are NOT MEASURED (the workspace dist was not built; CI's Lint & Repo Gates and Type Check are green at this head);check-issue-citations --base fc6ddb87aexits 0.engine.tsis untouched this round.The "Tests" section below is round 0's readings at
b1de69f91.Tests (all at
b1de69f91)Failing probe first.
636550185added the pins before any fix. At that commit,engine-predicate-relationship.test.tsgave 3 failed / 28 passed, and the failures were exactly the new-text pins. Each received today's generic text quoted above; the guard, cascade and control pins were green, as they measure existing behaviour.Two test files:
pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-predicate-relationship.test.ts src/validation/rule-relationship-traversal.test.tsgives 63/63 passed.@objectstack/objectql:vitest run --project local --maxWorkers=2: 311 files / 5255 tests passed.test:repo: 1 file / 5 tests passed.typecheckexit 0, includingcheck:test-typecheck. The debt is unchanged at 40 files / 234 errors / 65 pinned.plugin-security, end to end on the real
SecurityPlugin.delete-reference-cleanup-system-identity.test.tsstays green unchanged, 18/18. It includes THE CONTRACT: a secret and a public related row give identical outcomes, the message included, and zero reads. A scratch copy that only printed the message (deleted after the run) shows the new text arriving through the security middleware, so the cause key survives that path:eslint.
eslint --no-inline-config --format jsonover the 4 touched TS files: 4 files, 0 errors, 0 warnings. The config has no type-aware linting:parserOptionsis{ ecmaVersion, sourceType }only, with noproject. So this diff cannot move the verdict on any untouched file. The repo-wide run is CI's.Ablations
Each ablation went through
scripts/ablation-replace.mjsin WRAP mode, running the two test files. Every mutation was verified on disk (anchor 1 → 0, blob changed). Every restore ended with blob == HEAD andgit diff HEADempty, and porcelain was 0 after the set.return unbound)return unevaluable)Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatb1de69f91(merge basefc6ddb87a, 5 paths) derived 65 commands, and all 65 were run: 63 exit 0; 2 exit 3 (PREREQUISITE NOT MET, workspacedist/not built in this worktree), NOT MEASURED:check:dual-build-cjs-loads,check:type-check-debt.--ranreconciliation: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN.require('./packages/objectql/dist/index.js')loads.node scripts/check-issue-citations.mjs --base fc6ddb87a4cd065171080f018cd865cd9c84c9d9: exit 0. 7 citations were judged across 2 files, and all resolve.Acceptance notes
ConditionalValidationSchemaTSDoc examples inpackages/spec/src/data/validation.zod.ts(use cases 1–3) fail when copied:when: 'account_type = "enterprise"',condition: 'approval_status = null'and'shipping_address = null OR shipping_address = ""'each giveparse: Unexpected character: =.'order_total > 10000'givesUnknown variable: order_total, because it has norecord.root. Those were measured throughExpressionEngine.evaluate. The examples ship in the spec's published.d.ts(object.zod-*.d.ts)..changeset/20006-cascade-fk-clear-refusal-text.md,@objectstack/objectql: patch,Clause-②: no.Generated by Claude Code