Repository navigation
fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads an unbound ctx/os member (#22274) - #22392
Conversation
…check never binds (#22274) WIP: the member arm of the option-predicate verdict; pins follow. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
… the save door (#22274) Adds the refused/accepted pins, the positive control at a formula field, the buildScope-derived allowlist parity test, and the changeset. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…tion-visible-when-members
…ts (#22274) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cda028da584d176c5c2e17f14cda31747dead187 && git checkout cda028da584d176c5c2e17f14cda31747dead187
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 00bee2724d5d5dcfab902f5208f1576be6ff1da3 dffc512cf2bef65a8e3af54dd4350f0c017bcec0 && git checkout -B drift-repro 00bee2724d5d5dcfab902f5208f1576be6ff1da3 && git merge --no-ff dffc512cf2bef65a8e3af54dd4350f0c017bcec0
node scripts/docs-audit/affected-docs.mjs --json 00bee2724d5d5dcfab902f5208f1576be6ff1da3
|
Contract reviewServed-tier: Stamp: 2026-10-09T02:08Z · isolated at-tier reviewer, read-only: the card (#22274, body and comments Head, as found. Check-runs on the head, read once. No failure. Completed green: Build Core, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Check Changeset (the no-major and ADR-0087 gates), Check PR Size, Dogfood Regression Gate (1/3, 2/3, 3/3 and the rollup), Dogfood Verify CLI, Governed Surface Queue Guard, the four PR guards (same issue, same single-writer path, card claims this branch, part-of must not close), Auto Label, Check Documentation Links, Flag docs affected by code changes. Skipped by filter: Build Docs, Console Pin Gate, Packed-tarball smoke. Still in progress at read time: Test Core (1/6 through 6/6), Temporal Conformance (live PG + MySQL), Type Check · workspace, Lint & Repo Gates. Those four are the ones that run the new pins and the repo-wide lint; the dev's local runs (lint 5871 passed, protocol 28325 passed, both typechecks OK) are the only evidence on them until they conclude. ① Derived judgmentsEach one derived from the binding text at
② Semver level
③ Boundary flagsEvery deviation and out-of-scope finding in report
Not in the report, found here, not blocking: the What is required before this record can read PASS: the one-line changeset frontmatter addition in ②. Everything else held under adversarial reading; with that line added, a re-review on the new head is a formality, and the four in-progress check-runs must conclude green. Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ions of a has()/optional member read (#22274) The changeset names metadata-protocol's doors in its BREAKING section, so that package carries the entry beside lint, as the door-crossing changesets in this seam do. The member refusal's tail no longer says a has() test or an optional read is admitted: it is refused on every write, or admitted on every write when negated or defaulted to a passing value. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Stamp: 2026-10-09T02:29Z · delta review of patch round 1, by the same isolated at-tier reviewer as record Head, as found. Check-runs on For the record on the superseded head: ① Derived judgments
② Semver level
③ Boundary flags
Standing condition on landing, not on this verdict: the four in-progress Test Core shards and Type Check · workspace must conclude green on Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22274
Clause-②: no (narrowing: a select option's
visibleWhenthat reads an unbound member of a bound root is refused at build and at the object save door)What changes
A select option's
visibleWhenis a gate the server enforces on write. The server's option check (evaluateOptionVisibilityinpackages/objectql/src/validation/rule-validator.ts) bindsrecord,previousand the acting user. Underctxandosit binds only theirusermember: it passes no organization and no environment. The root verdict from the sibling card #22157 acceptedctxandosas whole roots, so an option predicate readingos.org.id,os.envorctx.localepassedos buildand the object save door. At write time each one faulted and the value was admitted.optionVisibleWhenRootIssueinpackages/lint/src/validate-expressions.tskeeps its root test. When no unbound root is read, it hands off tooptionVisibleWhenMemberIssue, which checks each member read underctxandosagainstOPTION_VISIBLE_WHEN_BOUND_MEMBERS({ ctx: ['user'], os: ['user'] }). Any other member is refused aterror, at the option slot, one finding per option.@objectstack/formula'sanalyzeRelationshipTraversalsreads the member. Soos.org,os.?org,os['org']andhas(os.org)count as one read. A computed key (os[k]) names no member and is not judged.usermember and nothing else. Then it gives a remedy for that member:os.org: comparecurrent_user.organizationId. The engine builds the acting user with the caller's organization id (nulloutside one), so that fact IS bound at the option check. Measured: it evaluates there, a cleantrueadmits the value and a cleanfalserefuses it.os.env: the option check has no environment. Gate on a column or oncurrent_user.ctx.locale: rewrite againstrecord/previousorcurrent_user.evaluateOptionVisibilitybinds noorgorenv, and its fault-open stays as it is. That behaviour isdomain:engine's question.osnamespace". That is false at the server, and this change made it contradict the new verdict. Comment only.The dispatch's premises, measured
@objectstack/objectql(evaluateValidationRules, insert, authenticated caller{ id, positions, organizationId }, permissions passed), at basebb4f5cc005:os.org.id != ''was admitted, withpredicate-fault/No such key: org.os.env == 'prod'was admitted, withpredicate-fault/No such key: env.ctx.locale == 'en'was admitted, withpredicate-fault/No such key: locale.os.user.id != '',ctx.user.id != '',current_user.id != '',user.id != '',record.x == 'a'andcurrent_user.can('fx', 'edit')evaluated cleanly.os.user.id == 'nobody'was refusedVALIDATION_FAILED(option 'gold' is not available), which shows the gate runs.*.object.tsunderpackages/**andexamples/**, plus the twoapp-multi-packagesub-stacks. At basebb4f5cc005that is 112 files and 119 objects. At merged head4e0f473df6it is 111 files and 118 objects, becauseorigin/main117d34de3fretired one. There were 0 import or parse failures.defineStackcomposes them, 33 objects.showcase_cascade. Their roots arerecordx4 andcurrent_userx1. The members read underosandctxare none, at base and at head.visibleWhenwith anos.orctx.member read found no option predicate. It found 5 lines: a lint test fixture, a pagevisibleWhen, and three spec.describe()strings.evaluateOptionVisibilitymakes,ExpressionEngine.evaluate(expr, { record: merged, previous, user, permissions });@objectstack/formula'sbuildScope, which from that context mountsctx = { user }andos = { user }, and mountsos.org/os.envonly from anorg/envin the context;user,ctx.user,os.userare the sameEvalUser).current_useranduserARE theEvalUser, and its members are the same at every site, so they are not in the member map. That is also whyctx.user.positionsis not judged here.buildScopeandExpressionEngine.evaluatewith the option check's context. Every member mounted underctx/osmust be accepted and evaluate. Every member mounted only when anorgandenvare ALSO given must be refused and fault. The exact accepted/refused lists are pinned. SobuildScopemounting a new member there, or droppinguser, turns it red.org.@objectstack/lintcannot 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 ownUSER_SCOPE_ROOTSdocblock, which states the same exactness claim. This is named in the report.Pins (Zone 3)
validate-expressions.test.ts, new describe#22274):os.org.id != '',os.env == 'prod'andctx.locale == 'en'are each refused aterror, at the option slot. The message names the option, the field, the member path, and theusermember as what is bound.os.orgrefusal namescurrent_user.organizationId. That replacement passes the build, and it evaluatestruein the option check's context.current_user.id,os.user.id,'org_admin' in ctx.user.positions,user.id,record.x,previous.x,current_user.can(...), and arecordfield spelled like a refused member (record.locale) all pass.os.org.id != ''as aformulafield'sexpression, a site whose evaluator bindsos.org(applyFormulaPlan), is not refused. So the refusal belongs to the option slot, not to every slot.has(os.org),os.?org,os['org']andhas(ctx.locale).SCOPE_ROOTS.buildScopeparity test (P3 above).protocol.runtime-authoring-gate.test.ts, new#22274block, through the realsaveMetaItem):INVALID_METADATAwith oneexpression-invalidissue at the option that names the member and the boundusermember, and nothing lands.active.rule,where,path,messageandhintare equal at the door and at the build, for each body.Reverse verification (ablation)
The run was made from the committed head
f270d45a7ethroughscripts/ablation-replace.mjsin WRAP mode, with an outertraprestore on EXIT, INT and TERM against the absolute path. The restore was checked by comparing the file's blob hash with the HEAD blob.Reflect.has(Object, "ablation22274"), which is always false. The anchor went x1 to x0, and the blob went256380b4d7c8to057b663b2c3a. On disk, the anchor count was 0 and the marker count was 1.src/validate-expressions.test.ts: 7 failed, 360 passed, the predicted seven.@objectstack/lintwas then rebuilt.ablation-dist-preflightfound the marker in 4 built files (index.js,index.cjs,runtime.js,runtime.cjs).256380b4d7c8, equal to HEAD, andgit diff HEADis empty. After a rebuild,--absentfound 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 mergingorigin/main117d34de3f)@objectstack/metadata-protocol,metadata-core,metadata,platform-objectsandspec. None of this PR's files changed. Afterpnpm install --frozen-lockfileand 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 fulltesttask,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 --noEmitpassed, andcheck:test-typecheckwas OK, withvalidate-expressions.test.tscarrying no debt entry.pnpm --filter @objectstack/metadata-protocol typecheck: OK.tsc --listFilesincludes the protocol test file.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat65ac7df278derived the same 63 commands as at claim time.pnpm check:dual-build-cjs-loadsexited 3 with PREREQUISITE NOT MET: packages unrelated to this diff have nodist/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..tsfiles with--no-inline-config --format json: 3 files, 0 errors, 0 warnings.eslint.config.mjs:**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}, minusNEVER_LINTEDand thepackages/spec/**ignores.parserOptions.project), so this diff cannot change the verdict on any file it does not touch.pnpm lintis CI's.Grade and changeset
.changeset/22274-option-visible-when-members.mdlists@objectstack/lintand@objectstack/metadata-protocolasminor.metadata-protocolwas added in patch round 1 atdffc512cf2, per contract review6072804991: the BREAKING section names that package's doors. It has thefix(lint)!prefix, the Clause-② line above, a BREAKING section with the remedy, and the ADR-0087 dispositionnot-required (no-migration-prescription).check-adr-0087-registrationreads it as[BREAKING+bang+clause-②-narrowing].check-changeset-no-majorandcheck-empty-changesetare green.File surface
All four files are inside the claim's surface:
packages/lint/src/validate-expressions.tspackages/lint/src/validate-expressions.test.tspackages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts.changeset/22274-option-visible-when-members.mdrule-validator.tswas read, not edited. The diff is +412/-7 lines against the merge base117d34de3.Acceptance notes
ctx.user.rolesas 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.rolesfault withNo such key: roles: ADR-0090 D3 renamedrolestopositions. Pinning it as an accepted case would endorse a gate that is never enforced, so the control pins'org_admin' in ctx.user.positionsinstead. TheEvalUsermember level is reported to the PM as the next finding in this family.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 withpredicate-fault/No such key: org. Not measured: rows stored before this change, writes underOS_ALLOW_UNLINTED_METADATA_WRITES=1, and theEvalUsermember level above.os.org.idpredicate to move it to an option'svisibleWhen. There it now meets this refusal, which namescurrent_user.organizationId. The author gets there in two steps, and no message is false. Carrier: none.content/docs/data-modeling/formulas.mdxlistsos.org/os.envas available in "predicates". That is broader than what the option check binds. Carrier: none.Generated by Claude Code