Repository navigation
Commit b1f7a7a
Fixes #22274
Clause-②: no (narrowing: a select option's `visibleWhen` that reads an
unbound member of a bound root is refused at build and at the object
save door)
## What changes
A select option's `visibleWhen` is a gate the server enforces on write.
The server's option check (`evaluateOptionVisibility` in
`packages/objectql/src/validation/rule-validator.ts`) binds `record`,
`previous` and the acting user. Under `ctx` and `os` it binds only their
`user` member: it passes no organization and no environment. The root
verdict from the sibling card #22157 accepted `ctx` and `os` as whole
roots, so an option predicate reading `os.org.id`, `os.env` or
`ctx.locale` passed `os build` and the object save door. At write time
each one faulted and the value was admitted.
- **The option verdict now judges members too.**
`optionVisibleWhenRootIssue` in
`packages/lint/src/validate-expressions.ts` keeps its root test. When no
unbound root is read, it hands off to `optionVisibleWhenMemberIssue`,
which checks each member read under `ctx` and `os` against
`OPTION_VISIBLE_WHEN_BOUND_MEMBERS` (`{ ctx: ['user'], os: ['user'] }`).
Any other member is refused at `error`, at the option slot, one finding
per option.
- **The members are read by the platform's own reader.**
`@objectstack/formula`'s `analyzeRelationshipTraversals` reads the
member. So `os.org`, `os.?org`, `os['org']` and `has(os.org)` count as
one read. A computed key (`os[k]`) names no member and is not judged.
- **The message names what IS bound.** It names the member and says that
under that root the option check binds the `user` member and nothing
else. Then it gives a remedy for that member:
- `os.org`: compare `current_user.organizationId`. The engine builds the
acting user with the caller's organization id (`null` outside one), so
that fact IS bound at the option check. Measured: it evaluates there, a
clean `true` admits the value and a clean `false` refuses it.
- `os.env`: the option check has no environment. Gate on a column or on
`current_user`.
- Any other member, such as `ctx.locale`: rewrite against `record` /
`previous` or `current_user`.
- **One pass, two doors.** The object save door runs this same pass, so
the door's finding is the build's finding. No second judge.
- **The runtime is unchanged (ruling).** `evaluateOptionVisibility`
binds no `org` or `env`, and its fault-open stays as it is. That
behaviour is `domain:engine`'s question.
- **A stale docblock is corrected.** The field-rule verdict's docblock
said the option surface "binds the whole `os` namespace". That is false
at the server, and this change made it contradict the new verdict.
Comment only.
## The dispatch's premises, measured
- **P1 held.** Through the built `@objectstack/objectql`
(`evaluateValidationRules`, insert, authenticated caller `{ id,
positions, organizationId }`, permissions passed), at base `bb4f5cc005`:
- `os.org.id != ''` was admitted, with `predicate-fault` / `No such key:
org`.
- `os.env == 'prod'` was admitted, with `predicate-fault` / `No such
key: env`.
- `ctx.locale == 'en'` was admitted, with `predicate-fault` / `No such
key: locale`.
- Controls: `os.user.id != ''`, `ctx.user.id != ''`, `current_user.id !=
''`, `user.id != ''`, `record.x == 'a'` and `current_user.can('fx',
'edit')` evaluated cleanly. `os.user.id == 'nobody'` was refused
`VALIDATION_FAILED` (`option 'gold' is not available`), which shows the
gate runs.
- **P2 held: no corpus hit, so no fork.**
- Corpus A: every git-tracked `*.object.ts` under `packages/**` and
`examples/**`, plus the two `app-multi-package` sub-stacks. At base
`bb4f5cc005` that is 112 files and 119 objects. At merged head
`4e0f473df6` it is 111 files and 118 objects, because `origin/main`
`117d34de3f` retired one. There were 0 import or parse failures.
- Corpus B: the example stacks as `defineStack` composes them, 33
objects.
- Both carry the same 5 option predicates, all on `showcase_cascade`.
Their roots are `record` x4 and `current_user` x1. The members read
under `os` and `ctx` are none, at base and at head.
- Option findings were 0 at the build and 0 at the door, at both trees.
- A tree-wide text grep for `visibleWhen` with an `os.` or `ctx.` member
read found no option predicate. It found 5 lines: a lint test fixture, a
page `visibleWhen`, and three spec `.describe()` strings.
- **P3 held.** The allowlist mirrors three things in code:
- the one call `evaluateOptionVisibility` makes,
`ExpressionEngine.evaluate(expr, { record: merged, previous, user,
permissions })`;
- `@objectstack/formula`'s `buildScope`, which from that context mounts
`ctx = { user }` and `os = { user }`, and mounts `os.org` / `os.env`
only from an `org` / `env` in the context;
- ADR-0068 D1's aliases (`user`, `ctx.user`, `os.user` are the same
`EvalUser`).
- `current_user` and `user` ARE the `EvalUser`, and its members are the
same at every site, so they are not in the member map. That is also why
`ctx.user.positions` is not judged here.
- **How drift surfaces:** a new lint test drives the real `buildScope`
and `ExpressionEngine.evaluate` with the option check's context. Every
member mounted under `ctx` / `os` must be accepted and evaluate. Every
member mounted only when an `org` and `env` are ALSO given must be
refused and fault. The exact accepted/refused lists are pinned. So
`buildScope` mounting a new member there, or dropping `user`, turns it
red.
- **Not caught mechanically:** a change to ObjectQL's call shape, such
as the option check starting to pass `org`. `@objectstack/lint` cannot
depend on ObjectQL. That direction rests on the constant's docblock rule
(a member joins the list in the same change that binds it) and on
ObjectQL's own `USER_SCOPE_ROOTS` docblock, which states the same
exactness claim. This is named in the report.
## Pins (Zone 3)
- **Build side** (`validate-expressions.test.ts`, new describe
`#22274`):
- `os.org.id != ''`, `os.env == 'prod'` and `ctx.locale == 'en'` are
each refused at `error`, at the option slot. The message names the
option, the field, the member path, and the `user` member as what is
bound.
- The `os.org` refusal names `current_user.organizationId`. That
replacement passes the build, and it evaluates `true` in the option
check's context.
- CONTROL: `current_user.id`, `os.user.id`, `'org_admin' in
ctx.user.positions`, `user.id`, `record.x`, `previous.x`,
`current_user.can(...)`, and a `record` field spelled like a refused
member (`record.locale`) all pass.
- POSITIVE CONTROL: the same `os.org.id != ''` as a `formula` field's
`expression`, a site whose evaluator binds `os.org`
(`applyFormulaPlan`), is not refused. So the refusal belongs to the
option slot, not to every slot.
- Every member spelling is judged as one read: `has(os.org)`, `os.?org`,
`os['org']` and `has(ctx.locale)`.
- One finding per option. An unbound root wins over a member, and
members are ordered by `SCOPE_ROOTS`.
- The `buildScope` parity test (P3 above).
- **Door side** (`protocol.runtime-authoring-gate.test.ts`, new `#22274`
block, through the real `saveMetaItem`):
- (a) Each of the three bodies: a publish save answers 422
`INVALID_METADATA` with one `expression-invalid` issue at the option
that names the member and the bound `user` member, and nothing lands.
- (b) Control: the eight accepted bodies save and land `active`.
- (c) PARITY: `rule`, `where`, `path`, `message` and `hint` are equal at
the door and at the build, for each body.
## Reverse verification (ablation)
The run was made from the committed head `f270d45a7e` through
`scripts/ablation-replace.mjs` in WRAP mode, with an outer `trap`
restore on EXIT, INT and TERM against the absolute path. The restore was
checked by comparing the file's blob hash with the HEAD blob.
- **Mutation.** The member arm's call was gated on `Reflect.has(Object,
"ablation22274")`, which is always false. The anchor went x1 to x0, and
the blob went `256380b4d7c8` to `057b663b2c3a`. On disk, the anchor
count was 0 and the marker count was 1.
- **Prediction, recorded before the run.**
- Lint: 7 red. They are the three refusals, the replacement test, the
spelling test, the second half of the ordering test, and the parity
test. CONTROL, POSITIVE CONTROL and every other test stay green.
- Protocol: 6 red, (a) x3 and (c) x3. (b) and every other block stay
green.
- **Observed.**
- Lint `src/validate-expressions.test.ts`: 7 failed, 360 passed, the
predicted seven.
- `@objectstack/lint` was then rebuilt. `ablation-dist-preflight` found
the marker in 4 built files (`index.js`, `index.cjs`, `runtime.js`,
`runtime.cjs`).
- Protocol: 6 failed, 120 passed, the predicted six.
- **Restore.** The blob is `256380b4d7c8`, equal to HEAD, and `git diff
HEAD` is empty. After a rebuild, `--absent` found the marker gone from
all 20 built files and the whole tree clean. Lint went back to 367 of
367, and the protocol file to 126 of 126.
## Local verification (at `65ac7df278`, after merging `origin/main`
`117d34de3f`)
- The merge brought one commit that touches
`@objectstack/metadata-protocol`, `metadata-core`, `metadata`,
`platform-objects` and `spec`. None of this PR's files changed. After
`pnpm install --frozen-lockfile` and a rebuild of the
`@objectstack/metadata-protocol...` closure, both touched packages were
re-run in full.
- `pnpm --filter @objectstack/lint test`: 128 files, 5871 tests passed.
The full `test` task, `vitest run`, is one project.
- `pnpm --filter @objectstack/metadata-protocol test`: 223 files passed
and 3 skipped. 28325 tests passed and 19 skipped. All the skips were
there before this change.
- `pnpm --filter @objectstack/lint typecheck`: `tsc --noEmit` passed,
and `check:test-typecheck` was OK, with `validate-expressions.test.ts`
carrying no debt entry.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: OK. `tsc
--listFiles` includes the protocol test file.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `65ac7df278` derived the same 63 commands
as at claim time.
- 62 exited 0.
- `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET:
packages unrelated to this diff have no `dist/` in the local worktree.
**NOT MEASURED: dual-build-cjs-loads, reason: prerequisite not met
locally; CI builds the full tree.**
- `--ran`, with exit codes recorded: 63 derived, 62 run, 1 NOT-MEASURED
(derived from the recorded exit 3), 0 UNRUN.
- ESLint, narrowed to the 3 changed `.ts` files with `--no-inline-config
--format json`: 3 files, 0 errors, 0 warnings.
- The population was read from `eslint.config.mjs`:
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, minus `NEVER_LINTED` and the
`packages/spec/**` ignores.
- That config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot change the verdict on any
file it does not touch.
- The repo-wide `pnpm lint` is CI's.
## Grade and changeset
- `.changeset/22274-option-visible-when-members.md` lists
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`.
`metadata-protocol` was added in patch round 1 at `dffc512cf2`, per
contract review `6072804991`: the BREAKING section names that package's
doors. It has the `fix(lint)!` prefix, the Clause-② line above, a
BREAKING section with the remedy, and the ADR-0087 disposition
`not-required (no-migration-prescription)`.
- `check-adr-0087-registration` reads it as
`[BREAKING+bang+clause-②-narrowing]`.
- `check-changeset-no-major` and `check-empty-changeset` are green.
- No export or signature moves. The new constant and the two helpers are
module-private.
## File surface
All four files are inside the claim's surface:
- `packages/lint/src/validate-expressions.ts`
- `packages/lint/src/validate-expressions.test.ts`
-
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`
- `.changeset/22274-option-visible-when-members.md`
`rule-validator.ts` was read, not edited. The diff is +412/-7 lines
against the merge base `117d34de3`.
## Acceptance notes
- **Deviation from the dispatch's pin list.** Zone 3 lists
`ctx.user.roles` as still accepted. It IS accepted by this verdict,
which stops at the first member. But measured through the built engine,
`ctx.user.roles == ['a']` and `'admin' in current_user.roles` fault with
`No such key: roles`: ADR-0090 D3 renamed `roles` to `positions`.
Pinning it as an accepted case would endorse a gate that is never
enforced, so the control pins `'org_admin' in ctx.user.positions`
instead. The `EvalUser` member level is reported to the PM as the next
finding in this family.
- **Residual reachable paths to the fault-open, reported to the PM for
the card (`domain:engine`'s question), not fixed here.** Measured:
`os['o' + 'rg'].id != ''` passes the build with 0 findings, because a
computed key names no member, and the built engine admits it with
`predicate-fault` / `No such key: org`. Not measured: rows stored before
this change, writes under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`, and the
`EvalUser` member level above.
- **Two-step prescription, noted.** The field-rule verdict's user tier
tells an author with a field-level `os.org.id` predicate to move it to
an option's `visibleWhen`. There it now meets this refusal, which names
`current_user.organizationId`. The author gets there in two steps, and
no message is false. Carrier: none.
- **Docs, noted.** The variable-scope table in
`content/docs/data-modeling/formulas.mdx` lists `os.org` / `os.env` as
available in "predicates". That is broader than what the option check
binds. Carrier: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a55fdc4 commit b1f7a7a
4 files changed
Lines changed: 414 additions & 7 deletions
File tree
- .changeset
- packages
- lint/src
- metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
2088 | 2088 | | |
2089 | 2089 | | |
2090 | 2090 | | |
| 2091 | + | |
| 2092 | + | |
| 2093 | + | |
| 2094 | + | |
| 2095 | + | |
| 2096 | + | |
| 2097 | + | |
| 2098 | + | |
| 2099 | + | |
| 2100 | + | |
| 2101 | + | |
| 2102 | + | |
| 2103 | + | |
| 2104 | + | |
| 2105 | + | |
| 2106 | + | |
| 2107 | + | |
| 2108 | + | |
| 2109 | + | |
| 2110 | + | |
| 2111 | + | |
| 2112 | + | |
| 2113 | + | |
| 2114 | + | |
| 2115 | + | |
| 2116 | + | |
| 2117 | + | |
| 2118 | + | |
| 2119 | + | |
| 2120 | + | |
| 2121 | + | |
| 2122 | + | |
| 2123 | + | |
| 2124 | + | |
| 2125 | + | |
| 2126 | + | |
| 2127 | + | |
| 2128 | + | |
| 2129 | + | |
| 2130 | + | |
| 2131 | + | |
| 2132 | + | |
| 2133 | + | |
| 2134 | + | |
| 2135 | + | |
| 2136 | + | |
| 2137 | + | |
| 2138 | + | |
| 2139 | + | |
| 2140 | + | |
| 2141 | + | |
| 2142 | + | |
| 2143 | + | |
| 2144 | + | |
| 2145 | + | |
| 2146 | + | |
| 2147 | + | |
| 2148 | + | |
| 2149 | + | |
| 2150 | + | |
| 2151 | + | |
| 2152 | + | |
| 2153 | + | |
| 2154 | + | |
| 2155 | + | |
| 2156 | + | |
| 2157 | + | |
| 2158 | + | |
| 2159 | + | |
| 2160 | + | |
| 2161 | + | |
| 2162 | + | |
| 2163 | + | |
| 2164 | + | |
| 2165 | + | |
| 2166 | + | |
| 2167 | + | |
| 2168 | + | |
| 2169 | + | |
| 2170 | + | |
| 2171 | + | |
| 2172 | + | |
| 2173 | + | |
| 2174 | + | |
| 2175 | + | |
| 2176 | + | |
| 2177 | + | |
| 2178 | + | |
| 2179 | + | |
| 2180 | + | |
| 2181 | + | |
| 2182 | + | |
| 2183 | + | |
| 2184 | + | |
| 2185 | + | |
| 2186 | + | |
| 2187 | + | |
| 2188 | + | |
| 2189 | + | |
| 2190 | + | |
| 2191 | + | |
| 2192 | + | |
| 2193 | + | |
| 2194 | + | |
| 2195 | + | |
| 2196 | + | |
| 2197 | + | |
| 2198 | + | |
| 2199 | + | |
| 2200 | + | |
| 2201 | + | |
| 2202 | + | |
| 2203 | + | |
| 2204 | + | |
| 2205 | + | |
| 2206 | + | |
| 2207 | + | |
| 2208 | + | |
| 2209 | + | |
| 2210 | + | |
| 2211 | + | |
| 2212 | + | |
| 2213 | + | |
| 2214 | + | |
| 2215 | + | |
| 2216 | + | |
| 2217 | + | |
| 2218 | + | |
| 2219 | + | |
| 2220 | + | |
| 2221 | + | |
| 2222 | + | |
| 2223 | + | |
| 2224 | + | |
| 2225 | + | |
| 2226 | + | |
| 2227 | + | |
2091 | 2228 | | |
2092 | 2229 | | |
2093 | 2230 | | |
| |||
0 commit comments