Skip to content

Commit aa9447c

Browse files
fix(lint)!: the object save door gives the build's option visibleWhen verdict (#22032 pass 3) (#22151)
Part of #22032 Clause-②: no (narrowing) This is pass 3 of #22032: a field option's `visibleWhen`. Pass 4 (the object's own action predicates) stays fenced, and the card stays open for it. ## What changes **The object save door gives the build's verdict on a field option's `visibleWhen`.** `formulas.mdx` says "the same `validateExpression` validator backs `os build` and metadata registration". After pass 2 the object door ran the whole field walk except the per-option loop, which kept its own guard. So an object whose option carried `visibleWhen: 'amount > 1'` (a bare field reference) still saved with a 200, while `os build` refused it at error. The server's option check cannot evaluate such a predicate and fails open (logged, allowed through), so the gate it declares is never enforced (read from `evaluateOptionVisibility` in `packages/objectql/src/validation/rule-validator.ts`). - **The lift is the guard, nothing else (H1 held).** On `origin/main` `8fc50b7647` the loop read `for (const [oi, opt] of (objectWrite ? [] : recordsOf(f.options)).entries())` (`validate-expressions.ts:2072`), under a `[#22032] FENCED on an object write (pass 3 …)` comment. It now reads `recordsOf(f.options)`. On an object write the loop runs at the build's own position in the field walk, so the door gets both of the option's checks: - `check(optionWhere, opt.visibleWhen, objectName, 'record')`; - `refuseFieldTraversal(optionWhere, 'option visibleWhen', …)`, the refusal of a read through a reference field on `record` or `previous`. - **`current_user` keeps the build's two verdicts (H2).** An option's evaluator binds the acting user (ADR-0068 D1), so the build accepts `current_user` on an option and refuses it on the field-rule slots one level up. The door now gives both verdicts as the build does. The showcase's role gate, `'org_admin' in current_user.positions`, still saves on an option, and the same text on the field's own `visibleWhen` is refused at both doors. Both are pinned. - **No registry change (H3 re-verified).** The `validateStackExpressions` entry declares `runtimeTypes: ['flow', 'action', 'hook', 'object']` (`authoring-rules.ts`), and `runtimeAuthoringRulesFor('object')` (`runtime-gate.ts`) dispatches it. `runtime-gate.ts` is untouched. - **Docblocks made true.** `StackExpressionOptions.runtimeWriteType` now names four admitted passes and one fenced pass. `AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one line that reaches a built `.d.ts`, names the per-option pass. The function-head comment and the field walk's two comments move with it. In `authoring-rules.ts` the registry entry gains a `[#22032, pass 3]` measurement comment, as passes 1 and 2 added theirs. Comments in four sibling test files are corrected so that none of them still says option `visibleWhen` is fenced. - **The door's verdict is the build's finding (H4).** The door's 422 issue and `runAuthoringRules('build', …)` give the same rule (`expression-invalid`), location (`object 'fx_option' · field 'province' option 'zj' visibleWhen`), path, message and hint. The pins compare these key by key. - **No code change in `packages/metadata-protocol`.** Only its test file gains the door-level pins. ## Pins - **Lint door:** `packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts` (new, 14 tests). - LIT: one refused body per finding the pass gives. These are a bare `amount > 1`, an unregistered `sqrt(record.amount) > 1`, an unknown field `record.amont > 1`, a syntax error `record.country ==`, and the traversal refusal through `record.account` and through `previous.account`. Each is located at the option and asserted on its named subject. - CONTROL (the still-accepted cases): the `record.country` cascade, the showcase's `current_user.positions` role gate, a grant check plus role gate (`current_user.can(…) && …`), and a reference compared as a value (`record.account != null`). Each is clean at the door and at the build. - CONTRAST: `current_user` on the option and on the field's own `visibleWhen` in one body. The build and the door both give exactly one finding, at the field slot. - PARITY: for each refused body, the door's findings equal the build's. - The differential: a stored sibling's broken options are not this write's to answer for. - **The fence (enumeration pin)** in `packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The fenced site is now pass 4's alone (an action `visible`). The option `visibleWhen` site moves to the lifted sites, beside the validation rule and the `requiredWhen`. The build flags all four sites. The object door flags the three lifted sites in the build's order, and `runStackExpressionPasses` on an object write returns exactly the build's findings for the admitted passes. - **Protocol door:** a new pass-3 block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, through the real `saveMetaItem`, `publishMetaItem` and `publishPackageDrafts`. - (a) A bare reference, an unregistered function and a read through a reference field are each refused on an active save. The answer is a 422 `INVALID_METADATA` carrying the build's located finding, and nothing lands. - (a) The card-shaped body is refused on a draft's promotion and on a package draft publish (`outcome: 'refused'`, `failed` naming the object with `INVALID_METADATA`, the row left a draft). The draft saves themselves still succeed. - (b) The showcase's cascade and its `current_user` role gate still save, and the row lands active. The role gate rides every refused body too, which each yield exactly one finding. - (d) For each refused body, the door and `os build` give the same finding on rule, where, path, message and hint. ## Reverse verification (one-off, from committed HEAD `23ce6c494c`) - **What was mutated.** `scripts/ablation-replace.mjs` (wrap mode) put the guard back on the option loop. The new text was `const ablationFence22032p3 = objectWrite;` followed by `for (const [oi, opt] of (ablationFence22032p3 ? [] : recordsOf(f.options)).entries()) {`. The anchor was hit once, 1 to 0, and the blob went `879fcb828037` to `73bd026d1554`. The outer script carried `trap restore EXIT INT TERM` on the absolute path. - **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - **Lint suites (source): 9 failed and 14 passed, as predicted.** - Red: the 6 LIT tests, PARITY, and the two fence tests that assert the lifted sites. - Green: the 4 CONTROL tests, CONTRAST, the differential, the registry test, the build-flags-each-site test and the six formula-door tests. - **Protocol pass-3 block (dist-mediated): 6 failed and 1 passed, as predicted.** Red: the three (a) saves, (a) on promotion, (a) on package publish, and (d). Green: (b). - **Restore.** The tool restored the file: blob `879fcb828037` equals HEAD, and `git diff HEAD` is empty. The whole tree had 0 changed paths. Lint was rebuilt, and `--absent` found the marker gone from all 14 built files. Both suites went green again: lint 23 of 23, and the protocol file 99 of 99. ## Measurements - **Corpus first: the stop condition was not met (H5).** Every object this tree ships was judged before the door changed. That is every `*.object.ts` under `packages/**` and `examples/**` (111 files) plus the two `app-multi-package` sub-stacks: 118 objects in 18 groups. Each was judged at the raw shape and at the `ObjectSchema.parse` shape (0 parse failures), with its own group as context. - 5 option predicates on 2 fields of 1 object, all on `showcase_cascade`: `province`'s four `record.country` cascades (`zj`, `gd`, `ca`, `tx`) and `tier`'s `restricted` role gate (`'org_admin' in current_user.positions`). - At base `8fc50b7647`: 0 build errors and 0 build warnings for the option pass, through `validateStackExpressions` and through `runAuthoringRules('build')`, at both shapes. There were 0 door expression findings over all 118 objects. - Non-vacuity: the 5 sites are judged. Mutating one cascade to a bare `country` and the role gate to `sqrt(record.amount) > 1`, in a copy, gave 2 build errors at those two options. - At head `23ce6c494c`, and again at the merged heads `06d3cad963` and `3c1d3262ee`: 0 door errors and 0 door advisories over every object, through `runRuntimeAuthoringRules` with type `object`, at both shapes. The harness passes each object's own group as context, so at `3c1d3262ee` every one of those saves is an update and also runs the stored-universe pass that #22118 added. - Positive control in the same harness (an option `visibleWhen: 'amount > 1'`): 1 build error at every head. The door gave 0 at base and 1 at head. - **Which doors newly answer 422.** The active publish save, a draft's promotion, and a package draft publish. Each was measured through the real methods above. A draft save stays ungated, measured by the same pins. ## Clause-② (measured) - **Accept set: narrowing.** An object write in publish mode answered 200 for an option `visibleWhen` the validator refuses. It now answers 422 on the three doors above. - **Built entry declarations.** In `@objectstack/lint` one doc comment moves (`AuthoringRuleContext.runtimeWriteType`). `StackExpressionOptions` and `runStackExpressionPasses` are not in the built declarations. No exported signature moves. - **Changeset.** `.changeset/22032-object-save-door-option-visible-when.md` covers `@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It carries `fix(lint)!`, the `Clause-②: no (narrowing)` line, a BREAKING section with the remedy, and ADR-0087 `not-required (no-migration-prescription)`. `@objectstack/metadata-protocol` is listed as passes 1 and 2 listed it, although no code moves there: its save, promotion and package-publish doors are where the BREAKING behaviour can be seen. `.changeset/pre.json` is absent on `origin/main` (read at `8fc50b7647`, 2026-10-08T01:54Z, at `ef1fcb26a2`, 2026-10-08T02:40Z, and at `7ef50a4fbb`, 2026-10-08T03:39Z), so the bump is `minor` with the BREAKING banner, as in passes 1 and 2. The changeset says it supersedes the earlier #22032 entries' line that option `visibleWhen` is not judged at this door. ## Merges - **`06d3cad963`** merges `origin/main` `ef1fcb26a2` (PR #22103), through `scripts/pm/os-regen-merge.sh`. It was clean. - **`3c1d3262ee`** merges `origin/main` `7ef50a4fbb` (parents `06d3cad963` and `7ef50a4fbb`), through `scripts/pm/os-regen-merge.sh`. That brought PR #22129 (#21982), PR #22094, PR #22134, PR #22133 (#22118, the gate's object-write baseline keeps the written item's stored self), PR #22126 and PR #22140. - `validate-expressions.ts` auto-merged. PR #22129's edit is in the flow `script` / `subflow` region; the option loop's lift is unchanged. - One conflict: `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, one hunk. Both sides had added a new top-level `describe` block at the same place, after the pass-2 block. It was resolved by stacking: the pass-3 block first, then #22118's stored-self block, and every line of both was kept. Against `7ef50a4fbb` the file shows only this branch's lines; against `06d3cad963` it shows only #22118's 114 lines. - No generated path was touched by this branch. The rerun of the script took `origin/main`'s side of every generated path main moved, and that left nothing to commit. - This branch's own changes are the same before and after the merge: for each of the 9 files, the added and removed lines against the merge base are identical. The delta against `7ef50a4fbb` is 9 files, +467 / −45. - **The interaction with #22118, measured.** #22118 adds a third, stored-universe pass on an update into a context collection. A finding whose path names no entry is judged as one on the written item. The expression rule's paths are `where` strings, so an option finding is always judged that way. - A probe ran the real gate (`runRuntimeAuthoringRules`, type `object`) over 20 cases, against this branch's lint source before the merge (`06d3cad963`) and after it (`3c1d3262ee`). The cases are a create, an update over a stored self that is broken and over one that is clean, and a stored sibling that is broken, each for three refused bodies, plus two still-accepted bodies. - The verdicts are identical, case for case. Re-saving a broken option is still refused, including over a broken stored self, because re-saving a row is writing it. A clean save over a broken stored self passes. A broken sibling is never charged. - The pass-3 pins (differential, PARITY, LIT, CONTROL, CONTRAST) and #22118's pins all pass at `3c1d3262ee`: lint 48 of 48 across the three files, and the protocol file 103 of 103. - This matches the code. The option pass reads only the written object's own field index, and binds `record` and `previous`, never `parent`, so the stored universe has nothing extra to offer it. ## Tests and gates (all at `3c1d3262ee`, the merge of `origin/main` `7ef50a4fbb`) - **`main` moved during the run (H6).** PR #22103 landed as `ef1fcb26a2` and touched two files this pass edits, `authoring-rules.ts` and `runtime-gate.object-writes.test.ts`, in other hunks. It was merged with `scripts/pm/os-regen-merge.sh` as a merge commit. The merge was clean, and no generated path was taken from either side. After the merge: `pnpm install --frozen-lockfile`, a full turbo build (72 tasks), and `@objectstack/spec check:generated` ("All 15 generated artifacts are up to date"). #22118 and #21982 had not landed at that read (2026-10-08T02:40Z). Both have since landed, and they are merged at `3c1d3262ee` (see Merges). After that merge the same sequence ran: a full turbo build (72 tasks) and `check:generated` ("All 15 generated artifacts are up to date"). - **`@objectstack/lint`:** 125 files and 5729 tests passed. `typecheck` exit 0, with its test-typecheck included (`--listFiles`: the five touched or new lint test files are in the `tsconfig.test.json` program). - **`@objectstack/metadata-protocol`:** 221 files passed and 3 skipped; 28260 tests passed and 19 skipped. `typecheck` exit 0 (`--listFiles`: the door test file is in the program). - **Consumer readings.** Every package was built at the head named. - `@objectstack/objectql`, 8 files and 282 tests passed: `publish-package-drafts-response-conformance`, `save-meta-response-conformance`, `publish-meta-response-conformance`, `plugin.integration`, `engine-field-predicate-fault`, `engine-option-permission-predicate`, `validation/rule-validator.option-visibility` and `engine`. - `@objectstack/rest`, 11 files and 197 tests passed: every `meta-object-*` file and `meta-publish-package-scope`. - `@objectstack/cli`, 3 files and 16 tests passed, run as `--project integration` because the tier predicate puts them there: `validate-field-predicate-traversal` (which asserts an option `visibleWhen` `expression-invalid` finding), `authoring-rule-command-parity` and `verify-author-time-stage`. The three nightly-tier `*.e2e` files that assert `expression-invalid` are left to CI. - The search for other consumers covered every test file in the repository carrying `visibleWhen`, matched against the save-door entry points. Only the two packages above save an option `visibleWhen` through a door. - **Gates.** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands, the same set at `23ce6c494c`, `06d3cad963` and `3c1d3262ee`. At `3c1d3262ee` all 63 exit 0, each exit code captured before any pipe. `--ran` reconciles 63 derived, 63 run, 0 NOT-MEASURED and 0 UNRUN, with an exit code recorded for each. The printed artifact-roster block names 51 families, and all 51 ran at `3c1d3262ee`, each with exit 0. That is 47 in the same battery, one (`check:engine-double-contract`) already among the 63, and the three PR-scoped ones (`check-partof-closing-keyword`, `check-closing-target-claim`, `check-single-claim-paths`) run with this PR's number and this body. The same 63 also ran at `06d3cad963`, all exit 0. At `23ce6c494c`, before the merge, the same 63 ran: 61 exited 0 on the first run. `check:dual-build-cjs-loads` and `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no full build) and exited 0 after the full build. - **ESLint, narrowed to the 8 touched TypeScript files** (`--no-inline-config --format json`): 8 files, 0 errors and 0 warnings. Each file is matched by `eslint.config.mjs` (`--print-config`), and none was ignored. The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - **Out of this pass, reported for the seat: an option `visibleWhen` reading `parent` gets no verdict at the build, so none at the door either.** - Measured at `23ce6c494c` with scratch tests that were deleted afterwards. Through the real `saveMetaItem`, an object whose option carries `visibleWhen: "parent.status == 'closed'"` saved with success, and the row landed `active`. `runAuthoringRules('build')` gave 0 findings. - The server's option check (`evaluateValidationRules`, authenticated caller, the option picked) then logged `failed to evaluate (authenticated caller) — allowed through`, with `Unknown variable: parent`, and admitted the value. The option evaluator binds `record`, `previous`, the user and permissions only. - This is a gap in the build, which the door now mirrors. This card's contract is parity with the build, so it is not changed here. - **A code comment names an old spelling.** The comment above the option loop calls the showcase's legal usage `'admin' in current_user.positions`. The showcase now writes `'org_admin' in current_user.positions`, and the pins use that spelling. The comment is not changed here: per the contract review, it rides pass 4. - **The pending #22032 changesets.** Pass 1's and pass 2's changesets each list option `visibleWhen` under "Unchanged", which was true at their heads. This pass's changeset says it supersedes that line rather than editing them, the same way pass 2 left pass 1's changeset alone. Per the contract review, reconciling those lines rides pass 4. - `formulas.mdx` could name the object save door. That would be a docs addition, not a correction of a false line. - **Contract review.** Triage's grade asks for one per pass. A PASS is on record for head `06d3cad963` (`6051573476`). The head has since moved to `3c1d3262ee` by the merge above, so the review for this head is the seat's. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c5aa50 commit aa9447c

