Repository navigation
Commit 46692c1
Fixes #22394
Clause-②: no (narrowing: a select option's `visibleWhen` that reads a
member of the acting user (`EvalUser`) the option check does not bind —
whether the schema does not declare it (`roles`) or declares it but the
option check never sets it (`name`, `email`) — 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 the acting
user under four spellings: `current_user` and its ADR-0068 aliases
`user`, `ctx.user` and `os.user`. All four are one `EvalUser` object.
The family's first two cards refused an unbound root (#22157) and an
unbound member of `ctx` / `os` (#22274). Neither judged the members of
the acting user itself. So `'admin' in current_user.roles`,
`ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os
build` and the object save door with 0 findings. At write time each one
faulted and the value was admitted.
- **The option verdict now judges the acting user's members.**
`optionVisibleWhenMemberIssue` in
`packages/lint/src/validate-expressions.ts` judges the first member of
`current_user` / `user`, and the member one hop below `ctx.user` /
`os.user`. Each is held to one allowlist. Any other member is refused at
`error`, at the option slot, one finding per option.
- **The allowlist is derived, never written out**
(`optionVisibleWhenUserMembers`, computed on first use). It is the
members that are BOTH:
- **declared:** the keys of `EvalUserSchema` (`@objectstack/spec`,
imported from `@objectstack/spec/identity`, an existing export);
- **bound:** the keys of the object `@objectstack/formula`'s
`buildScope` mounts as `current_user` for the acting user ObjectQL's
`buildEvalUser` hands over (`{ id, positions, organizationId }`).
- Today that is `id`, `positions`, `isPlatformAdmin` and
`organizationId`.
- **The members are read by the platform's own reader**,
`analyzeRelationshipTraversals`, as for #22274. So `current_user.roles`,
`current_user.?roles`, `current_user['roles']`,
`has(current_user.roles)`, `ctx.user['roles']` and `ctx['user'].roles`
are one read. A computed key names no member and is not judged.
- **The message names what IS bound.** It names the member and the four
members the acting user carries there, then gives a remedy:
- `roles` / `role`: ADR-0090 D3 renamed the array `positions`, with no
alias. The remedy is written in the author's own spelling, for example
`'NAME' in ctx.user.positions`.
- `name`, `email`: declared on `EvalUser`, but the server builds the
user for this check from `id`, `positions` and `organizationId` only.
Gate on a bound member or a column.
- Anything else: not a member of `EvalUser`. Gate on a bound member or a
column.
- **One pass, two doors.** The object save door runs this same pass, so
the door's finding is the build's finding.
- **The runtime is unchanged (ruling).** The fault-open of
`evaluateOptionVisibility`, computed keys and computed receivers stay
with #22402.
- **Refactor, no behaviour change:** the `ctx` / `os` refusal moved into
its own helper (`optionVisibleWhenNamespaceMemberIssue`). Its message
bytes are unchanged; the #22274 pins and the door parity pins pass
unchanged.
## The enumeration pin: every receiver the option slot binds, and its
member source
This card closes the #22157 → #22274 family, so every receiver is listed
with the source its members are judged against. The receivers are read
off the REAL `buildScope` given the option check's context, `{ record,
previous, user, permissions }`:
| Receiver | Member source | Judged by |
|---|---|---|
| `record` | the object's declared fields | `validateExpression`'s
`unknown field` check (field index) |
| `previous` | the object's declared fields | the same check |
| `ctx` | `buildScope`: the `user` member only |
`OPTION_VISIBLE_WHEN_BOUND_MEMBERS` (#22274) |
| `os` | `buildScope`: the `user` member only |
`OPTION_VISIBLE_WHEN_BOUND_MEMBERS` (#22274) |
| `current_user` | `EvalUserSchema`, as far as the option check binds it
| `optionVisibleWhenUserMembers` (this PR) |
| `user` | the same | the same |
| `ctx.user` | the same | the same |
| `os.user` | the same | the same |
`permissions` mounts no receiver: it answers `current_user.can(OBJECT,
VERB)` through the evaluator's environment (#18783), and `can` is a
call, not a member read.
The test `every receiver the option check binds judges its members
against a declared source` walks the real `buildScope` output and
asserts:
- the receiver set equals this table, so a new receiver with no row is
red;
- for every receiver, an undeclared member (`RECEIVER.zz_undeclared`) is
refused by the build and faults in the evaluator;
- no member is mounted on the acting user without an `EvalUserSchema`
declaration;
- for every declared member, the build accepts it exactly when the
option check mounts it, and evaluation agrees.
The readings are pinned: declared `id, name, email, positions,
isPlatformAdmin, organizationId`; accepted under `current_user` `id,
positions, isPlatformAdmin, organizationId`.
## The dispatch's premises, measured
- **P1 held.** At base `abd254508b`:
- Runtime, through the built `@objectstack/objectql`
(`evaluateValidationRules`, insert, authenticated caller `{ id,
positions, organizationId }`, permissions passed): each of these was
admitted with `predicate-fault`:
- `'admin' in current_user.roles`, `ctx.user.roles == ['a']`, `'admin'
in user.roles` and `'admin' in os.user.roles` (`No such key: roles`);
- `current_user.role == 'admin'` (`No such key: role`);
- `current_user.email == 'a@b.c'` (`No such key: email`) and
`current_user.name != ''` (`No such key: name`);
- `current_user['roles'] == ['a']` (`No such key: roles`).
- `has(current_user.roles)` was refused on every write (a clean
`false`), and `!has(current_user.roles)` was admitted on every write.
- Controls evaluated cleanly: `'member' in current_user.positions`,
`'member' in ctx.user.positions`, `user.id != ''`,
`os.user.organizationId == 'org_1'` and `current_user.isPlatformAdmin ==
false`. `current_user.id == 'nobody'` and `current_user.can('fx',
'edit')` (empty permission map) were refused `VALIDATION_FAILED`, which
shows the gate runs.
- Build: the built `validateStackExpressions` gave 0 findings for all
nine bodies.
- Door: the new door block, run against the base `@objectstack/lint`
build, went red exactly on (a) x3 and (c) x3, with `the save resolved —
the door still accepts the option predicate`; (b) stayed green.
- **P2 held, with one difference recorded.** No new export was needed:
`EvalUserSchema` is already exported from `@objectstack/spec` and
`@objectstack/spec/identity`.
- Declared but not bound: `name` and `email`. The engine never passes
them, so they fault (P1). **The option check wins: they are refused.**
This is wider than the Clause-② line's wording ("the schema does not
declare"); see Acceptance notes.
- Bound but not declared: none. `can` is bound through the permissions
source, but it is a receiver call, not a member, so it never enters the
allowlist. The enumeration pin holds this set empty.
- **P3 held: no corpus hit, so no fork.** At base `abd254508b`, and
again on this branch's built `@objectstack/lint`:
- Corpus: every git-tracked `*.object.ts` under `packages/**` and
`examples/**`, plus the two `app-multi-package` sub-stacks. That is 113
files and 118 objects, with 0 import failures.
- It carries 5 option predicates, all on `showcase_cascade`. Their roots
are `record` x4 and `current_user` x1. The one acting-user member read
is `current_user.positions`, which is bound.
- Option findings were 0 at base and 0 on this branch.
- Positive control: the same built verdict on this branch gives 1
finding for each of the nine P1 bodies.
- A tree-wide grep for option predicates reading `roles`, `role`,
`email` or `name` under a user receiver found none. The
`current_user.email` hits are row-level security `using` / `check`
predicates and a page `visibleWhen`, surfaces this verdict does not
judge.
- **P4: not touched.** The derivation reads `EvalUserSchema` and
`buildScope`, never `EvaluateRulesOptions.currentUser`
(`@objectstack/lint` cannot depend on ObjectQL). `packages/objectql` is
not in this diff, so no cross-lane declaration is owed.
- **Not caught mechanically:** `OPTION_CHECK_ACTING_USER` mirrors the
key set of ObjectQL's `buildEvalUser` return value. This is the one fact
the allowlist cannot read from code. Its docblock carries the rule that
it changes in the same change as `buildEvalUser`, as
`OPTION_VISIBLE_WHEN_BOUND_MEMBERS` does for the call shape.
## Pins
- **Build side** (`validate-expressions.test.ts`, new describe
`#22394`):
- Seven bodies are each refused at `error`, at the option slot: `roles`
under all four spellings, `current_user.role`, `current_user.email` and
`ctx.user.name`. The message names the member path and the four bound
members, and the evaluator faults on each body in the option check's
context.
- The `roles` refusal names `RECEIVER.positions` in the author's
spelling. That replacement passes the build and evaluates `true`.
- CONTROL: every bound member under every spelling, `RECEIVER.can(...)`,
and a `record` field named `roles` all pass, and each evaluates.
- POSITIVE CONTROL: the same `current_user.email` in an object action's
`visible`, a surface this refusal does not judge, is not refused.
- Spellings: `has()`, `.?`, `['roles']`, `ctx['user'].roles` and
`has(os.user.email)` are each one finding.
- Ordering: an unbound root first, then a namespace member, then
`SCOPE_ROOTS` order (`os` before `current_user`), then name order.
- The enumeration pin above.
- The `#5017` declared-key meta-test registers five new local names
(`declaredUserMembers`, `boundUserMembers`, `listedNames`,
`tickedNames`, `membersRead`). Each is a `string[]` of member names,
named to stay clear of metadata receivers.
- **Door side** (`protocol.runtime-authoring-gate.test.ts`, new `#22394`
block, through the real `saveMetaItem`):
- (a) For each of the three bodies, a publish save answers 422
`INVALID_METADATA` with one `expression-invalid` issue at the option.
The issue names the member and the bound members, and nothing lands.
- (b) Control: six accepted bodies save and land `active`.
- (c) PARITY: `rule`, `where`, `path`, `message` and `hint` are equal at
the door and at the build.
## Reverse verification (ablation)
The run was made from the committed head `f49bdb2fc8`. It went through
`scripts/ablation-replace.mjs` (HOLD mode, `--expect 2`) inside a script
with a `trap` restore on EXIT, INT and TERM against the absolute path.
- **Mutation.** Both acting-user checks,
`!optionVisibleWhenUserMembers().includes(m))`, were gated on
`Reflect.has(Object, "ablation22394")`, which is always false. The
anchor went x2 to x0 and the marker x0 to x2. The blob went
`7343ff874edd` to `b85f0f7ad0d3`.
- **Prediction, recorded before the run.**
- Lint: 11 red. They are the 7 refusals, the `roles` remedy, the
spellings, the ordering and the enumeration pin.
- Protocol: 6 red, (a) x3 and (c) x3.
- Everything else green, including every #22274 and #22157 pin.
- **Observed.**
- Lint `src/validate-expressions.test.ts`: 11 failed and 369 passed, the
predicted eleven.
- `@objectstack/lint` was then rebuilt. `ablation-dist-preflight` found
the marker in 4 built files (`index.js`, `index.cjs`, `runtime.js`,
`runtime.cjs`).
- Protocol `src/protocol.runtime-authoring-gate.test.ts`: 6 failed and
127 passed, the predicted six.
- **Restore.** The blob is back at `7343ff874edd`, 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
380 of 380, and the protocol file to 133 of 133.
## Local verification
Measured at `f49bdb2fc8`, the head before merging `origin/main`.
- **The `origin/main` merge** (`1d8b879dd5`) brought 3 commits. They
touch `packages/runtime`, `packages/verify`, `packages/qa/dogfood`,
docs, a changeset, and five `packages/spec/src/**/*.test.ts` files (test
titles only).
- None of them is in this diff's packages or its build closure
(`@objectstack/lint` → `formula`, `spec`, `sdui-parser`;
`@objectstack/metadata-protocol`).
- The spec side moved only in test files, which no build or generator
reads. So the suites below were not re-run after the merge. This
narrowing is declared; CI runs the full set.
- `pnpm --filter @objectstack/lint test`: 128 files and 5894 tests
passed.
- `pnpm --filter @objectstack/lint typecheck`: `tsc --noEmit` passed,
and `check:test-typecheck` was OK. `tsc -p tsconfig.test.json
--listFiles` includes `validate-expressions.test.ts`.
- `pnpm --filter @objectstack/metadata-protocol test`: 223 files passed
and 3 skipped. 28332 tests passed and 19 skipped. The skips were there
before this change.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: OK. `tsc
--listFiles` includes the protocol test file.
- Gates, at the merged head `1d8b879dd5`:
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived the same 63 commands as at claim
time (`abd254508b`). 62 exited 0.
- `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET:
packages unrelated to this diff have no `dist/` locally. **NOT MEASURED:
dual-build-cjs-loads, reason: prerequisite not met locally; CI builds
the full tree.**
- In its place, the one package whose bundle changed was loaded
directly. A `require` of `@objectstack/lint`'s `dist/index.cjs` and
`dist/runtime.cjs` succeeded, so the new `@objectstack/spec/identity`
import resolves under CJS, and the CJS `validateStackExpressions` gave
the refusal (1 finding).
- `--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.
- **Example apps, declared narrowing.** The four example apps build with
`objectstack build`, which runs this verdict. They were not built
locally. Their objects are in the P3 corpus above, with 0 option
findings on this branch's built `@objectstack/lint`. CI builds them.
## Grade and changeset
- `.changeset/22394-option-visible-when-evaluser-members.md` lists
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`, as
for #22274, since the BREAKING section names that package's doors.
- It has the `fix(lint)!` prefix, the Clause-② line above, a BREAKING
section with the remedy (`roles` → `positions`), 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 constants and 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/22394-option-visible-when-evaluser-members.md`
`rule-validator.ts` and `engine.ts` were read, not edited. The diff is
+631/-49 lines against `origin/main`.
## Acceptance notes
- **Declared-but-unbound members are refused too.** The Clause-② line
(copied from the claim) says the refusal covers members "the schema does
not declare". Following the dispatch's P2 (the allowlist is what is both
declared AND bound), this PR also refuses `name` and `email`.
`EvalUserSchema` declares them, but the option check never sets them,
and P1 measured both faulting open. The changeset's BREAKING list names
them. If the seat wants the Clause-② line to say so, the wording is the
seat's to amend.
- **Residual reachable paths to the fault-open, not this card's
(carrier: #22402).** A computed key (`current_user[k]`, `os['o' +
'rg']`), a computed receiver (`[os].all(o, o.org.id != '')`), rows
stored before this change, and `OS_ALLOW_UNLINTED_METADATA_WRITES=1`
writes are not judged statically. The fault-open itself is #22402's
question.
- **The `record` / `previous` row of the enumeration has a spelling gap,
reported to the seat as its own finding.** Their member source is the
`unknown field` check in `@objectstack/formula`'s `validateExpression`,
and that check reads `record.FIELD` / `previous.FIELD` with a regex. So
`record['zz_typo'] == 'a'` and `previous['zz_typo'] == 'a'` give 0
findings at the build. Through the built engine, the option check then
faults (`No such key: zz_typo`) and admits the value. The same spelling
also passes a field `requiredWhen` and a validation `condition` at the
build. The fix belongs in formula's check, which covers every
record-scoped slot, not in this option-only verdict. The enumeration pin
probes the dot spelling, which the check does see.
- **`EvaluateRulesOptions.currentUser`** (`rule-validator.ts`) still
types `roles?`. It does not feed this allowlist, so it is untouched
here. Carrier: #22402, which works in that file.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9222dc commit 46692c1
4 files changed
Lines changed: 631 additions & 49 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 | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
| |||
2225 | 2226 | | |
2226 | 2227 | | |
2227 | 2228 | | |
| 2229 | + | |
| 2230 | + | |
| 2231 | + | |
| 2232 | + | |
| 2233 | + | |
| 2234 | + | |
| 2235 | + | |
| 2236 | + | |
| 2237 | + | |
| 2238 | + | |
| 2239 | + | |
| 2240 | + | |
| 2241 | + | |
| 2242 | + | |
| 2243 | + | |
| 2244 | + | |
| 2245 | + | |
| 2246 | + | |
| 2247 | + | |
| 2248 | + | |
| 2249 | + | |
| 2250 | + | |
| 2251 | + | |
| 2252 | + | |
| 2253 | + | |
| 2254 | + | |
| 2255 | + | |
| 2256 | + | |
| 2257 | + | |
| 2258 | + | |
| 2259 | + | |
| 2260 | + | |
| 2261 | + | |
| 2262 | + | |
| 2263 | + | |
| 2264 | + | |
| 2265 | + | |
| 2266 | + | |
| 2267 | + | |
| 2268 | + | |
| 2269 | + | |
| 2270 | + | |
| 2271 | + | |
| 2272 | + | |
| 2273 | + | |
| 2274 | + | |
| 2275 | + | |
| 2276 | + | |
| 2277 | + | |
| 2278 | + | |
| 2279 | + | |
| 2280 | + | |
| 2281 | + | |
| 2282 | + | |
| 2283 | + | |
| 2284 | + | |
| 2285 | + | |
| 2286 | + | |
| 2287 | + | |
| 2288 | + | |
| 2289 | + | |
| 2290 | + | |
| 2291 | + | |
| 2292 | + | |
| 2293 | + | |
| 2294 | + | |
| 2295 | + | |
| 2296 | + | |
| 2297 | + | |
| 2298 | + | |
| 2299 | + | |
| 2300 | + | |
| 2301 | + | |
| 2302 | + | |
| 2303 | + | |
| 2304 | + | |
| 2305 | + | |
| 2306 | + | |
| 2307 | + | |
| 2308 | + | |
| 2309 | + | |
| 2310 | + | |
| 2311 | + | |
| 2312 | + | |
| 2313 | + | |
| 2314 | + | |
| 2315 | + | |
| 2316 | + | |
| 2317 | + | |
| 2318 | + | |
| 2319 | + | |
| 2320 | + | |
| 2321 | + | |
| 2322 | + | |
| 2323 | + | |
| 2324 | + | |
| 2325 | + | |
| 2326 | + | |
| 2327 | + | |
| 2328 | + | |
| 2329 | + | |
| 2330 | + | |
| 2331 | + | |
| 2332 | + | |
| 2333 | + | |
| 2334 | + | |
| 2335 | + | |
| 2336 | + | |
| 2337 | + | |
| 2338 | + | |
| 2339 | + | |
| 2340 | + | |
| 2341 | + | |
| 2342 | + | |
| 2343 | + | |
| 2344 | + | |
| 2345 | + | |
| 2346 | + | |
| 2347 | + | |
| 2348 | + | |
| 2349 | + | |
| 2350 | + | |
| 2351 | + | |
| 2352 | + | |
| 2353 | + | |
| 2354 | + | |
| 2355 | + | |
| 2356 | + | |
| 2357 | + | |
| 2358 | + | |
| 2359 | + | |
| 2360 | + | |
| 2361 | + | |
| 2362 | + | |
| 2363 | + | |
| 2364 | + | |
| 2365 | + | |
| 2366 | + | |
| 2367 | + | |
| 2368 | + | |
| 2369 | + | |
| 2370 | + | |
| 2371 | + | |
| 2372 | + | |
| 2373 | + | |
| 2374 | + | |
| 2375 | + | |
| 2376 | + | |
| 2377 | + | |
| 2378 | + | |
| 2379 | + | |
| 2380 | + | |
| 2381 | + | |
| 2382 | + | |
| 2383 | + | |
| 2384 | + | |
| 2385 | + | |
| 2386 | + | |
| 2387 | + | |
| 2388 | + | |
| 2389 | + | |
| 2390 | + | |
| 2391 | + | |
| 2392 | + | |
| 2393 | + | |
| 2394 | + | |
| 2395 | + | |
| 2396 | + | |
| 2397 | + | |
| 2398 | + | |
| 2399 | + | |
| 2400 | + | |
| 2401 | + | |
| 2402 | + | |
| 2403 | + | |
| 2404 | + | |
| 2405 | + | |
| 2406 | + | |
| 2407 | + | |
| 2408 | + | |
| 2409 | + | |
| 2410 | + | |
| 2411 | + | |
| 2412 | + | |
| 2413 | + | |
| 2414 | + | |
| 2415 | + | |
| 2416 | + | |
| 2417 | + | |
| 2418 | + | |
| 2419 | + | |
| 2420 | + | |
| 2421 | + | |
| 2422 | + | |
| 2423 | + | |
| 2424 | + | |
| 2425 | + | |
| 2426 | + | |
| 2427 | + | |
| 2428 | + | |
| 2429 | + | |
| 2430 | + | |
| 2431 | + | |
| 2432 | + | |
| 2433 | + | |
| 2434 | + | |
| 2435 | + | |
| 2436 | + | |
| 2437 | + | |
| 2438 | + | |
| 2439 | + | |
| 2440 | + | |
| 2441 | + | |
2228 | 2442 | | |
2229 | 2443 | | |
2230 | 2444 | | |
| |||
3253 | 3467 | | |
3254 | 3468 | | |
3255 | 3469 | | |
| 3470 | + | |
| 3471 | + | |
| 3472 | + | |
| 3473 | + | |
| 3474 | + | |
3256 | 3475 | | |
3257 | 3476 | | |
3258 | 3477 | | |
| |||
0 commit comments