Repository navigation
Commit 073b1b2
fix(objectql,lint)!: a conditional rule's
Fixes #24512
Clause-②: no (narrowing) (the save door newly refuses a `when` that
reads a second hop or an undeclared related column, which today saves
and then refuses every write it guards; a narrowing owes one
`CONTRACT_REVIEW_TIER` review of record before the queue)
Dispatched by the `domain:engine` seat 2 PM (claim 6275835421,
`session_01AHeUBbe6S3JRawcpbLejXD`). The developer ran as that session's
subagent. The route is triage 6265543836's: hydrate `when`.
## What changes
**Engine (`packages/objectql`, `validation/rule-validator.ts`).** No
change in `engine.ts`: the engine's related read already takes its input
from the collector below.
- `collectPredicateRelationships` now collects a `conditional` rule's
`when`, at every depth, beside the rules it wraps. So the engine
preloads a `when`'s hop through the same loader as a condition's. It
uses the same system authority, the same projection, and PR #24508's
per-rule handling of undeclared columns.
- `checkConditional` evaluates `when` on the scope
`resolveTraversalScope` decides, the function `checkPredicate` calls.
The hydrated copy belongs to the `when` alone. The branch gets the
untouched context and decides its own scope, so a branch comparing the
stored id still sees the id.
- The scope can refuse a `when`: a second hop, a reference read both
through it and as a value, an undeclared related column, or an empty,
unresolved or unreadable reference. That refusal goes through one new
builder, `traversalScopeRefusal`, which `checkPredicate` now uses too.
It answers `code: 'rule_violation'` with `constraint: { rule, reason:
'unevaluable', fault }`. `checkPredicate`'s message and log line are
byte-identical to before.
- A `when` that faults on a delete's reference cleanup gets
`referentialClearRefusal`'s text, as a condition does.
**Lint (`packages/lint`, `validate-expressions.ts`).** This is the
cross-domain rider the claim declares to `domain:spec`.
- The top-level `when` and every nested predicate pass
`traversalHydration`. The existing hop checks now judge a `when`: second
hop, both-ways read, stored-metadata-body hop, and undeclared hop
column. They run under the existing `EXPRESSION_INVALID` rule and are
located at the `when` slot. No new rule, code or message.
- `RulePredicate.slot` lost its only reader and is removed.
## Measurement
### Write time
The fixture is the real `ObjectQL` engine over an in-memory driver
double. A `conditional` has `when` W, `then` and `otherwise` rules that
both refuse an amount over 10, and the insert has `amount: 50`. So
"`then`" or "`otherwise`" names the branch that was judged.
- **Before:** `rule-validator.ts` restored from `1e8b6284c` (this PR's
base); `engine.ts` is unchanged by this PR.
- **After:** this branch.
- The probe was a one-off file. It is not committed.
| W | empty reference, before | empty reference, after | partner
account, before | partner account, after |
|---|---|---|---|---|
| `record.account.type == 'partner'` | refused, `No such key: type` |
refused, "no single related record" (names the guard) | refused, `No
such key: type` | **`then`** |
| `record.account != null && record.account.type == 'partner'` |
`otherwise` | refused, both-ways read | refused, `No such key: type` |
refused, both-ways read |
| `record.account.?type.orValue('') == 'partner'` | `otherwise` |
refused, "no single related record" | `otherwise` (wrong branch,
silently) | **`then`** |
| `has(record.account.type) && record.account.type == 'partner'` |
`otherwise` | refused, "no single related record" | `otherwise` (wrong
branch, silently) | **`then`** |
| `record.account.owner.region == 'x'` | refused, `No such key: owner` |
refused, more than one hop | refused, `No such key: owner` | refused,
more than one hop |
| `record.account.region == 'x'` (undeclared) | refused, `No such key:
region` | refused, `'crm_account' declares no 'region'` | refused, `No
such key: region` | refused, `'crm_account' declares no 'region'` |
| `record.account != null` (the guard) | `otherwise` | `otherwise` |
`then` | `then` |
The "before" refusal text for every row was "The when-predicate reads
'type', which this object does not declare — fix the rule's condition,
or declare the field". That sentence points the author at the wrong
object.
**The runtime narrowing.** Rows 2 to 4, empty-reference column: a write
that `otherwise` judged before is refused now. The `when` is judged as a
`condition` reading through an empty reference has been judged since
hydration landed for conditions: the verdict does not depend on which
CEL operator is written. The refusal names the guard, which is a
separate `when: record.account != null` with this rule nested as its
`then`. This was not in the claim's measurement assumptions; see the
deviations.
### Save door and build
These run `validateStackExpressions`, the pass `os validate`, `os build`
and the object save door run. The fixtures pass `ObjectSchema` /
`ObjectStackSchema` validation.
| `when` | before (`1e8b6284c`) | after |
|---|---|---|
| `record.account.region == 'north'` (target declares no `region`) | 0
errors | 1 `EXPRESSION_INVALID` at `validation 'gate' when`, "unknown
field `region` on `hop_account`" |
| `record.account.owner.email == 'x'` | 0 errors | 1, "read through more
than one relationship hop" |
| `record.account != null && record.account.status == 'active'` | 0
errors | 1, "read BOTH through the relationship", naming the guard |
| `record.account.status == 'active'` (declared) | 0 errors | 0 errors |
| `record.account != null` (the guard) | 0 errors | 0 errors |
The "before" column is the new pins run on the unfixed tree: three red,
with `expected [] to deeply equal [ "object 'hop_order' · validation
'gate' when" ]`. The stored-metadata-body hop takes the same opt-in and
is not separately pinned.
**Exposure in the shipped tree.** Measured at `1e8b6284c` with `git
grep` outside tests: zero `when` reads through a reference. The control
is the same regex with `condition` in place of `when`, which finds 8
lines, among them
`examples/app-showcase/src/data/objects/invoice.object.ts:191`. The only
`when` in `examples/` is `record.status == 'churned'`.
## Pins
-
`packages/objectql/src/validation/rule-relationship-traversal.test.ts`,
`[#24512]` block, on the evaluator:
- the collector names a top-level and a nested `when` hop, and nothing
for the guard spelling;
- `then` and `otherwise` are each picked on the related row, at the top
level and nested;
- the branch keeps the stored id, and the payload is not mutated;
- the guard control;
- a second hop and an undeclared column are refused (`code` +
`constraint.rule` / `reason` / `fault`) while a sibling keeps its own
verdict;
- an empty reference names the guard;
- a delete's cleanup is named.
- `packages/objectql/src/engine-predicate-relationship.test.ts`,
`[#24512]` block, on the real engine:
- both branches on real related rows, with the `VALIDATION_FAILED`
envelope and per-field `code`;
- one system read projecting `['id', 'type']`;
- a sibling `when` reading an undeclared column refuses alone and never
enters the read.
- `packages/lint/src/validate-expressions.rule-one-hop.test.ts`,
`[#24512]` block, on the build: the three refusals at the top-level and
nested `when` slots, the declared read admitted, and the guard control.
- `packages/lint/src/runtime-gate.object-validation-writes.test.ts`:
- The `#22042` per-slot pin used to assert the opt-out ("OFF for a
nested `when`"). It is rewritten to assert the opt-in at both slots, top
level and nested.
- A new `#24512` block at the save door: a target from another package,
a nested `when`, and the control, all asserting `rule:
EXPRESSION_INVALID`.
## Ablations
Each leg went through `scripts/ablation-replace.mjs`. The anchor hit
once and the mutation landed (blob changed). The file was restored to
the HEAD blob with `git diff HEAD` empty. The subjects are imported by
relative path from `src/`, so no `dist/` is involved.
- **A** — `addFrom(r.when)` deleted from the collector: 5 red, the two
collector pins and the three engine pins. Pin (a) on the real engine
shows the card's defect: `Validation rule 'partner_gate' could not be
evaluated (runtime: No such key: type)`.
- **B** — `checkConditional` evaluating `ctx.merged` instead of the
hydrated copy: 6 red (both branch pins, nested, branch-isolation, engine
branch pin, engine sibling pin).
- **C** — the `when` scope refusal replaced by `return null`: 4 red
(second hop, undeclared, empty reference, engine sibling).
- **D** — the `when` cleanup text replaced by `null`: 1 red (the cleanup
pin).
- **E1 / E2** — the lint opt-in reverted at the top-level `when` / the
nested `when`: 5 red / 4 red.
## Verification
Final head `a8614e4116`, a merge of `origin/main` `14ccb1da9`. The
incoming commits are `service-automation` and a `spec/ui` export, and
they touch none of this diff's files.
- `pnpm install --frozen-lockfile`: exit 0. `pnpm turbo run build
--concurrency=2`: 77/77 tasks, exit 0.
- `pnpm --filter @objectstack/objectql exec vitest run --project local`:
447 files, 8432 tests passed, exit 0.
- `pnpm --filter @objectstack/lint exec vitest run`: 148 files, 6932
tests passed, exit 0.
- `typecheck`: objectql exit 0 and lint exit 0. Both include their
`check:test-typecheck` leg.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 70 commands. All 70 ran
on `a8614e4116` and exited 0. `--ran` reports 70 derived, 70 run, 0
NOT-MEASURED, 0 UNRUN.
- An earlier battery on `e5a2eecb0` had three non-zero exits, each
resolved before the final run:
- `check:error-code-casing` (exit 1): the new test literals asserted
`code: 'rule_violation'` without the `field` key that marks them
field-addressed. Fixed in `9b80c99f6`.
- `check:engine-split-ratio` (exit 2): the shallow-clone refusal.
Deepened with `git fetch --shallow-since=2026-07-14`.
- `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET): missing
dists. Ran after the full build.
- `test:repo` was not run locally. It is CI-owned, and this diff touches
no `repo`-project test.
## Changeset
`.changeset/24512-conditional-when-hydration.md`:
- `@objectstack/objectql` `minor` and `@objectstack/lint` `minor`;
- `fix(objectql,lint)!:`, BREAKING (an accept-set narrowing at the save
door and at write time), `Clause-②: no (narrowing)`;
- ADR-0087 marker `not-required (no-migration-prescription)`, accepted
by `check-adr-0087-registration`;
- FROM / TO lines for each package and a "What to do".
## Deviations from the dispatch
- **`@objectstack/objectql` is `minor` BREAKING, not `patch`.** The
write-time probe above measured a narrowing on that face. A write on an
empty reference under a null-safe `when` (`.?`, `has()`, or an inline
`!= null` test of the same reference) was judged by `otherwise` before.
Now it is refused. Precedent for an objectql write-time narrowing: the
`8d8f965` entry, `Clause-②: no (narrowing)`, minor.
- **The triage premise holds in part.** "Hydration admits nothing new at
the door, and it makes a write that is refused today evaluate" is true
for the plain spelling. It is not the whole effect:
- the null-safe spellings silently took the wrong branch on every set
reference before (now corrected);
- they took `otherwise` on an empty reference (now refused, as above).
- **Rulings kept.** The route is unchanged: hydrate `when` as
`checkPredicate` hydrates a condition, with the same helpers and no new
resolver.
- **No dogfood pin.** A booted pin is outside the claim's file surface
(`packages/qa/dogfood` is `domain:cli`). The envelope is asserted at the
engine (`code: 'VALIDATION_FAILED'` plus per-field `code` and
`constraint`) and at the save-door pass (`rule: 'expression-invalid'`).
The HTTP statuses (`400` on the data door, `422 INVALID_METADATA` on the
save door) are the existing mappings and were not re-measured on a
booted stack here.
## Acceptance notes
- A `when` now follows a condition's rules for an empty reference. No
single-expression spelling skips a rule while the reference is empty.
The guard is its own `when`, as the refusal says. This is the platform's
existing stance for conditions, now applied to `when`.
-
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`
carries no `when`-hop pin. The save-door pass is pinned in lint's
runtime-gate test, which is the body the door runs. Not added here.
- `pnpm --filter @objectstack/core test:repo` was not run.
`packages/core`'s per-file census already has rows for both edited
source files, and CI's `Test Core` runs it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_
---------
Co-authored-by: Claude <noreply@anthropic.com>when reads one hop through a reference, hydrated like a condition (#24650)1 parent 73d4cc7 commit 073b1b2
7 files changed
Lines changed: 498 additions & 55 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
43 | | - | |
| 42 | + | |
| 43 | + | |
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| |||
236 | 236 | | |
237 | 237 | | |
238 | 238 | | |
239 | | - | |
| 239 | + | |
240 | 240 | | |
241 | 241 | | |
242 | 242 | | |
| |||
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
315 | 315 | | |
316 | 316 | | |
317 | | - | |
318 | | - | |
319 | | - | |
320 | | - | |
321 | | - | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
322 | 326 | | |
323 | | - | |
| 327 | + | |
| 328 | + | |
324 | 329 | | |
325 | 330 | | |
326 | 331 | | |
| |||
382 | 387 | | |
383 | 388 | | |
384 | 389 | | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
544 | 544 | | |
545 | 545 | | |
546 | 546 | | |
547 | | - | |
548 | | - | |
549 | 547 | | |
550 | 548 | | |
551 | 549 | | |
| |||
568 | 566 | | |
569 | 567 | | |
570 | 568 | | |
571 | | - | |
| 569 | + | |
572 | 570 | | |
573 | | - | |
| 571 | + | |
574 | 572 | | |
575 | 573 | | |
576 | 574 | | |
| |||
1946 | 1944 | | |
1947 | 1945 | | |
1948 | 1946 | | |
1949 | | - | |
| 1947 | + | |
1950 | 1948 | | |
1951 | 1949 | | |
1952 | 1950 | | |
| |||
1984 | 1982 | | |
1985 | 1983 | | |
1986 | 1984 | | |
1987 | | - | |
1988 | | - | |
| 1985 | + | |
| 1986 | + | |
1989 | 1987 | | |
1990 | 1988 | | |
1991 | 1989 | | |
| |||
2163 | 2161 | | |
2164 | 2162 | | |
2165 | 2163 | | |
2166 | | - | |
| 2164 | + | |
| 2165 | + | |
2167 | 2166 | | |
2168 | 2167 | | |
2169 | 2168 | | |
| |||
2787 | 2786 | | |
2788 | 2787 | | |
2789 | 2788 | | |
2790 | | - | |
2791 | | - | |
2792 | | - | |
| 2789 | + | |
| 2790 | + | |
| 2791 | + | |
| 2792 | + | |
| 2793 | + | |
2793 | 2794 | | |
2794 | | - | |
2795 | | - | |
2796 | | - | |
2797 | | - | |
2798 | | - | |
| 2795 | + | |
2799 | 2796 | | |
2800 | 2797 | | |
2801 | 2798 | | |
| |||
2804 | 2801 | | |
2805 | 2802 | | |
2806 | 2803 | | |
2807 | | - | |
2808 | | - | |
2809 | | - | |
2810 | | - | |
2811 | | - | |
2812 | | - | |
| 2804 | + | |
| 2805 | + | |
| 2806 | + | |
| 2807 | + | |
2813 | 2808 | | |
2814 | 2809 | | |
2815 | | - | |
| 2810 | + | |
2816 | 2811 | | |
2817 | 2812 | | |
2818 | 2813 | | |
| |||
0 commit comments