9 files changed

Lines changed: 467 additions & 45 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032)
7+
8+
Clause-②: no (narrowing)
9+
10+
`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced.
11+
12+
The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. This supersedes the earlier #22032 entries' line that option `visibleWhen` is not judged at this door.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
17+
- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories.
18+
19+
**Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
20+
21+
**Unchanged.**
22+
23+
- `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before.
24+
- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged.
25+
- The object's own action predicates (`actions[].visible`, `actions[].disabled`) are still not judged at this door. `os build` judges them, and the door does not, as before.
26+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
27+
- Measured before crossing: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door.
28+
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`.
29+
30+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the object save door of a field option's visibleWhen predicate the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose option predicate the validator refuses keeps loading until it is next saved, and the repair is the author's edit of the predicate, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,8 @@ export interface AuthoringRuleContext {
309309
*
310310
* [#22019] One other rule reads it, on that argument: `validateStackExpressions`
311311
* is one entry over several PASSES, and an `object` write is admitted for its
312-
* field-formula pass and (#22032) its validation-rule and field-rule-slot
313-
* passes alone (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
312+
* field-formula pass and (#22032) its validation-rule, field-rule-slot and
313+
* per-option `visibleWhen` passes alone (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
314314
* `runtimeTypes` can say that an object write reaches the rule; it cannot say
315315
* which of the rule's passes judge that write.
316316
*/
@@ -646,6 +646,18 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
646646
// errors and 0 warnings for the pass, and 0 door errors and 0 advisories
647647
// at the door's own snapshot shape, against a refusal at each for the
648648
// card's body in the same harness.
649+
//
650+
// [#22032, pass 3] The per-option `visibleWhen` pass joins the object
651+
// door: every `fields[].options[].visibleWhen`, with the reference-traversal
652+
// refusal, and `current_user` accepted there as the build accepts it. The
653+
// object's own `actions[]` predicates stay fenced. No entry-level change.
654+
// MEASURED first, at both the raw and the parsed shape: every option
655+
// predicate the repository ships — 5 options on 2 fields of 1 object
656+
// (examples: app-showcase `showcase_cascade`, four `record.country`
657+
// cascades and one `current_user.positions` role gate), over 118 objects
658+
// → 0 build errors and 0 warnings for the pass, and 0 door errors and 0
659+
// advisories at the door's own snapshot shape, against a refusal at each
660+
// for a bare `amount > 1` option in the same harness.
649661
surfaces: CLI_AND_RUNTIME,
650662
runtimeTypes: ['flow', 'action', 'hook', 'object'],
651663
run: (stack, ctx) =>

‎packages/lint/src/runtime-gate.object-field-rule-writes.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
* `runStackExpressionPasses` admits the field-rule slots on an object write,
2525
* at the build's own position in the field walk, so the door's finding IS the
2626
* build's finding — rule, location, message and hint. The per-option
27-
* `visibleWhen` (pass 3) and the object's own action predicates (pass 4) stay
28-
* fenced; that pin is in `runtime-gate.object-formula-writes.test.ts`.
27+
* `visibleWhen` joined the door in pass 3
28+
* (`runtime-gate.object-option-visibility-writes.test.ts`); the object's own
29+
* action predicates (pass 4) stay fenced, and that pin is in
30+
* `runtime-gate.object-formula-writes.test.ts`.
2931
*
3032
* The protocol-level half — the same verdict through the real `saveMetaItem`,
3133
* `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 2 block of

