Repository navigation
Commit bb4f5cc
Fixes #22157
Clause-②: no (narrowing: `os build` and the object save door refuse a
select option's `visibleWhen` that reads `parent`, which the runtime's
option check cannot bind)
## 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, and nothing else. An option predicate
that read `parent.status == 'closed'`, on a detail with exactly one
`master_detail`, passed `os build` and the object save door. Then every
write that picked the option faulted (`Unknown variable: parent`) and
was admitted unchecked.
- **The option loop gains a root verdict.** `optionVisibleWhenRootIssue`
in `packages/lint/src/validate-expressions.ts` is an allowlist of what
the option check binds: `record`, `previous`, and the acting user as
`current_user` with its ADR-0068 aliases `user`, `ctx` and `os`. Every
other `SCOPE_ROOTS` member is refused at `error`, `parent` included. The
finding is located at the option slot (`object 'NAME' · field 'FIELD'
option 'VALUE' visibleWhen`). The message names the option, the field
and the root. For `parent` it says that only a field's own
`readonlyWhen` / `requiredWhen` binds that root, and it gives the
denormalise-onto-the-detail remedy.
- **One change covers both doors.** The object save door runs this same
pass (`runStackExpressionPasses`, since the #22032 pass-3 crossing), so
the door's finding is the build's finding.
- **The runtime is unchanged (ruling).** `evaluateOptionVisibility`
still binds no `parent`. The constant's docblock says a root joins the
list only in the change that binds it there.
- **Why the membership test is `SCOPE_ROOTS`, not the field-rule's
judged vocabulary.** The gap is exactly the set of roots the strict env
declares. A root it does not declare, such as `app`, is already refused
on this slot by the bare-reference check, so judging it here would give
one mistake two verdicts. The existing #13935 pin ("`app` there keeps
the bare-reference verdict") is untouched and green. Because the judged
roots are all declared, `check` never reports a bare reference to them,
so the two verdicts stay disjoint by construction.
## The dispatch's hypotheses, measured
- **H1 held.** At base `73a0a6bf1d`, through the built
`@objectstack/lint`, the body `parent.status == 'closed'` on a
one-master detail gave 0 findings from `runAuthoringRules('build')` and
0 door errors from `runRuntimeAuthoringRules({ type: 'object' })`. So
did `input.x == 1` and `data.x == 1`. The option loop had no root
verdict, and `parent` is a `SCOPE_ROOTS` member, so the bare-reference
check stayed silent.
- **H2 held, read off the call.** `evaluateOptionVisibility` calls
`ExpressionEngine.evaluate(expr, { record: merged, previous, user,
permissions })`. `buildScope` turns that into `record`, `previous` (when
defined) and, when the write carries a user, the same `EvalUser` under
`current_user`, `user`, `ctx.user` and `os.user`. In the expression env
the acting user is spelled `current_user` (ADR-0068 D1's canonical
root); `user`, `ctx` and `os` are aliases. `permissions` mounts no root:
it reaches `can` through the environment. The allowlist is exactly those
six bare roots, the same root-granularity reading as the runtime's own
`USER_SCOPE_ROOTS` next to the evaluator.
- **H3 held: corpus first, stop condition not met.** Corpus A is every
`*.object.ts` under `packages/**` and `examples/**` (112 files) plus the
two `app-multi-package` sub-stacks: 119 objects in 18 groups, 0 import
or parse failures. Corpus B is the example stacks as `defineStack`
composes them: 33 objects in 5 groups. Both carry the same 5 option
predicates, all on `showcase_cascade`: four `record.country` cascades
and one `'org_admin' in current_user.positions` gate. Their roots are
`record` ×4 and `current_user` ×1. Option findings: 0 at the build and 0
at the door (errors and advisories), at both shapes, at base
`73a0a6bf1d` and at head `48b9329137`. Positive control in the same
harness: the `parent` body gave build 0 and door 0 at base, and build 1
and door 1 at head.
- **H4 held.** The new protocol block was run against the base build of
`@objectstack/lint` (`73a0a6bf1d`). Through the real `saveMetaItem`,
with the header in the registry, the publish save of the measured body
resolved `{ success: true }`, so pin (a) went red, and (d) went red with
0 build findings. (b), as first written with the draft save alone, and
(c) were green. The promotion and package-publish legs of (b) were added
later; the ablation below covers them. With the verdict built, the same
save answers 422 `INVALID_METADATA`, because the door runs the same
pass.
## Pins (Zone 3)
- **Build side** (`validate-expressions.test.ts`, new describe
`#22157`):
- `parent.status == 'closed'` on a one-master detail is refused at
`error`, at the option slot, with a message naming the option, the field
and the root.
- CONTRAST: the same `parent` read on the field's own `readonlyWhen`
passes.
- CONTROL: `record`, `previous`, `current_user`, `user`, `ctx.user`,
`os.user`, `current_user.can(...)` and a `record` member spelled like
the root (`record.parent_code`) all pass.
- Every `SCOPE_ROOTS` member outside the allowlist is refused, one
finding each. The table is generated from the real `SCOPE_ROOTS`.
- **Door side, lint**
(`runtime-gate.object-option-visibility-writes.test.ts`): LIT (refused
at the door, located at the option), PARITY (the door's finding equals
the build's), and CONTROL for `record.x` and the `current_user` role
gate.
- **Door side, protocol** (`protocol.runtime-authoring-gate.test.ts`,
new `#22157` block, through the real methods):
- (a) A publish save answers 422 `INVALID_METADATA`, with one
`expression-invalid` issue at the option, and nothing lands.
- (b) A draft save is allowed and lands as `draft`. Its promotion
through `publishMetaItem` is refused 422, and a package draft publish
answers `refused` with 0 published.
- (c) Control: `record.x == 'a'` saves and lands `active`.
- (d) 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 `012e8d7dbe` through
`scripts/ablation-replace.mjs` in WRAP mode, with an outer `trap`
restore on EXIT, INT and TERM against the absolute path.
- **Mutation.** The verdict call was gated on `Reflect.has(Object,
'ablation22157')`, which is always false. The anchor went x1 to x0 and
the blob went `9781066c15f0` to `570d3d5df714`.
- **Prediction, recorded before the run.** The lint source suites go 5
red: build refuses `parent`, the generated table, the two-roots case,
door LIT and door PARITY. Everything else stays green. The protocol file
goes 4 red, (a), both (b) refusals and (d), with (c) and every other
block green.
- **Observed.** Lint: 5 failed, 371 passed, the predicted five.
`@objectstack/lint` was then rebuilt, and `ablation-dist-preflight`
found the marker in 4 built files. Protocol: 4 failed, 115 passed, the
predicted four.
- **Restore.** The blob is `9781066c15f0`, equal to HEAD, and `git diff
HEAD` is empty. After a rebuild, `--absent` found the marker gone from
all 14 built files and the whole tree clean. Lint went back to 376 of
376 and the protocol file to 119 of 119.
## Local verification (at `c6d1020b90`, the merge of `origin/main`
`b460153912`)
- The merge changed none of this PR's lines. For each of the 5 files,
`git patch-id --stable` of the diff from the merge base is equal at
`012e8d7dbe` (base `73a0a6bf1d`) and at `c6d1020b90` (base
`b460153912`).
- `pnpm --filter @objectstack/lint test`: 128 files, 5862 tests passed.
- `pnpm --filter @objectstack/lint typecheck`: `tsc --noEmit` and
`check:test-typecheck` both OK.
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2 src/protocol.runtime-authoring-gate.test.ts`: 119 passed.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: OK. Its
`tsconfig.json` includes `src/**/*` and excludes only `node_modules` and
`dist`, so the test file is in the program.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `c6d1020b90` derived the same 63 commands
as at claim time. 62 exited 0. `pnpm check:dual-build-cjs-loads` exited
3 with PREREQUISITE NOT MET: 68 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.** The
`--ran` reconciliation reads 63 derived, 62 run, 1 NOT-MEASURED, 0
UNRUN.
- ESLint, narrowed to the 4 changed `.ts` files with `--no-inline-config
--format json`, gave 4 files, 0 errors and 0 warnings. The population
was read from `eslint.config.mjs`:
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus the build-dir ignores. That
config enables no type-aware linting (no `parserOptions.project`), so
this diff cannot move the verdict on any file it does not touch. The
repo-wide `pnpm lint` is CI's.
## Grade and changeset
- `.changeset/22157-option-visible-when-parent.md` lists
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It
has `fix(lint)!`, 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]`.
- **Why `minor`, not triage's `patch`.** AGENTS.md says "`(narrowing)`
is BREAKING". This is the shape of the #22032 pass changesets.
`@objectstack/metadata-protocol` is listed as those passes listed it: no
code moves there, but its save, promotion and package-publish doors are
where the breaking behaviour shows.
- `.changeset/pre.json` is present on `origin/main`, in pre mode `next`.
The bump is still `minor`, and `check-changeset-no-major` is green.
- **Built declarations.** One doc comment moves in `@objectstack/lint`'s
`index.d.ts`: the docblock above the exported `FIELD_RULE_BOUND_ROOTS`
gains one sentence pointing at the option verdict. No export or
signature moves, because the new constant and function are
module-private.
## File surface
All five files are inside the claim's surface:
`packages/lint/src/validate-expressions.ts`, its tests in
`packages/lint/src/` (`validate-expressions.test.ts` and
`runtime-gate.object-option-visibility-writes.test.ts`),
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
and `.changeset/22157-option-visible-when-parent.md`.
`rule-validator.ts` was read, not edited. The diff is +449/-6 lines.
## Acceptance notes
- **A residual in the same family, measured and reported to the PM, not
fixed here.** The allowlist is root-granular, as the ruling asked. At
the option site, `ctx` and `os` carry only their `user` member:
`evaluateOptionVisibility` passes no `org` or `env`.
- At head `012e8d7dbe`, an option `visibleWhen` of `os.org.id != ''`,
`os.env == 'prod'` or `ctx.locale == 'en'` passes the build and the door
with 0 findings.
- Through the built `evaluateValidationRules` with an authenticated
caller, `os.org.id != ''` and `ctx.locale == 'en'` are admitted with
`predicate-fault` (`No such key: org`, `No such key: locale`). `os.env`
was not run.
- A member-level verdict needs its own shape decision, so it goes to the
family's closing card.
- An option `visibleWhen` reading `app` keeps the bare-reference
message, which prescribes the `record`-qualified rewrite that #13935
removed on the field-rule slots. It is still refused at `error`, so only
the prose is affected. Carrier: none.
- Observed, not measured: `validate-expressions.ts` never walks a
picklist's own option `visibleWhen` (the `picklist` metadata type).
Whether the server's option check ever evaluates those predicates was
not read. Carrier: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 345d3f3 commit bb4f5cc
5 files changed
Lines changed: 449 additions & 6 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
181 | 181 | | |
182 | 182 | | |
183 | 183 | | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2005 | 2005 | | |
2006 | 2006 | | |
2007 | 2007 | | |
| 2008 | + | |
| 2009 | + | |
| 2010 | + | |
| 2011 | + | |
| 2012 | + | |
| 2013 | + | |
| 2014 | + | |
| 2015 | + | |
| 2016 | + | |
| 2017 | + | |
| 2018 | + | |
| 2019 | + | |
| 2020 | + | |
| 2021 | + | |
| 2022 | + | |
| 2023 | + | |
| 2024 | + | |
| 2025 | + | |
| 2026 | + | |
| 2027 | + | |
| 2028 | + | |
| 2029 | + | |
| 2030 | + | |
| 2031 | + | |
| 2032 | + | |
| 2033 | + | |
| 2034 | + | |
| 2035 | + | |
| 2036 | + | |
| 2037 | + | |
| 2038 | + | |
| 2039 | + | |
| 2040 | + | |
| 2041 | + | |
| 2042 | + | |
| 2043 | + | |
| 2044 | + | |
| 2045 | + | |
| 2046 | + | |
| 2047 | + | |
| 2048 | + | |
| 2049 | + | |
| 2050 | + | |
| 2051 | + | |
| 2052 | + | |
| 2053 | + | |
| 2054 | + | |
| 2055 | + | |
| 2056 | + | |
| 2057 | + | |
| 2058 | + | |
| 2059 | + | |
| 2060 | + | |
| 2061 | + | |
| 2062 | + | |
| 2063 | + | |
| 2064 | + | |
| 2065 | + | |
| 2066 | + | |
| 2067 | + | |
| 2068 | + | |
| 2069 | + | |
| 2070 | + | |
| 2071 | + | |
| 2072 | + | |
| 2073 | + | |
| 2074 | + | |
| 2075 | + | |
| 2076 | + | |
| 2077 | + | |
| 2078 | + | |
| 2079 | + | |
| 2080 | + | |
| 2081 | + | |
| 2082 | + | |
| 2083 | + | |
| 2084 | + | |
| 2085 | + | |
| 2086 | + | |
| 2087 | + | |
| 2088 | + | |
| 2089 | + | |
| 2090 | + | |
2008 | 2091 | | |
2009 | 2092 | | |
2010 | 2093 | | |
| |||
0 commit comments