Skip to content

Commit 440bed6

Browse files
fix(objectql)!: a faulting option visibleWhen refuses the write instead of admitting it (#22402) (#22436)
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
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/lint': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402)
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at the server's per-option gate: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is the set of writes the gate admits: a picked option whose predicate faults is refused instead of admitted. The refusal reuses the existing field-rule envelope, so no error code and no TypeScript export changes. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->
12+
13+
**BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path.
14+
15+
A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too.
16+
17+
- **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller.
18+
- **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal.
19+
- **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save.
20+
- **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted.
21+
- **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly.
22+
23+
**The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault.
24+
25+
Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option.
26+
27+
Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change.
28+
29+
`@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves.

‎content/docs/references/data/field.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ const result = CurrencyConfigSchema.parse(data);
195195
| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
196196
| **color** | `string` | optional | Color code for badges/charts |
197197
| **default** | `boolean` | optional | Is default option |
198-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
198+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
199199

200200
### Nested Shape: `Field.inlineColumns[number]`
201201

@@ -406,7 +406,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name`
406406
| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
407407
| **color** | `string` | optional | Color code for badges/charts |
408408
| **default** | `boolean` | optional | Is default option |
409-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
409+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
410410

411411

412412
---

‎content/docs/references/data/picklist.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ A shared option list that select fields reference by name instead of copying opt
7878
| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
7979
| **color** | `string` | optional | Color code for badges/charts |
8080
| **default** | `boolean` | optional | Is default option |
81-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
81+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
8282
8383
8484
---
@@ -103,7 +103,7 @@ Options added to a picklist owned by another package (additive only — the `obj
103103
| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
104104
| **color** | `string` | optional | Color code for badges/charts |
105105
| **default** | `boolean` | optional | Is default option |
106-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
106+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
107107
108108
109109
---
@@ -128,7 +128,7 @@ The served form of a picklist-bound field: `options` resolved, `picklist` kept
128128
| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
129129
| **color** | `string` | optional | Color code for badges/charts |
130130
| **default** | `boolean` | optional | Is default option |
131-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
131+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
132132
133133
134134
---

0 commit comments

Comments
 (0)