‎packages/lint/src/runtime-gate.object-formula-writes.test.ts‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
* `object` joins `runtimeTypes`, and the gate's `runtimeWriteType` reaches the
2020
* rule (`runStackExpressionPasses`), which on an object write runs the
2121
* field-formula pass — and, since #22032's pass 1, the validation-rule pass
22-
* (its pins: `runtime-gate.object-validation-writes.test.ts`), and since its
23-
* pass 2 the field-rule slots (`runtime-gate.object-field-rule-writes.test.ts`).
24-
* Every other object-borne pass the build runs — option `visibleWhen`, the
25-
* object's own action predicates — is FENCED off this door by name, and the
26-
* fence is pinned below with the build still flagging the same body, so a
27-
* later widening moves that line consciously rather than by drift.
22+
* (its pins: `runtime-gate.object-validation-writes.test.ts`), since its
23+
* pass 2 the field-rule slots (`runtime-gate.object-field-rule-writes.test.ts`),
24+
* and since its pass 3 the per-option `visibleWhen`
25+
* (`runtime-gate.object-option-visibility-writes.test.ts`). The one other
26+
* object-borne pass the build runs — the object's own action predicates — is
27+
* FENCED off this door by name, and the fence is pinned below with the build
28+
* still flagging the same body, so a later widening moves that line
29+
* consciously rather than by drift.
2830
*
2931
* The protocol-level half — the same verdict through the real `saveMetaItem`
3032
* and `publishMetaItem`, and the door/build equality of the finding — is
@@ -114,10 +116,10 @@ describe('#22019 — the object door dispatches the build\'s expression rule', (
114116

115117
describe('#22019 — the fence: every other object-borne expression pass stays off this door', () => {
116118
/**
117-
* One body carrying a fault in each FENCED pass — #22032's passes 3 and 4,
118-
* one site each: an option's `visibleWhen`, an object action's `visible` —
119-
* beside a fault in each LIFTED pass, the validation-rule pass (#22032 pass
120-
* 1) and a field-rule slot (`requiredWhen`, #22032 pass 2), and a CLEAN
119+
* One body carrying a fault in the FENCED pass — #22032's pass 4, one site:
120+
* an object action's `visible` — beside a fault in each LIFTED pass, the
121+
* validation-rule pass (#22032 pass 1), a field-rule slot (`requiredWhen`,
122+
* #22032 pass 2) and an option's `visibleWhen` (#22032 pass 3), and a CLEAN
121123
* formula. The build flags every fault; the object door flags the lifted
122124
* passes' alone. Each fault is one the build refuses at `error`, so "the
123125
* door is silent on a fenced site" cannot be read as "there was nothing to
@@ -142,14 +144,14 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
142144
};
143145
return body;
144146
};
145-
/** The fenced sites (passes 3–4) and the lifted ones (passes 1–2), by the build's `where`, in the build's order. */
147+
/** The fenced site (pass 4) and the lifted ones (passes 1–3), by the build's `where`, in the build's order. */
146148
const FENCED_SITES = [
147-
"object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen",
148149
"object 'fx_sqrt' · action 'fx_close' visible",
149150
];
150151
const LIFTED_SITES = [
151152
"object 'fx_sqrt' · validation 'amount_root'",
152153
"object 'fx_sqrt' · field 'name' requiredWhen",
154+
"object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen",
153155
];
154156

155157
it('the build (no `runtimeWriteType`) still flags each fenced site, and the lifted ones', () => {
@@ -163,11 +165,11 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
163165
expect(wheres.some((w) => w === WHERE), 'the clean formula must not be flagged').toBe(false);
164166
});
165167

166-
it('the object door flags none of the fenced sites — only the formula, validation-rule and field-rule-slot passes judge there', () => {
168+
it('the object door flags none of the fenced sites — only the formula, validation-rule, field-rule-slot and option passes judge there', () => {
167169
const result = gateObject(withFieldRule());
168170

169171
expect(result.rulesRun).toContain('validateStackExpressions');
170-
// [#22032 passes 1–2] The lifted passes' findings, and nothing else.
172+
// [#22032 passes 1–3] The lifted passes' findings, and nothing else.
171173
expect(expressionFindings(result.errors).map((f) => f.where), dump(result)).toEqual(LIFTED_SITES);
172174
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
173175
});
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22032, pass 3 — the OBJECT write door runs the build's per-option
5+
* `visibleWhen` pass.
6+
*
7+
* ## The state this closes
8+
*
9+
* `validateStackExpressions` is the build's expression rule. Its field walk
10+
* judges every `fields[].options[].visibleWhen` as a `record`-scoped predicate,
11+
* plus the #20078 refusal of a read through a reference field. After #22032's
12+
* pass 2 the object door ran the rest of the field walk and fenced the option
13+
* loop off by its own guard, so an option whose `visibleWhen` read a bare
14+
* `amount` — refused by `os build` — published clean, and the server's option
15+
* check then could not evaluate it and failed open on every write.
16+
*
17+
* ## The crossing
18+
*
19+
* No registry change: the entry already declares `object`. The option loop's
20+
* guard is gone, so on an object write it runs at the build's own position in
21+
* the field walk, and the door's finding IS the build's finding — rule,
22+
* location, message and hint. The object's own action predicates (pass 4)
23+
* stay fenced; that pin is in `runtime-gate.object-formula-writes.test.ts`.
24+
*
25+
* ## What still publishes
26+
*
27+
* An option's evaluator binds `current_user` (ADR-0068 D1), so the build
28+
* accepts it there while it refuses it on the field-rule slots one level up.
29+
* The door gives the two verdicts the build gives: the showcase's role gate
30+
* (`'org_admin' in current_user.positions`) still publishes on an option, and
31+
* the same text on the field's own `visibleWhen` is refused, at both doors.
32+
*
33+
* The protocol-level half — the same verdict through the real `saveMetaItem`,
34+
* `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 3 block of
35+
* `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
36+
*/
37+
import { describe, expect, it } from 'vitest';
38+
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
39+
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
40+
41+
/**
42+
* The probe object; `visibleWhen` lands on the `gold` option of `tier`, and
43+
* `fieldVisibleWhen` (when given) on the `name` field's own slot.
44+
* `sharingModel` keeps `security-owd-unset` quiet, so a refusal is the rule's.
45+
*/
46+
const fxOption = (visibleWhen: unknown, fieldVisibleWhen?: unknown) => ({
47+
name: 'fx_option',
48+
label: 'Option Probe',
49+
sharingModel: 'private',
50+
fields: {
51+
name: { type: 'text', label: 'Name', ...(fieldVisibleWhen === undefined ? {} : { visibleWhen: fieldVisibleWhen }) },
52+
amount: { type: 'number', label: 'Amount' },
53+
country: {
54+
type: 'select',
55+
label: 'Country',
56+
options: [{ label: 'China', value: 'cn' }, { label: 'United States', value: 'us' }],
57+
},
58+
account: { type: 'lookup', label: 'Account', reference: 'fx_account' },
59+
tier: {
60+
type: 'select',
61+
label: 'Tier',
62+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
63+
},
64+
},
65+
});
66+
67+
const WHERE = "object 'fx_option' · field 'tier' option 'gold' visibleWhen";
68+
69+
/**
70+
* One refused body per finding the pass gives. Each `subject` is the named
71+
* subject of the build's finding (what the author typed), not its prose.
72+
*/
73+
const REFUSED = [
74+
// The card's shape: a bare field reference.
75+
{ body: 'amount > 1', subject: 'bare reference `amount`' },
76+
{ body: 'sqrt(record.amount) > 1', subject: '`sqrt`' },
77+
{ body: 'record.amont > 1', subject: 'unknown field `amont`' },
78+
{ body: 'record.country ==', subject: 'invalid CEL predicate' },
79+
// The traversal refusal, on both roots an option binds.
80+
{ body: "record.account.name == 'x'", subject: 'reads `name` through `record.account`' },
81+
{ body: "previous.account.name == 'x'", subject: 'reads `name` through `previous.account`' },
82+
] as const;
83+
84+
/**
85+
* Bodies the build accepts on an option, and so must the door: the cascade,
86+
* the showcase's role gate, the grant check, and a reference compared as a
87+
* value rather than read through.
88+
*/
89+
const ACCEPTED = [
90+
"record.country == 'cn'",
91+
"'org_admin' in current_user.positions",
92+
"current_user.can('fx_option', 'edit') && 'org_admin' in current_user.positions",
93+
'record.account != null',
94+
] as const;
95+
96+
const gateObject = (item: unknown, objects: unknown[] = []) =>
97+
runRuntimeAuthoringRules({ type: 'object', item, context: { objects } });
98+
99+
const expressionFindings = <T extends { rule: string }>(fs: readonly T[]): T[] =>
100+
fs.filter((f) => f.rule === EXPRESSION_INVALID);
101+
102+
const buildFindings = (...objects: unknown[]) => {
103+
const stack = { objects };
104+
return expressionFindings(runAuthoringRules('build', { normalized: stack, parsed: stack }));
105+
};
106+
107+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
108+
109+
describe('#22032 pass 3 — the object door gives the build\'s option `visibleWhen` verdict', () => {
110+
it('needs no registry change: `validateStackExpressions` is already on the object door', () => {
111+
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
112+
});
113+
114+
for (const { body, subject } of REFUSED) {
115+
it(`⭐ LIT — an option's \`visibleWhen: ${body}\` is REFUSED, located at the option the author edits`, () => {
116+
const result = gateObject(fxOption(body));
117+
118+
expect(result.rulesRun).toContain('validateStackExpressions');
119+
const errs = expressionFindings(result.errors);
120+
expect(errs, dump(result)).toHaveLength(1);
121+
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE });
122+
expect(errs[0]!.message).toContain(subject);
123+
});
124+
}
125+
126+
for (const body of ACCEPTED) {
127+
it(`⭐ CONTROL — an option's \`visibleWhen: ${body}\` still publishes clean, and the build agrees`, () => {
128+
const result = gateObject(fxOption(body));
129+
130+
expect(result.rulesRun).toContain('validateStackExpressions');
131+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
132+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
133+
// Clean at both doors, not only this one.
134+
expect(buildFindings(fxOption(body))).toEqual([]);
135+
});
136+
}
137+
138+
it('⭐ CONTRAST — `current_user` is accepted on the option and refused on the field\'s own slot, at both doors', () => {
139+
const role = "'org_admin' in current_user.positions";
140+
const body = fxOption(role, role);
141+
const atBuild = buildFindings(body);
142+
const atDoor = expressionFindings(gateObject(body).errors);
143+
144+
// The field-rule slot's root verdict, and nothing at the option.
145+
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual(["object 'fx_option' · field 'name' visibleWhen"]);
146+
expect(atBuild[0]!.message).toContain('reads `current_user`');
147+
expect(atDoor, dump(atDoor)).toEqual(atBuild);
148+
});
149+
150+
it('⭐ PARITY — for each refused body the door findings ARE the build findings', () => {
151+
for (const { body } of REFUSED) {
152+
const atBuild = buildFindings(fxOption(body));
153+
const atDoor = expressionFindings(gateObject(fxOption(body)).errors);
154+
155+
// Non-vacuous: the build refuses each of them.
156+
expect(atBuild.length, body).toBeGreaterThan(0);
157+
expect(atDoor, body).toEqual(atBuild);
158+
}
159+
});
160+
161+
it('a stored sibling\'s broken options are not this write\'s to answer for (the differential)', () => {
162+
const sibling = {
163+
...fxOption(REFUSED[0].body),
164+
name: 'fx_sibling',
165+
fields: {
166+
...fxOption(REFUSED[0].body).fields,
167+
grade: {
168+
type: 'select',
169+
label: 'Grade',
170+
options: REFUSED.map(({ body }, i) => ({ label: `G${i}`, value: `g${i}`, visibleWhen: body })),
171+
},
172+
},
173+
};
174+
// Non-vacuous: the sibling is refused at the build.
175+
expect(buildFindings(sibling).length).toBeGreaterThan(0);
176+
177+
const result = gateObject(fxOption(ACCEPTED[0]), [sibling]);
178+
179+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
180+
});
181+
});

0 commit comments

Comments
 (0)