Skip to content

fix(objectql): a delete refused by a traversing rule on its reference cleanup names the delete, the reference and the repair - #20021

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20006-cascade-fk-clear-refusal-text
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20006-cascade-fk-clear-refusal-text

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20006
Clause-②: no

What this changes

Deleting a record clears every set_null reference to it (the default for an optional lookup). ObjectQL.cascadeDeleteRelations does 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 a script / cross_field rule 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:

Validation rule 'closed_account_frozen' could not be evaluated (runtime: No such key: status) — write rejected.
The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field.

The person who deleted the record did not write that rule. Following the advice adds a bogus status column to crm_deal, the wrong object.

After:

Cannot delete crm_account (acc_1): the delete clears `account` on the crm_deal records that reference it,
and validation rule 'closed_account_frozen' on crm_deal could not be evaluated on that write — it reads
'status' through `account`, and a rule is given no related record while a delete clears references.
Guard the rule on `account` being set: make it the `then` of a `conditional` rule whose `when` is
`record.account != null`. Or change `deleteBehavior` on crm_deal.account: 'cascade' deletes those records
with the crm_account, 'restrict' refuses the delete while they exist.

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:

  • The conditional guard 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 only deleteBehavior.
  • A rule that reads only through another reference (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 plugin-security fixture's own shape) is offered only deleteBehavior. 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).
  • deleteBehavior is a real, authorable FieldSchema key ('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 whole constraint (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 with record.amount > 100 && record.account.status == 'closed'; CEL's false && FAULT is false. Any other refusal keeps today's text, and two controls pin it byte for byte:

  • a rule that reads through no reference, on the same cleanup;
  • the same fault on an ordinary update.

How

  • rule-validator.ts. On a CEL fault, checkPredicate asks referentialClearRefusal whether this is the cleanup. The answer is yes only when three things hold:

    • the related binding was minted by referentialClearBinding;
    • the fault's missing key is a column the rule reads through a reference field;
    • readsAnOwnColumnItLacks finds 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:

    • a bare record.K the record does not hold;
    • a record.F.K through a field that is not a reference, whose value lacks K;
    • a bare previous.K the previous row does not hold;
    • a previous.F.K through ANY field, a reference included, whose value lacks K. previous is never hydrated, so a hop through a reference there meets its bare id.

    A declared column is materialised on record and previous, 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 __referentialFieldClear short-circuit, the ⛔ A referential FK clear resolves NOTHING block. 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, RelatedUnavailableReason and EvaluateRulesOptions are unchanged. The cause rides a module-private WeakMap keyed by the minted binding. Measured in the rebuilt dist/*.d.ts, referentialClearBinding and ReferentialClearCause have 0 hits in index.d.ts, core.d.ts and the util chunk; the control RelatedRecordBinding has 1, 1 and 3 hits. There is no new error code and no ledger change. Neither OperationContext nor update() 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 are packages/formula or packages/spec.

For #20007

#20007 rewrites the neighbouring no single related record prescription. It should reuse this sentence, with its own reference name:

Guard the rule on REF being set: make it the then of a conditional rule whose when is record.REF != null.

In rule-validator.ts the clause after the colon is referenceGuardRepair(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

  • H1 holds. The cleanup context is { ...(context ?? {}), __referentialFieldClear: true }, and resolvePredicateRelated returns unbound for it.
  • H2: the CEL-fault arm fires, not the no single related record arm. When the rule reads the cleared reference, null.status gives runtime: No such key: status. When it reads another reference, the bare id 'reg_1'.kind gives No such key: kind. Both fall to unevaluableRuleError. The no-reference arm fires only on an ordinary write that empties the reference, and that text is unchanged here.
  • H3. The validator could not learn which reference and which delete from a boolean. The cause is carried on the cleanup UPDATE's own context object only, not on the OperationContext interface, and there is no public option.
  • H4 holds. The conditional guard makes the delete succeed, and deleteBehavior is a real key. Both are named exactly, with what each does.

Deviations, declared

  • Option D's written mechanism was not taken, only its direction. Option D sketched "an explicit unavailable binding with its own reason ... a new RelatedUnavailableReason member". 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, and has(record.account.status) && .... A pre-evaluation refusal would refuse both deletes. That is a narrowing, against the claim's Clause-②: 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).
  • The engine.ts edit spans two methods. Besides cascadeDeleteRelations (the stamp), it edits the __referentialFieldClear short-circuit in resolvePredicateRelated (the read), plus one named import. There is no other seam where the cleanup's context reaches the validator without editing update(). 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 at 9a31fc992), item F3

  • F3, attribution keyed on the one key CEL reports. When both operands of && / || fault, CEL reports the right-hand key. So at 9a31fc992, record.kind == 'x' && record.account.status == 'closed' on a crm_deal declaring no kind got the cleanup text with the guard, although the rule is broken on every write. So did its || form and previous.kind == 'x' && ….
  • The fix: readsKeyOffTheTraversal is replaced by the key-independent readsAnOwnColumnItLacks, 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.
  • Pins: red at 7a57a1092 (3 failed / 67 passed, the three F3 cleanup legs), then green at b22174cd4 (70/70). The ordinary-write control, where the rule refuses on its own kind, is green at both heads.
  • Ablations: each restore ended blob == HEAD.
    • G1, the bare-read loop removed: 5 red.
    • G2, the previous root unchecked: 2 red.
  • Refused set and envelope, re-measured: a base-revert probe over 22 engine-level shapes moved 0 verdicts and 0 envelopes. The message differs from base only in cleanup shapes where only the traversal faults: 5 in the author's 22-shape probe, and 18 in the contract review's 78-shape probe (5822996409).
  • Suites and checks:
    • the two pin files: 70/70;
    • the plugin-security fixture: 18/18;
    • objectql --project local: 311 files / 5262 tests;
    • typecheck exits 0, with the debt unchanged;
    • narrowed eslint: 0 / 0;
    • the module-local names have 0 hits in the rebuilt d.ts;
    • dispatch-gates gives the same 65 families (63 exit 0, 2 NOT MEASURED, dist-reading families re-run after the rebuild);
    • check-issue-citations exits 0.
  • engine.ts is untouched this round.
  • Changeset: only the "Unchanged" bullet is reworded, to the four reads the code checks.
  • Accepted as is: a read through a reference into a column the related object does not declare (record.account.nope, with no nope on crm_account).
    • It gets the cleanup text with the guard. Every sentence of that text is true, and the guard does let the delete through.
    • The rule's breakage stays loud on every ordinary linked write ("'crm_account' declares no 'nope'").
    • The generic text would be worse there: it would say the rule's own object does not declare nope.

Patch round 1 (9a31fc992): contract review 5821350148 (FAIL at b1de69f91), items F1 and F2

  • F1, attribution by key name. At b1de69f91, record.status == 'x' && record.account.status == 'closed' on a crm_deal that declares no status got 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 a previous.KEY collision.
    • The review's literal rule, "a key also read bare at the root keeps the generic text", was measured and not taken. On an object that DOES declare 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.
    • A bare read therefore counts only when the record does not hold the key. That case is pinned, and so is its ablation (F1lit).
  • F2, the guard's cost: option (b). The conditional guard 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).
    • A rule that reads only through ANOTHER reference is offered deleteBehavior alone. A guard there would stop judging it on every record whose cleared reference is empty.
    • Pinned: an account-less secret-region insert is refused with the authored message both unguarded and under deleteBehavior 'restrict' / 'cascade'.
  • Pins: red at 98cb79454 (4 failed / 61 passed, the new F1/F2 pins against round 0's implementation), then green at 9a31fc992 (66/66).
  • Ablations: each restore ended blob == HEAD.
    • F1, the off-traversal check disabled: 2 red.
    • F1lit, the bare read counted whether or not the record holds the key: 1 red, the declared-status leg.
    • F2, the guard gated on emptiness only: 2 red.
  • Suites and checks:
    • the two pin files: 66/66;
    • plugin-security delete-reference-cleanup-system-identity.test.ts: 18/18;
    • objectql vitest run --project local: 311 files / 5258 tests;
    • typecheck exits 0, with the debt unchanged;
    • narrowed eslint: 0 / 0;
    • the new module-local names have 0 hits in the shipped d.ts;
    • dispatch-gates derives 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 fc6ddb87a exits 0.
  • engine.ts is untouched this round.
  • The changeset: the guard bullet and the "Unchanged" bullet are rewritten to match, and an other-reference bullet is added. The example block and the multi-value bullet are unchanged.

The "Tests" section below is round 0's readings at b1de69f91.

Tests (all at b1de69f91)

  • Failing probe first. 636550185 added the pins before any fix. At that commit, engine-predicate-relationship.test.ts gave 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.ts gives 63/63 passed.
    • The new blocks add 12 end-to-end cases (real engine, in-file driver double, no new double) and 5 evaluator cases.
  • @objectstack/objectql:

    • vitest run --project local --maxWorkers=2: 311 files / 5255 tests passed.
    • test:repo: 1 file / 5 tests passed.
    • typecheck exit 0, including check: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.ts stays 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:

    Cannot delete os_ehr_product (r_2): the delete clears `product` on the os_ehr_inspection records that reference it,
    and validation rule 'no_secret_line' on os_ehr_inspection could not be evaluated on that write — it reads 'kind'
    through `line`, ...
    
  • eslint. eslint --no-inline-config --format json over the 4 touched TS files: 4 files, 0 errors, 0 warnings. The config has no type-aware linting: parserOptions is { ecmaVersion, sourceType } only, with no project. 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.mjs in WRAP mode, running the two test files. Every mutation was verified on disk (anchor 1 → 0, blob changed). Every restore ended with blob == HEAD and git diff HEAD empty, and porcelain was 0 after the set.

Ablation Mutation Red Tests that went red
A Engine binding dropped (return unbound) 3 / 63 the two new-text pins, and the inherited-marker control leg
B Validator branch dropped (return unevaluable) 6 / 63 those 3, plus the evaluator guard / multi / decides-nothing cases
C1 Row-id match dropped 1 / 63 the inherited-marker pin
C2 Object match dropped 1 / 63 the inherited-marker pin
D Attribution check dropped 2 / 63 the byte-for-byte clear control, and the own-columns evaluator case
E Guard offered unconditionally 1 / 63 the multi-value case
  • My first try at A was refused by the tool: the replacement was a substring of the anchor, so the count could not rise. Nothing ran. It was re-run with a distinct marker; the result above is from that re-run.

Gates

  • Derivation. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at b1de69f91 (merge base fc6ddb87a, 5 paths) derived 65 commands, and all 65 were run: 63 exit 0; 2 exit 3 (PREREQUISITE NOT MET, workspace dist/ not built in this worktree), NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt.
    • --ran reconciliation: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN.
    • As a supplementary reading, not the gate: require('./packages/objectql/dist/index.js') loads.
  • Citations. node scripts/check-issue-citations.mjs --base fc6ddb87a4cd065171080f018cd865cd9c84c9d9: exit 0. 7 citations were judged across 2 files, and all resolve.

Acceptance notes

  • Out of scope, class (a), not filed here (the seat files it). The ConditionalValidationSchema TSDoc examples in packages/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 give parse: Unexpected character: =. 'order_total > 10000' gives Unknown variable: order_total, because it has no record. root. Those were measured through ExpressionEngine.evaluate. The examples ship in the spec's published .d.ts (object.zod-*.d.ts).
  • Changeset. .changeset/20006-cascade-fk-clear-refusal-text.md, @objectstack/objectql: patch, Clause-②: no.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/system-context.mdx (via resolvePredicateRelated (symbol, a method of class ObjectQL))

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

  • content/docs/releases/v17/17-1.mdx (via cascadeDeleteRelations (symbol, a method of class ObjectQL))

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 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 17 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 9d81af714f4938f368909322ac8eb2c24276598a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 263c6c0f53b8d24faf6c4f5137b7c944beebdbfc — the merge of head b22174cd4da742de304d22523c36462870f64ce8 into base 9d81af714f4938f368909322ac8eb2c24276598a, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b1de69f917de0629e5d1c3789aad360e44ada51e

Scope read:

Run in the reviewer's own detached worktree at head, which was removed afterwards:

  • the two pin files (63/63), the plugin-security fixture (18/18), and eslint over the 4 touched files (0/0);
  • a probe of 14 delete shapes, 12 write shapes and 7 multi-value shapes, run at head AND with both source files reverted to the merge base;
  • the built d.ts.

① Derived judgments

  • The refused set is unchanged: RIGHT. Over the 14 delete shapes, base and head give identical verdicts, and code + fields[] (whole constraint) are byte-identical. record.amount > 100 && record.account.status == 'closed' (amount 50) and a has(record.account.status)-guarded rule pass the cleanup at base and at head. The 12 ordinary writes and 7 multi-value shapes are identical too. The refusal stays fail-closed.
  • Option D's literal mechanism is rejected: RIGHT.
    • Option D (5790469129) reads: "hand an explicit unavailable binding … needs a new RelatedUnavailableReason member, which is a public type".
    • resolveTraversalScope refuses before evaluation on any absent binding, so D's literal mechanism would refuse the two shapes above, which is a narrowing. RelatedUnavailableReason is in the published d.ts.
    • The ruled direction, 「让外键清空的拒绝文字指对对象」, is implemented on the fault path instead.
  • The error envelope is unchanged: RIGHT. VALIDATION_FAILED, rule_violation, _record and constraint {rule, reason:'unevaluable', fault, missingKey} are byte-identical in every refused shape. Only message differs, and only in the cleanup shapes.
  • The new text is reached only on the right write: RIGHT, with one corner (③ F1).
    • The cause is read at one site, inside the pre-existing __referentialFieldClear branch, and handed only to the row matching referencingId on referencingObject.
    • These keep today's text (measured): an inherited context on another row, a hand-emptied reference, an own-column fault (record.nope), and ordinary writes.
  • SECURITY / disclosure: RIGHT, nothing new.
  • The prescriptions are true: RIGHT, with an undisclosed cost (③ F2).
    • The guard makes the same delete succeed. An ordinary write with the reference set still refuses with the authored message.
    • deleteBehavior is z.enum(['set_null','cascade','restrict']) on FieldSchema. cascade deletes the child and restrict gives DELETE_RESTRICTED 409, measured on single- and multi-value references.
    • Offering only deleteBehavior for multi-value references is right: the cleanup keeps the other members, so the guard would still run the rule (measured: still refused).
  • Public surface: RIGHT. The new names have 0 hits in the built index.d.ts, core.d.ts and util chunk (control: RelatedRecordBinding has 1/1/3). There is no new error code. update() and OperationContext are untouched. There is no tracker number in the runtime string.
  • Ablation: RIGHT. Both files reverted to base give 7 red of 68: the 3 new-text pins and the 4 evaluator pins. The other A traversing validation rule on a child refuses the cascade FK clear that deleting its parent issues, and the refusal names the wrong fix: it points at the child object, not the reference the delete is clearing #20006 pins measure existing behaviour and stay green.
  • CI at head: 34 runs, 31 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in)), 0 failures. Every required context is success.

② Semver level

@objectstack/objectql: patch with Clause-②: no is correct. A runtime message changes, while the accept set is measured unchanged, the envelope is byte-identical, and no reachable type, option, export or error code moves.

③ Boundary flags

Changeset: 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.

  • The case: record.status == 'x' && record.account.status == 'closed' on a crm_deal that declares no status. CEL faults on the bare record.status, with the same fault at base.
  • Head nevertheless emits the cleanup text, because a missing key that is ALSO read through a reference is attributed to the reference.
  • The rule is broken on every write, not by the cleanup. So the changeset's "Unchanged" bullet and the PR body's "A fault on the rule's own columns keeps the generic text" are false for this shape.

F2: the guard silently stops enforcing a rule that reads ANOTHER reference.

  • Following "Guard the rule on account being set" for record.region.kind == 'secret' makes that rule skip every crm_deal whose account is empty, on every insert and update.
  • Measured: a guarded rule on an account-less deal with a secret region is accepted; unguarded, it is refused.
  • The text says only "still judged on every write where the reference is set". An author following a prescription must be told what it costs (PD chore: version packages #10).

PR body: TRUE where measured, including the option-D argument, the untouched OperationContext / update(), the d.ts counts, eslint, plugin-security 18/18, the __-prefix stripping, and "row id never reaches the text". The failing-probe-first count and the per-ablation counts are UNMEASURED; the reviewer's full revert is consistent. The #20007 reuse note is TRUE: referenceGuardRepair(field) is module-local in the same file. #20007 should carry F2's cost clause where it applies.

Out-of-scope finding: REAL. Through ExpressionEngine.evaluate, all seven ConditionalValidationSchema TSDoc example predicates in packages/spec/src/data/validation.zod.ts fail when copied: six give parse: Unexpected character: =, and order_total > 10000 gives Unknown variable. The strings ship in the spec d.ts.

Implemented-by: claude/issue-20006-cascade-fk-clear-refusal-text
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: FAIL

What must change (small, and both in files the claim already owns):

  1. F1. Choose one:
    • tighten the attribution, so that a missing key that is also read bare at the root (record.KEY) keeps the generic text, pinned with record.status == 'x' && record.account.status == 'closed' on an object declaring no status;
    • or narrow the changeset bullet and the PR body to what the code does.
  2. F2. Where the rule reads a DIFFERENT reference, state the guard's cost in the text and in the changeset bullet "It applies whether…", e.g. "(the rule is then not judged on crm_deal records whose account is empty)". Or offer only deleteBehavior in that arm. Pin the account-less ordinary write in whichever direction the text promises.

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>
@github-actions github-actions Bot added size/l and removed size/m labels Sep 24, 2026
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9a31fc9925e5a930aa5ae2a7c51b766a9883c448

Scope read:

Run in the reviewer's own detached worktree at head, which was removed afterwards:

  • the two pin files and the plugin-security fixture;
  • a probe of 82 engine-level shapes (55 deletes, 27 ordinary writes) plus 8 evaluator-level shapes, run at head AND with rule-validator.ts + engine.ts reverted to fc6ddb87a;
  • the same pins with rule-validator.ts reverted to b1de69f91;
  • the built d.ts.

① Derived judgments

  • Diff scope: RIGHT. Only the four files changed, so the round-0 measurements of engine.ts carry over.
  • F1, readsKeyOffTheTraversal: RIGHT on every shape the prior record asked for, WRONG on a neighbouring one (③ F3).
    • record.status == 'x' && record.account.status == 'closed' on a crm_deal declaring no status: today's generic text byte for byte.
    • The same rule with status declared: stored null, absent or 'y' is ACCEPTED, as at base. Stored 'x' gives the cleanup text, every sentence of which is true: the declared read is materialised and cannot fault.
    • "which this object does not declare" is never emitted on a declared key.
    • The ||, previous.status, previous.kind == 'x' && record.region.kind == 'secret' and json-field (record.meta.status) variants were all measured and behave as claimed.
    • A rule reading only through the cleared reference keeps the cleanup text.
  • F2, the guard's cost: RIGHT.
    • The guard is offered only to a rule reading through the cleared reference. A rule reading only record.region.kind gets deleteBehavior alone, with no conditional in the text.
    • A rule reading BOTH references keeps the guard. Measured on insert (account omitted, explicit null, system and acting caller) and on update (set to null; already account-less): unguarded, every such write is refused before evaluation with no single related record, at base and at head. Guarded, those writes are accepted, and a write with account set is still judged.
    • So "already refuses every write that leaves that reference empty" is true.
  • Refused set: unchanged, RIGHT. 0 verdicts moved between base and head over the 82 engine-level shapes. The pass-through controls pass at both.
  • Envelope: byte-identical, RIGHT. code, status, the field error and the whole constraint are identical in all 82 shapes. Only message differs, in 22 cleanup refusals; three of those are ③ F3.
  • Public surface: RIGHT. The five new module-local names have 0 hits in the built d.ts (controls 1/1/3), and there is no new error code.
  • Pins red then green: RIGHT. Head's pin files against round 0's rule-validator.ts give 4 failed / 62 passed, exactly the four the addendum names. At head, 66/66. The plugin-security fixture is 18/18.
  • Disclosure: nothing new.
  • CI at this head: 41 runs, 0 failures, 0 pending. Every required context is success.

② Semver level

@objectstack/objectql: patch with Clause-②: no still holds.

③ Boundary flags

Changeset: 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 record or previous directly, or through a field that is not a reference, which the record does not hold, even when the rule also reads a column of that name through a reference." It is FALSE in a measured shape (F3).

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.

  • Measured on this CEL front end: when both operands of && or || fault, the RIGHT operand's key is reported.
  • So record.kind == 'x' && record.account.status == 'closed' on a crm_deal declaring no kind gets the cleanup text with the guard on the cleanup; base gave the generic text. The || form and previous.kind == 'x' && record.account.status == 'closed' do the same.
  • With the operands swapped, each keeps the generic text. The outcome depends on operand order, not on where the rule is broken.
  • The rule is broken on every write: an ordinary update with account set refuses with No such key: kind … which this object does not declare. Following the guard makes the delete succeed (measured), and the next linked write still faults on kind. The deleter is sent to a repair that hides the author's breakage.

PR body:

  • "Patch round 1" is TRUE where measured. The ablation counts are UNMEASURED.
  • The "How" bullet's description of readsKeyOffTheTraversal is inaccurate for the previous root: the code counts a reference hop on previous too (skipReferences: false). The function's own doc comment says so correctly.
  • "A fault on the rule's own columns keeps the generic text" is FALSE (F3).

reuse_sentence_for_20007: TRUE. The guard sentence is unchanged, and #20007's no single related record arm needs no cost clause.

Implemented-by: claude/issue-20006-cascade-fk-clear-refusal-text
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: FAIL

What must change (one item, in files the claim already owns):

  1. F3. Either:

    • make the attribution answer for EVERY own read the rule makes, not only the reported key: any record.K the record does not hold, any previous.K the previous row does not hold, and any record.F.K / previous.F.K through a non-reference field whose value lacks K. Pinned with record.kind == 'x' && record.account.status == 'closed' (kind undeclared) on the cleanup, expecting today's text byte for byte, plus its ordinary-write control and the || and previous.kind legs;
    • or narrow the changeset's "Unchanged" bullet and the PR body to what the code does.

    Either way, the PR body's "How" description of the previous branch must be corrected (the seat edits the body).

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b22174cd4da742de304d22523c36462870f64ce8

Scope read:

Run in the reviewer's own detached worktree at head, which was removed afterwards:

  • the two pin files and the plugin-security fixture;
  • the pin files with 9a31fc992's rule-validator.ts swapped in;
  • a probe of 78 engine-level shapes (57 deletes, 21 ordinary writes), run at head AND with rule-validator.ts + engine.ts reverted to fc6ddb87a, plus 2 multi-value deleteBehavior shapes and an analyzer probe of 19 predicate sources;
  • objectql's d.ts built with tsup at base and at head, compared byte for byte.

① Derived judgments

  • Diff scope since the last record: RIGHT. Only the three files named, as a fast-forward.
  • F3, readsAnOwnColumnItLacks: RIGHT on every shape asked, and on every neighbouring shape hunted.
    • record.kind == 'x' && record.account.status == 'closed' (kind undeclared), its || form and previous.kind == 'x' && … get today's generic text, byte for byte equal to the merge base. With the operands swapped, they are also generic and byte-equal to base.
    • With kind declared and stored 'x', they get the cleanup text with the guard, and every sentence of it is true.
    • A json field lacking the key, and previous.F.K through a reference hop, give generic text byte-identical to base.
    • A rule reading only through the cleared reference gets the cleanup text.
    • Also generic and byte-identical to base: a ternary, size(), .startsWith() and in on an undeclared own key, the collisions, record.nope alone, and the pre-evaluation refusals.
    • Hunt 1, the cleanup text for a rule whose fault is its own: none found among own reads.
    • Hunt 2, a generic text that now says something false: none. Every generic message at head is byte-identical to base's.
  • Regression against the prior records' passes: RIGHT.
    • F1's shapes hold.
    • F2: the guard goes only to the cleared reference, the other reference gets deleteBehavior alone, and the both-references rule keeps the guard. The account-less direction was measured.
    • Multi-value: the text, deleteBehavior only, the guard still running the rule, and cascade / restrict were all measured.
  • Refused set: unchanged, RIGHT. 0 of 78 verdicts moved between base and head, and the pass-throughs are accepted at both.
  • Envelope: byte-identical, RIGHT. code, status, the field error and the whole constraint are identical in all 78 shapes. Only message differs, in 18 delete cleanups where only the traversal faults, plus the PR's own forged-marker control.
  • Public surface: RIGHT. index.d.ts, core.d.ts and the util chunk built at base and at head are BYTE-IDENTICAL (180902 / 15039 / 553276 bytes). There is no new error code, and no tracker number in any runtime string.
  • Pins red then green: RIGHT. Head's pins against 9a31fc992's rule-validator.ts give 3 failed / 67 passed, exactly the three F3 legs. At head, 70/70. The plugin-security fixture is 18/18.
  • CI at this head: 41 runs, 36 success, 5 skipped (Auto Label, Check PR Size, Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failures. Every required context is success.

② Semver level

@objectstack/objectql: patch with Clause-②: no still holds. The d.ts is byte-identical to base, the refused set and envelope are unchanged, and one runtime message changes.

③ Boundary flags

Changeset: every sentence at head is TRUE, including:

  • the summary line and the four lead sentences;
  • "That refusal is unchanged…" (78 shapes) and the base-text quote;
  • the example block, which equals the generated message modulo wrapping;
  • the guard bullet (three sentences), the other-reference bullet and the multi-value bullet;
  • the reworded "Unchanged" bullet: "a rule that reads a key where it is not held keeps today's text byte for byte, whichever key the fault reports. Those reads are … A declared column always reads, as null when empty, so it never counts. …" This is exactly what the code checks.

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: record.account.nope": measured. Every sentence of its cleanup text is TRUE: the guard lets the delete through, cascade / restrict behave as said, and the breakage stays loud on every linked write. Base's generic text there names the wrong object. No sentence is false, and it is disclosed in the body.

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: has(record.nope), record.?nope and record[?'nope'] count without faulting and keep the generic text. The changeset's own wording ("reads a key where it is not held") is exact, so nothing shipped is false. This is a body-only correction.

Implemented-by: claude/issue-20006-cascade-fk-clear-refusal-text
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

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.

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

Projects

None yet

1 participant