Skip to content

Commit b373596

Browse files
fix(objectql): a delete refused by a traversing rule on its reference cleanup names the delete, the reference and the repair (#20021)
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: ```text 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**: ```text 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 #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 #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 #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 fc6ddb8` 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: ```text 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 fc6ddb8`: 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](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7766b62 commit b373596

5 files changed

Lines changed: 650 additions & 4 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a delete refused because its reference cleanup trips a traversing validation rule now says so, naming the delete, the cleared reference and the repair (#20006)
6+
7+
Clause-②: no
8+
9+
Deleting a record clears each `set_null` reference to it (the default for an optional `lookup`) with an UPDATE of every record that references it. That cleanup resolves no related record for a validation rule, so a `script` / `cross_field` rule on the referencing object that reads through a reference (`record.account.status`) cannot be evaluated there. It refuses the cleanup, and the delete with it. That refusal is unchanged: same `VALIDATION_FAILED` error, same `rule_violation` field error, same `constraint` (`reason: 'unevaluable'`, the fault, the missing key), and the same set of deletes refused.
10+
11+
What changes is the message. It used to be the generic one about the rule's own object: `The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field.` Whoever deleted the record did not write that rule, and following the advice adds a bogus column to the wrong object. The message now names the blocked delete, the reference being cleared, the rule and its object, and the repairs:
12+
13+
```text
14+
Cannot delete crm_account (acc_1): the delete clears `account` on the crm_deal records that reference it,
15+
and validation rule 'closed_account_frozen' on crm_deal could not be evaluated on that write — it reads
16+
'status' through `account`, and a rule is given no related record while a delete clears references.
17+
Guard the rule on `account` being set: make it the `then` of a `conditional` rule whose `when` is
18+
`record.account != null`. Or change `deleteBehavior` on crm_deal.account: 'cascade' deletes those records
19+
with the crm_account, 'restrict' refuses the delete while they exist.
20+
```
21+
22+
- **The guard** 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 every write where the reference is set.
23+
- **A rule that reads only through another reference** 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.
24+
- **On a multi-value reference** the cleanup removes the deleted record and keeps the other members, so the guard would still run the rule. There, only `deleteBehavior` is offered.
25+
- **Unchanged:** 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 `record.KEY`, `record.FIELD.KEY` through a field that is not a reference, `previous.KEY`, and `previous.FIELD.KEY` through any field, when the record, the previous row or the field's value lacks `KEY`. A declared column always reads, as `null` when empty, so it never counts. A rule that reads through no reference keeps today's text too, and so does every write that is not a delete's reference cleanup.

‎packages/objectql/src/engine-predicate-relationship.test.ts‎

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,310 @@ describe.each([
428428
expect(readIds()).toEqual([['acc_d']]);
429429
});
430430
});
431+
432+
// ---------------------------------------------------------------------------
433+
// [#20006] Deleting a record clears the references to it (`set_null`, the
434+
// lookup default) through a cleanup UPDATE of each referencing record. That
435+
// UPDATE resolves no related record for a traversing rule — deliberately, see
436+
// `resolvePredicateRelated` — so such a rule faults and refuses the cleanup,
437+
// which refuses the delete. The refusal stands; what these pin is its TEXT.
438+
//
439+
// The generic fault text named the wrong object: "The predicate reads 'status',
440+
// which this object does not declare — fix the rule's condition, or declare the
441+
// field." The DELETER did not write the referencing object's rule, and an
442+
// author who follows that sentence adds a bogus column to the object carrying
443+
// the rule. The text now names the blocked delete, the reference being cleared,
444+
// the rule and its object, and the two repairs measured to work.
445+
// ---------------------------------------------------------------------------
446+
447+
describe('#20006 — a cascade reference clear refused by a traversing rule says so', () => {
448+
const CLOSED_MESSAGE = 'Deals on a closed account are frozen.';
449+
const SECRET_MESSAGE = 'Deals in a secret region are frozen.';
450+
/** Reads the account the delete is clearing — the card's own shape. */
451+
const readsCleared = {
452+
name: 'closed_account_frozen', type: 'script', severity: 'error',
453+
message: CLOSED_MESSAGE, condition: "record.account.status == 'closed'",
454+
};
455+
/** Reads a DIFFERENT reference, which the clear leaves set. */
456+
const readsOther = {
457+
name: 'no_secret_region', type: 'script', severity: 'error',
458+
message: SECRET_MESSAGE, condition: "record.region.kind == 'secret'",
459+
};
460+
/** The repair the refusal prescribes, as an author writes it. */
461+
const guarded = (rule: Record<string, unknown>) => ({
462+
name: `${String(rule.name)}_when_linked`, type: 'conditional', severity: 'error',
463+
message: 'Only checked while the deal is linked to an account.',
464+
when: 'record.account != null', then: rule,
465+
});
466+
467+
async function boot(validations: unknown[], deal: Record<string, unknown> = {}) {
468+
const engine = new ObjectQL();
469+
const d = makeDriver();
470+
engine.registerDriver(d.driver, true);
471+
await engine.init();
472+
engine.registry.registerObject({
473+
name: 'crm_account', fields: { name: { type: 'text' }, status: { type: 'text' } },
474+
} as any, 'test-package');
475+
engine.registry.registerObject({
476+
name: 'crm_region', fields: { name: { type: 'text' }, kind: { type: 'text' } },
477+
} as any, 'test-package');
478+
engine.registry.registerObject({
479+
name: 'crm_deal',
480+
fields: {
481+
name: { type: 'text' },
482+
amount: { type: 'number' },
483+
// An OPTIONAL lookup with no `deleteBehavior`: the delete clears it.
484+
account: { type: 'lookup', reference: 'crm_account' },
485+
region: { type: 'lookup', reference: 'crm_region' },
486+
},
487+
validations,
488+
} as any, 'test-package');
489+
d.storeFor('crm_account').set('acc_1', { id: 'acc_1', name: 'A', status: 'closed' });
490+
d.storeFor('crm_region').set('reg_1', { id: 'reg_1', name: 'R', kind: 'secret' });
491+
// Straight into the store: a seed through the engine is judged by the very
492+
// rule under test, and the fixture is not the subject.
493+
d.storeFor('crm_deal').set('deal_1', {
494+
id: 'deal_1', name: 'D', amount: 50, account: 'acc_1', region: 'reg_1', ...deal,
495+
});
496+
return { engine, d };
497+
}
498+
499+
async function deleteAccount(validations: unknown[], deal?: Record<string, unknown>) {
500+
const { engine, d } = await boot(validations, deal);
501+
const err: any = await engine
502+
.delete('crm_account', { where: { id: 'acc_1' }, context: { isSystem: true } } as any)
503+
.then(() => null, (e: unknown) => e);
504+
return {
505+
err,
506+
accountStored: d.storeFor('crm_account').has('acc_1'),
507+
deal: d.storeFor('crm_deal').get('deal_1'),
508+
};
509+
}
510+
511+
it('REFUSES with the delete, the reference, the rule and both repairs named — a rule reading the cleared reference', async () => {
512+
const { err, accountStored, deal } = await deleteAccount([readsCleared]);
513+
expect(err?.code).toBe('VALIDATION_FAILED');
514+
expect(err?.fields).toHaveLength(1);
515+
// The machine-readable half is unchanged: same code, same reason, same fault.
516+
expect(err.fields[0]).toMatchObject({ field: '_record', code: 'rule_violation' });
517+
expect(err.fields[0].constraint).toEqual({
518+
rule: 'closed_account_frozen', reason: 'unevaluable',
519+
fault: 'runtime: No such key: status', missingKey: 'status',
520+
});
521+
const message: string = err.message;
522+
expect(message).toBe(err.fields[0].message);
523+
expect(message).toContain('Cannot delete crm_account (acc_1)'); // the blocked delete
524+
expect(message).toContain('clears `account` on the crm_deal records'); // the reference cleared
525+
expect(message).toContain("validation rule 'closed_account_frozen' on crm_deal"); // rule + object
526+
expect(message).toContain('`when` is `record.account != null`'); // repair 1
527+
expect(message).toContain('`deleteBehavior` on crm_deal.account'); // repair 2
528+
// ⛔ The wrong-object prescription the generic fault text carried.
529+
expect(message).not.toContain('declare the field');
530+
// The refusal itself stands (fail-closed): nothing landed.
531+
expect(accountStored).toBe(true);
532+
expect(deal?.account).toBe('acc_1');
533+
});
534+
535+
it('ACCEPTS the same delete once the rule is guarded as the refusal prescribes', async () => {
536+
const { err, accountStored, deal } = await deleteAccount([guarded(readsCleared)]);
537+
expect(err).toBe(null);
538+
expect(accountStored).toBe(false);
539+
expect(deal?.account).toBe(null);
540+
});
541+
542+
it('CONTROL: the guard does not switch the rule off — an ordinary write on a linked deal is still judged', async () => {
543+
const { engine } = await boot([guarded(readsCleared)]);
544+
const err: any = await engine
545+
.update('crm_deal', { id: 'deal_1', amount: 60 }, { context: { isSystem: true } } as any)
546+
.then(() => null, (e: unknown) => e);
547+
expect(err?.code).toBe('VALIDATION_FAILED');
548+
expect(err?.message).toBe(CLOSED_MESSAGE);
549+
});
550+
551+
it('REFUSES naming the reference the rule reads AND the one the delete clears — a rule reading another reference', async () => {
552+
const { err, accountStored, deal } = await deleteAccount([readsOther]);
553+
expect(err?.code).toBe('VALIDATION_FAILED');
554+
expect(err.fields[0].constraint).toEqual({
555+
rule: 'no_secret_region', reason: 'unevaluable',
556+
fault: 'runtime: No such key: kind', missingKey: 'kind',
557+
});
558+
const message: string = err.message;
559+
expect(message).toContain('Cannot delete crm_account (acc_1)');
560+
expect(message).toContain('clears `account` on the crm_deal records');
561+
expect(message).toContain("validation rule 'no_secret_region' on crm_deal");
562+
expect(message).toContain("reads 'kind' through `region`");
563+
expect(message).toContain('`deleteBehavior` on crm_deal.account');
564+
// ⛔ No guard: on a rule reading ANOTHER reference it costs enforcement (next pin).
565+
expect(message).not.toContain('conditional');
566+
expect(message).not.toContain('declare the field');
567+
expect(accountStored).toBe(true);
568+
expect(deal?.account).toBe('acc_1');
569+
});
570+
571+
// An ordinary insert of a deal with NO account, in a secret region.
572+
async function insertAccountless(validations: unknown[], deleteBehavior?: string) {
573+
const { engine } = await boot(validations);
574+
if (deleteBehavior) {
575+
const deal = engine.registry.getObject('crm_deal') as any;
576+
engine.registry.registerObject({
577+
...deal,
578+
fields: { ...deal.fields, account: { ...deal.fields.account, deleteBehavior } },
579+
}, 'test-package');
580+
}
581+
return engine
582+
.insert('crm_deal', { id: 'deal_2', name: 'E', amount: 5, region: 'reg_1' }, { context: { isSystem: true } } as any)
583+
.then(() => null, (e: any) => e?.message as string);
584+
}
585+
586+
it('the guard gives up nothing a rule on the CLEARED reference enforced: unguarded, it refuses an account-less write anyway', async () => {
587+
expect(await insertAccountless([readsCleared])).toContain('no single related record');
588+
expect(await insertAccountless([guarded(readsCleared)])).toBe(null);
589+
});
590+
591+
it('keeps a rule on ANOTHER reference judged on account-less records — why the guard is not offered for it', async () => {
592+
// What the refusal offers — `deleteBehavior` — leaves the rule judging such a deal.
593+
expect(await insertAccountless([readsOther])).toBe(SECRET_MESSAGE);
594+
expect(await insertAccountless([readsOther], 'restrict')).toBe(SECRET_MESSAGE);
595+
expect(await insertAccountless([readsOther], 'cascade')).toBe(SECRET_MESSAGE);
596+
// What it does NOT offer: a guard on `account` stops judging it there at all.
597+
expect(await insertAccountless([guarded(readsOther)])).toBe(null);
598+
});
599+
600+
it("ACCEPTS the delete when the reference's deleteBehavior is 'cascade' — the other named repair", async () => {
601+
const { engine, d } = await boot([readsCleared]);
602+
const deal = engine.registry.getObject('crm_deal') as any;
603+
engine.registry.registerObject({
604+
...deal,
605+
fields: { ...deal.fields, account: { ...deal.fields.account, deleteBehavior: 'cascade' } },
606+
}, 'test-package');
607+
const err = await engine
608+
.delete('crm_account', { where: { id: 'acc_1' }, context: { isSystem: true } } as any)
609+
.then(() => null, (e: unknown) => e);
610+
expect(err).toBe(null);
611+
expect(d.storeFor('crm_account').has('acc_1')).toBe(false);
612+
expect(d.storeFor('crm_deal').has('deal_1')).toBe(false);
613+
});
614+
615+
it("REFUSES the delete up front when the reference's deleteBehavior is 'restrict' — as the refusal says", async () => {
616+
const { engine, d } = await boot([readsCleared]);
617+
const deal = engine.registry.getObject('crm_deal') as any;
618+
engine.registry.registerObject({
619+
...deal,
620+
fields: { ...deal.fields, account: { ...deal.fields.account, deleteBehavior: 'restrict' } },
621+
}, 'test-package');
622+
const err: any = await engine
623+
.delete('crm_account', { where: { id: 'acc_1' }, context: { isSystem: true } } as any)
624+
.then(() => null, (e: unknown) => e);
625+
expect(err?.code).toBe('DELETE_RESTRICTED');
626+
expect(err?.status).toBe(409);
627+
expect(d.storeFor('crm_account').has('acc_1')).toBe(true);
628+
expect(d.storeFor('crm_deal').get('deal_1')?.account).toBe('acc_1');
629+
});
630+
631+
it('CONTROL: a rule that traverses nothing keeps its text byte for byte on the same clear', async () => {
632+
const { err } = await deleteAccount([{
633+
name: 'broken', type: 'script', severity: 'error', message: 'never shown',
634+
condition: "record.nope == 'x'",
635+
}]);
636+
expect(err?.code).toBe('VALIDATION_FAILED');
637+
expect(err?.message).toBe(
638+
"Validation rule 'broken' could not be evaluated (runtime: No such key: nope) — write rejected."
639+
+ " The predicate reads 'nope', which this object does not declare — fix the rule's condition, or declare the field.",
640+
);
641+
});
642+
643+
it('CONTROL: a fault on a bare column of the rule\'s own object keeps its text, though a reference reads the same name', async () => {
644+
// crm_deal declares no `status`, so `record.status` faults on EVERY write —
645+
// the rule is broken by its author, not by the cleanup. `account` also
646+
// reads a `status`, and the attribution must not confuse the two.
647+
const { err } = await deleteAccount([{
648+
...readsCleared, condition: "record.status == 'x' && record.account.status == 'closed'",
649+
}]);
650+
expect(err?.code).toBe('VALIDATION_FAILED');
651+
expect(err?.message).toBe(
652+
"Validation rule 'closed_account_frozen' could not be evaluated (runtime: No such key: status) — write rejected."
653+
+ " The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field.",
654+
);
655+
});
656+
657+
// When BOTH operands of `&&` / `||` fault, this CEL front end reports the
658+
// RIGHT one's key — here `status`, the traversal's. The rule's own
659+
// undeclared `kind` must still decide the text, whichever side it sits on.
660+
it.each([
661+
['&&', "record.kind == 'x' && record.account.status == 'closed'"],
662+
['||', "record.kind == 'x' || record.account.status == 'closed'"],
663+
['previous', "previous.kind == 'x' && record.account.status == 'closed'"],
664+
])('CONTROL: an own read the record lacks keeps the text, whatever key CEL reports — %s', async (_leg, condition) => {
665+
const { err } = await deleteAccount([{ ...readsCleared, condition }]);
666+
expect(err?.code).toBe('VALIDATION_FAILED');
667+
expect(err?.message).toBe(
668+
"Validation rule 'closed_account_frozen' could not be evaluated (runtime: No such key: status) — write rejected."
669+
+ " The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field.",
670+
);
671+
});
672+
673+
it('CONTROL: that rule is broken on every write — a linked deal refuses it on its own `kind`', async () => {
674+
const { engine } = await boot([{ ...readsCleared, condition: "record.kind == 'x' && record.account.status == 'closed'" }]);
675+
const err: any = await engine
676+
.update('crm_deal', { id: 'deal_1', amount: 60 }, { context: { isSystem: true } } as any)
677+
.then(() => null, (e: unknown) => e);
678+
expect(err?.code).toBe('VALIDATION_FAILED');
679+
expect(err?.message).toBe(
680+
"Validation rule 'closed_account_frozen' could not be evaluated (runtime: No such key: kind) — write rejected."
681+
+ " The predicate reads 'kind', which this object does not declare — fix the rule's condition, or declare the field.",
682+
);
683+
});
684+
685+
it('CONTROL: an ordinary write keeps its text byte for byte', async () => {
686+
const { engine } = await boot([{
687+
name: 'broken', type: 'script', severity: 'error', message: 'never shown',
688+
condition: "record.nope == 'x'",
689+
}]);
690+
const err: any = await engine
691+
.update('crm_deal', { id: 'deal_1', amount: 70 }, { context: { isSystem: true } } as any)
692+
.then(() => null, (e: unknown) => e);
693+
expect(err?.code).toBe('VALIDATION_FAILED');
694+
expect(err?.message).toBe(
695+
"Validation rule 'broken' could not be evaluated (runtime: No such key: nope) — write rejected."
696+
+ " The predicate reads 'nope', which this object does not declare — fix the rule's condition, or declare the field.",
697+
);
698+
});
699+
700+
it('CONTROL: a user emptying the same reference by hand is not told about a delete', async () => {
701+
const { engine } = await boot([readsCleared]);
702+
const err: any = await engine
703+
.update('crm_deal', { id: 'deal_1', account: null }, { context: { isSystem: true } } as any)
704+
.then(() => null, (e: unknown) => e);
705+
expect(err?.code).toBe('VALIDATION_FAILED');
706+
expect(err?.message).toContain("Validation rule 'closed_account_frozen' could not be evaluated");
707+
expect(err?.message).not.toContain('Cannot delete');
708+
});
709+
710+
it('THE REFUSED SET DOES NOT MOVE: a rule that never reaches its traversal still lets the delete through', async () => {
711+
// `false && <fault>` is `false` in CEL, so this rule has a verdict on the
712+
// clear without reading anything through `account` — and had one before.
713+
const { err, accountStored } = await deleteAccount([{
714+
...readsCleared, condition: "record.amount > 100 && record.account.status == 'closed'",
715+
}]);
716+
expect(err).toBe(null);
717+
expect(accountStored).toBe(false);
718+
});
719+
720+
it('a write that only INHERITS the clear marker keeps the generic text — the cause names another record', async () => {
721+
const { engine } = await boot([readsOther]);
722+
const inherited = (cause: Record<string, unknown>) => engine
723+
.update('crm_deal', { id: 'deal_1', amount: 80 }, {
724+
context: { isSystem: true, __referentialFieldClear: true, __referentialFieldClearCause: cause },
725+
} as any)
726+
.then(() => null, (e: any) => e?.message as string);
727+
const base = { object: 'crm_account', id: 'acc_1', referencingObject: 'crm_deal', referencingId: 'deal_1', field: 'account' };
728+
// CONTROL — the cause names THIS record: the delete's text.
729+
expect(await inherited(base)).toContain('Cannot delete crm_account (acc_1)');
730+
// Another object's cleanup, or another row's: today's text.
731+
for (const other of [{ referencingObject: 'crm_other' }, { referencingId: 'deal_2' }]) {
732+
const message = await inherited({ ...base, ...other });
733+
expect(message).toContain("Validation rule 'no_secret_region' could not be evaluated (runtime: No such key: kind)");
734+
expect(message).not.toContain('Cannot delete');
735+
}
736+
});
737+
});

0 commit comments

Comments
 (0)