Repository navigation
Commit 440bed6
Fixes #22402
Clause-②: no (narrowing)
The server's per-option gate (`evaluateOptionVisibility` in
`packages/objectql/src/validation/rule-validator.ts`) now refuses a
write whose picked option's `visibleWhen` cannot be evaluated. Before,
it logged one warn line and admitted the value. This implements ruling
6074855432, letter A, maintainer 「299 同意」: ADR-0137 D2's submit-time
refusal reaches the option gate on the write path, because that gate is
the server's enforcement of who may pick the option (ADR-0124 D1). D3's
render fail-open is unchanged. No new error code. BREAKING on
`@objectstack/objectql`, shipped as `minor` under the launch-window
convention.
## What changed
- **The arm.** The `predicate-fault` arm pushes a refusal into the
call's `ValidationError`, built by the existing `unevaluableRuleError`
through a new `unevaluableOptionGateError`. The result is
`VALIDATION_FAILED` / 400 with one field entry per faulting pick: `code:
'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule:
'visibleWhen'`, `constraint.fault`, and the picked option as `value`.
Example message: `Option 'gold' of field 'tier' visibleWhen could not be
evaluated (runtime: No such key: statsu) — write rejected.` The operator
still gets a `warn` with `meta.reason: 'predicate-fault'`, now saying
`write rejected`.
- **The `no-acting-user` arm is unchanged.** A system write with no
acting user whose predicate reads the acting user is still admitted and
logged.
- **A system write whose predicate faults WITHOUT reading the acting
user is refused too.** That case was also in the `predicate-fault` arm.
I checked the field-level D2 precedent before mirroring it, and neither
arm exempts a write with no acting user:
- the `requiredWhen` block binds no user at all, and refuses every
fault. `engine-field-predicate-fault.test.ts` block (a) inserts with no
context and is refused.
- the `readonlyWhen` strip runs on `isSystem` writes and refuses. Its
docblock says "`isSystem` is deliberately still NOT an exemption here".
- **The trailing sentence says what is true for this gate.**
- A read through a reference gets the traversal repair, worded for an
option.
- An unbound root names what the gate binds.
- A key that is not a column of this record gets no "declare the field"
sentence and no `missingKey`. That covers a computed key, a computed
receiver, and a member of `ctx` / `os` or below a bound member.
- **The header note (`:184` on base) is rewritten to match D2.** So are:
- the "Deliberately NOT changed" parenthetical;
- the `EvaluateRulesOptions.currentUser` / `permissions` docs;
- the `evaluateOptionVisibility` and `readsUserRoot` docblocks;
- the `unevaluableRuleError` caller list.
- **A behaviour-preserving refactor for the field rules.** The
reference-traversal detection moved out of `unevaluableFieldRuleError`
into a shared `referenceReadThrough`, so the field-rule and option-gate
refusals word their own repair. The federated-readers ledger row
(`federated-injected-column-readers.test.ts`) is renamed to the seam's
new home.
## Population census, measured before flipping the arm
Readings on `origin/main` 3054516:
- **Option predicates in `examples/**` and `packages/qa/dogfood/**`:**
5, all in
`examples/app-showcase/src/data/objects/cascading-select.object.ts`.
Four are `record.country == …` cascades and one is the `'org_admin' in
current_user.positions` role gate.
- Instrument: `git grep -n visibleWhen` over both trees. Every other hit
is a field-level, row-CRUD, page or view predicate, or a ledger mention.
- Control: the same grep finds the field-level `visibleWhen` on
`invoice.object.ts:152`.
- **Did each predicate fault on a write?** Measured through the built
`evaluateValidationRules`:
| shape | verdict on base | after |
| --- | --- | --- |
| insert, country + matching province, acting user | admitted | admitted
|
| insert, province without country (acting user or system) |
`invalid_option`: `null == 'cn'` is a clean false over the total record
| unchanged |
| update, patch province only, prior row lacks the column |
`invalid_option`: `previous` is made total | unchanged |
| insert `tier: restricted`, non-admin / `org_admin` / system write |
refused / admitted / admitted (`no-acting-user`) | unchanged |
| `validate()` in `update` mode, patch omits `country` (no prior row is
read) | `predicate-fault`, admitted | refused (see Acceptance notes) |
- **The ruling's cascade question.** No write path faults when the
column is absent from the payload. Insert, by-id update and per-row bulk
update evaluate over a total record. The by-id update always reads the
prior row. The bulk path takes the per-row branch for any object with an
option gate, because `fieldsNeedPrior` counts option gates.
- **No seed or dogfood write picks a gated option.** The showcase seed
has no `showcase_cascade` rows. The dogfood persona matrix writes
`showcase_cascade` with `{ name }` only.
- **Producers fixed:** none needed.
## Call sites
- `evaluateOptionVisibility` has one caller, `evaluateValidationRules`.
Its engine call sites:
- `insert`, single and batch, which includes `insertMany`
(`engine.ts:13904`);
- `validate()` (`:13089`);
- by-id `update` (`:15500`);
- bulk `update` per matched row (`:15803`);
- bulk `update` with no prior row (`:15819`). This one is unreachable
for an object with an option gate, because `needsPriorRecord` counts the
gate.
- The import's kept-option carve-out
(`ExecutionContext.keptOptionValues`) admits values outside the options
list. `pickedGatedOptions` skips a value that matches no option, so the
carve-out is unaffected.
## Pins: flipped, and new
**Flipped. Each now asserts the refusal envelope, not only that the old
assertion is gone.**
- `rule-validator.option-visibility.test.ts`:
- "authenticated caller + a genuinely faulting predicate": asserts the
full field entry (message, `constraint`, `value`) and the `write
rejected` warn.
- "system write + a predicate naming NO user root": now refused.
- "NO permission data": now refused as `unevaluable`, never
`invalid_option`.
- `engine-option-permission-predicate.test.ts` "NO resolver ⇒ no
permission data": refused, nothing stored, with an ungated pick on the
same engine as the control.
- `engine-field-predicate-fault.test.ts` block (d), the former "option
`visibleWhen` … stays fail-open" control, is now the real-engine pin. It
covers:
- authenticated insert, system insert, by-id update and bulk update,
each refused with the store read back unchanged;
- `validate()` previewing the same refusal;
- controls: an evaluable false gives `invalid_option`, true writes, and
a system write of a user-gated option is still admitted.
**New**, in `rule-validator.option-visibility.test.ts`. Each of these
refuses:
- a computed key (`os['o' + 'rg'].id`);
- computed receivers: a comprehension variable and a ternary;
- a read one hop below a bound member (`current_user.positions.x`,
`ctx.user.organizationId.y`), from the spec seat's cross-lane note
6075512559. Measured: `No such key: x` / `y`, refused;
- a read through a reference;
- an unbound root (`type: Unknown variable: parent`);
- the other half of a user-root predicate, for an acting user;
- the same predicates on update;
- one entry per faulting pick of a multi-value field;
- a fault joining a `requiredWhen` refusal in one `ValidationError`.
**Controls, new:**
- an unpicked broken option is not judged;
- a clean false gives `invalid_option`;
- true admits with no warn;
- `no-acting-user` is still admitted;
- `has()` over the below-member hop is a clean false;
- the shipped cascade shape is a clean false when the payload omits the
parent.
**Same-semantic pins swept across the repo:**
- `packages/lint/src/validate-expressions.test.ts`: the option slot's
`consequence: 'fail-OPEN'` becomes the new sentence. Every slot now also
asserts its message carries no `fail-OPEN` / `is admitted` / `never
enforced`.
- `packages/qa/dogfood/test/expression-conformance.ledger.ts`: row
`cel-select-option-visible` goes from `fail-soft-log` to `fail-closed`,
with its enforcement cell rewritten. Its `proof` is the flipped file.
## Published text this makes false
**Changed:**
- `packages/lint/src/validate-expressions.ts`:
- `FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen']`, the sentence
`objectstack validate` prints for a traversal, an unbound root or an
unbound member. It now reads "The server REFUSES every write by an
acting user that picks this option — an option gate it cannot evaluate
refuses the write, naming the option, the field and the fault (ADR-0137
D2)".
- the doc comments near `:1022`, `:1135`, `:1170`, `:1387` and `:2283`.
- `packages/spec/src/data/field.zod.ts`: the option `visibleWhen` JSDoc
(`:392` and the enforcement sentence above it) and its `.describe()`.
Regenerated with `check:generated --fix`:
`content/docs/references/data/field.mdx`, `data/picklist.mdx`,
`ui/view.mdx`.
- `packages/cli/test/validate-field-predicate-traversal.test.ts`: a
header comment.
**Already compliant:**
- the `cascading-select.object.ts` JSDoc ("fails open only when
`current_user` is unbound (a system write)");
- the `engine.ts` `buildEvalUser` doc (membership predicates fail open
on system writes);
- `content/docs/protocol/objectui/layout-dsl.mdx:926`, which lists
per-option `visibleWhen` among the write-path predicates and states no
direction;
- no other `content/docs/**`, `skills/**` or README sentence states the
option gate's fault direction (`git grep` over those trees).
**Out of scope, with the reason:**
- **The pending changesets
`22032-object-save-door-option-visible-when.md`,
`22157-option-visible-when-parent.md` and
`22274-option-visible-when-members.md`.** They say a faulting option
predicate is logged and admitted. Each records its own landing. Editing
them here would make them false for any release cut before this PR
lands. This changeset carries one sentence saying earlier entries in the
same release describe the gate before this change.
- **`packages/*/CHANGELOG.md` and `content/docs/releases/**`.** These
are release-owned and accurate for their versions.
- **Past-tense history.** `validate-expressions.ts:2257` and two
comments in `protocol.runtime-authoring-gate.test.ts` describe the hole
as it was. They are left as history.
## Verification
All on head 0da1d65 unless a sha is named. Local scope is targeted; CI
runs the farm.
- **objectql:**
- full `vitest run --project local` at 95bdaf3: 388/389 files,
7681/7682 tests. The one red was
`federated-injected-column-readers.test.ts`, whose ledger row named the
seam's old function; it was fixed in 41c797b and re-run green.
- objectql non-test sources are byte-identical from 95bdaf3 to head
(`git diff` of the non-test sources prints 0 bytes).
- the 4 touched files: 89/89 at 41c797b; the 3 option files 87/87 at
0da1d65.
- `typecheck` (tsc + check:test-typecheck) exit 0.
- **lint:** full suite 128 files, 5881 tests, plus `typecheck`, exit 0,
at 5bee38e. lint is unchanged since then.
- **spec:**
- `vitest run --project local`: 627 files, 18757 tests passed (1 todo),
at 5bee38e.
- `typecheck` exit 0.
- `check:generated` 15/15 after `--fix` regenerated `check:docs`.
- **dogfood:** `test/expression-conformance.test.ts` 7/7. The first
attempt failed to resolve `@objectstack/verify` (not built), so it
measured nothing; it was rebuilt and re-run.
- **cli:** `test/validate-field-predicate-traversal.test.ts` is
integration-tier and its edit is comment-only. It is declared to CI and
NOT MEASURED locally.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 115 families on
0da1d65, and all 115 exit 0.
- Four of them first exited 3/1 because a stale build I had started was
rebuilding `dist/` under them: `check:api-surface`,
`check:skill-examples`, `check:dts-closure`,
`check:dual-build-cjs-loads`. That measured nothing. I rebuilt
`client-react` and `organizations` and re-ran all four green.
- `--ran` with exit codes: "115 derived, 115 run, 0 NOT-MEASURED, 0
UNRUN".
- The derivation warned the tree is 3 commits behind `origin/main`
e02833c. Those commits touch none of this PR's files; CI's merge ref
judges the join.
- **Ablation of the refusal.** The fix was committed first. The tests
import `./rule-validator.js` and `./engine.js` relatively, so they read
`src/` and no `dist/` rebuild applies.
- Tool: `node scripts/ablation-replace.mjs` with anchor
`errors.push(refusal);` → `void refusal;`, the faulting gate admitted
again.
- The mutation landed: anchor 1→0, blob a08b856 → ea63d5eaec09.
- Baseline 87/87. Mutated: 20 failed, 67 passed. Every new or flipped
refusal pin went red and every control stayed green.
- Restore: blob == HEAD (a08b856) and `git diff HEAD` is empty.
## Acceptance notes
- **`validate()` in `update` mode reads no stored row.** A field-level
predicate reading a column the patch omits therefore faults there. That
is an import dry run of a matched row. It already refuses for
`requiredWhen`, with "which this object does not declare" although the
column is declared. The option gate now joins it: before, it was a false
all-clear; now the preview refuses a row the write may admit. The write
itself reads the stored row.
- Measured on 3054516: `validate('showcase_cascade', { province: 'zj'
}, { mode: 'update' })`. The real by-id update over a stored `cn` row
admits.
- Reported for the seat to file. The engine's own `validate()` docblock
names this limit for traversing rules only.
- **The stale `EvaluateRulesOptions.currentUser` type**
(`rule-validator.ts:396`, `{ id?; roles?; organizationId?; [k]: unknown
}`). The engine's `buildEvalUser` returns `{ id, positions,
organizationId }`, and `roles` is read nowhere in `rule-validator.ts`;
its only hit is the type itself. Not fixed in place: it is a different
defect class (type drift on the exported `EvaluateRulesOptions`), so
condition ① of the bounded in-place fix does not hold.
- **The lint mirror `OPTION_CHECK_ACTING_USER`** (PR #22427; not on
`origin/main` e02833c) has no key-set pin against `buildEvalUser`. Not
built here. What this PR changes about that drift:
- Before: if a key is removed from `buildEvalUser` while lint still
admits it, a predicate reading it faulted open, admitted with a warn.
- Now: it refuses the write, naming the fault.
- So the drift turns from a silent open gate into a loud refusal at the
first write; `os build` still passes it. carrier: none.
-
**`scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json`.**
Its invariant says a broken predicate "still fails open, and
requiredWhen / option visibleWhen are untouched", which has been stale
since D2. carrier: none.
- **`docs/qa/platform-checklist/areas/records-forms.json`, the cascade
item's source line,** says "fail-open on unevaluable". It is a
revisioned checklist item. carrier: none.
- **The dogfood ledger row `cel-field-rule`.** Its comment still says
`fail-soft-log` on all three slots, which has been stale since D2 for
`requiredWhen` / `readonlyWhen`. carrier: none.
## Patch round 1
Head `2c1c85aff`. Contract review 6076709701 failed on ② only; every ①
and ③ judgment there carries over.
- **② fixed.** The changeset's parenthetical "(ADR-0131 D9)" had been
copied from another changeset, and it named a decision about
organization stamps. It is replaced. The `minor` bump now cites the
record of the launch-window convention
(`scripts/check-changeset-no-major.mjs`), and the governing decision is
ADR-0137 D2, extended to the option gate on the write path by the
maintainer's ruling on #22402 (letter A). Nothing else in the changeset
moved.
- **`origin/main` merged** (`05c7c3fa3`, merge commit `803e74cdf`, no
conflict). PR #22427's lint changes were read:
- The `OPTION_CHECK_ACTING_USER` docblock states no admission.
- Its member refusals print `FIELD_TRAVERSAL_CONSEQUENCE['option
visibleWhen']`, which already states the refusal.
- One docblock sentence (`optionVisibleWhenUserMembers`) records an
admission measured before that verdict existed. It gains: "(Since #22402
such a fault refuses the write instead, ADR-0137 D2; the build verdict
still names it first.)"
- Its test comment and the `protocol.runtime-authoring-gate.test.ts`
comments are past-tense measurements, left as history.
- Its changeset (`22394-option-visible-when-evaluser-members.md:27`)
stays as its own landing's record. The supersession sentence here covers
it.
- **ADR anchor**
`scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json`.
The invariant no longer says a broken predicate fails open. It now
states:
- D2's refusal for `requiredWhen`, for `readonlyWhen` and for the option
gate, with the no-acting-user case excepted;
- what stays fail-open: the render side (ADR-0137 D3), a broken `format`
or `json_schema`, and a rule that throws.
- `ADR-0137` is added to `adrs`.
- **Checklist** `records-forms.cascading-multilevel-and-clear`: its
source line for `evaluateOptionVisibility` said "fail-open on
unevaluable", which this PR makes false. It is corrected, with revision
1 → 2 and a history entry. No step or oracle moves.
- **The dogfood ledger `cel-field-rule` row comment is left as it is.**
It has been stale since D2, not since this PR. Rewriting it honestly
means deciding whether that row's `failPolicy` stays `fail-soft-log`
after D2. That is a classification judgment, not a mechanical text fix,
so bounded-in-place condition ② fails.
- **Lint wording (③ ① item 10) is kept**: "every write by an acting
user". The sentence is true in every message that carries it (a
traversal, an unbound root, an unbound member). The broader statement
would have to carry the no-acting-user exception, which applies only to
predicates that read the acting user.
- **Correction to the round-0 reason** for not editing the pending
changesets. That reason ("would be false for a release cut before this
lands") was backwards, because an edit lands only with this PR. The
choice stands for a different reason: each entry records its own
landing.
Verification on head `2c1c85aff`, after `pnpm install --frozen-lockfile`
and a rebuild of the `@objectstack/lint...` and
`@objectstack/objectql...` closures:
- `spec check:generated`: 15/15 up to date.
- lint: full suite 128 files, 5894 tests, plus `typecheck`, exit 0.
- objectql: full `--project local` 390 files, 7698 tests, plus
`typecheck`, exit 0.
- `dispatch-gates` derived 125 families on `2c1c85aff`, including
`check:adr-anchors`, `check:platform-checklist` and the changeset gates,
and all 125 exit 0. `check:skill-examples` and
`check:dual-build-cjs-loads` first exited 3 on unbuilt packages, which
measured nothing. Both ran green after the battery's own full build.
`--ran` with exit codes reports "125 derived, 125 run, 0 NOT-MEASURED, 0
UNRUN".
(Section written by the seat from the dev's patch-round report
6077384966.)
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent da159f7 commit 440bed6
16 files changed
Lines changed: 664 additions & 150 deletions
File tree
- .changeset
- content/docs/references
- data
- ui
- docs/qa/platform-checklist/areas
- packages
- cli/test
- lint/src
- objectql/src
- validation
- qa/dogfood/test
- spec/src/data
- scripts/adr-anchors
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
195 | 195 | | |
196 | 196 | | |
197 | 197 | | |
198 | | - | |
| 198 | + | |
199 | 199 | | |
200 | 200 | | |
201 | 201 | | |
| |||
406 | 406 | | |
407 | 407 | | |
408 | 408 | | |
409 | | - | |
| 409 | + | |
410 | 410 | | |
411 | 411 | | |
412 | 412 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
81 | | - | |
| 81 | + | |
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | | - | |
| 106 | + | |
107 | 107 | | |
108 | 108 | | |
109 | 109 | | |
| |||
128 | 128 | | |
129 | 129 | | |
130 | 130 | | |
131 | | - | |
| 131 | + | |
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
| |||
0 commit comments