Repository navigation
fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads an unbound member of the acting user (#22394) - #22427
Conversation
…ting user is refused The option verdict now judges the acting user's own members under all four ADR-0068 spellings (current_user, user, ctx.user, os.user), held to what EvalUserSchema declares AND the option check binds, derived from the schema and from formula's buildScope. Pins at the build and at the object save door, plus the receiver enumeration pin. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…ceivers Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…tion-visible-when-evaluser-members
📓 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 0337db666956cb9c47642d563eea07983595b441 && git checkout 0337db666956cb9c47642d563eea07983595b441
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 27a8b33dec2eb98b73b3e5146e740067cdbd7806 1d8b879dd5bbc3c02598319b8cd37d9a2600e43e && git checkout -B drift-repro 27a8b33dec2eb98b73b3e5146e740067cdbd7806 && git merge --no-ff 1d8b879dd5bbc3c02598319b8cd37d9a2600e43e
node scripts/docs-audit/affected-docs.mjs --json 27a8b33dec2eb98b73b3e5146e740067cdbd7806
|
Contract reviewServed-tier: Rendered 2026-10-09T06:16Z by an isolated at-tier subagent of the dispatching seat. Inputs: card #22394 (body and all 5 comments, the Check-runs on the head, as read. First read 2026-10-09T06:07Z: 18 success, 3 skipped, 10 in progress, 0 failure. Second read 2026-10-09T06:12Z: 24 success (Build Core, Check Changeset, Governed Surface Queue Guard, Type Check × 4, Dogfood ×4, Temporal Conformance, Test Core 5/6, the claim/branch/closing-keyword guards, Auto Label, PR Size, Doc Links, filter), 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 6 still in progress: Lint & Repo Gates, Test Core 1/6, 2/6, 3/6, 4/6, 6/6, 0 failure. Not waited on or polled; the seat reads their green before enqueue (Prime Directive #14 wording: a PASS record AND every check green). ① Derived judgmentsAccept-set changes the diff implies, each judged against the base sources:
One judgment is not derived and is recorded as such: ② Semver level
③ Boundary flagsEvery dev flag and
Implemented-by: VERDICT: PASS The narrowing is the one the card and the ruling asked for, derived where it can be and disclosed where it cannot, pinned at both doors with parity, graded and declared as the family's precedent is. Enqueue waits on the six in-progress check-runs reading green; flag 1's wording amendment is the seat's, and flags 2–4 are filings the seat owns. |
Fixes #22394
Clause-②: no (narrowing: a select option's
visibleWhenthat 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
visibleWhenis a gate the server enforces on write. The server's option check (evaluateOptionVisibilityinpackages/objectql/src/validation/rule-validator.ts) binds the acting user under four spellings:current_userand its ADR-0068 aliasesuser,ctx.userandos.user. All four are oneEvalUserobject. The family's first two cards refused an unbound root (#22157) and an unbound member ofctx/os(#22274). Neither judged the members of the acting user itself. So'admin' in current_user.roles,ctx.user.roles == ['a']orcurrent_user.email == 'a@b.c'passedos buildand the object save door with 0 findings. At write time each one faulted and the value was admitted.optionVisibleWhenMemberIssueinpackages/lint/src/validate-expressions.tsjudges the first member ofcurrent_user/user, and the member one hop belowctx.user/os.user. Each is held to one allowlist. Any other member is refused aterror, at the option slot, one finding per option.optionVisibleWhenUserMembers, computed on first use). It is the members that are BOTH:EvalUserSchema(@objectstack/spec, imported from@objectstack/spec/identity, an existing export);@objectstack/formula'sbuildScopemounts ascurrent_userfor the acting user ObjectQL'sbuildEvalUserhands over ({ id, positions, organizationId }).id,positions,isPlatformAdminandorganizationId.analyzeRelationshipTraversals, as for lint: a select option'svisibleWhenreading a member the option check never binds (os.org.id,os.env,ctx.locale) passesos buildand the save door, and the server's option gate then faults open #22274. Socurrent_user.roles,current_user.?roles,current_user['roles'],has(current_user.roles),ctx.user['roles']andctx['user'].rolesare one read. A computed key names no member and is not judged.roles/role: ADR-0090 D3 renamed the arraypositions, with no alias. The remedy is written in the author's own spelling, for example'NAME' in ctx.user.positions.name,email: declared onEvalUser, but the server builds the user for this check fromid,positionsandorganizationIdonly. Gate on a bound member or a column.EvalUser. Gate on a bound member or a column.evaluateOptionVisibility, computed keys and computed receivers stay with objectql:evaluateOptionVisibilitycontinues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402.ctx/osrefusal moved into its own helper (optionVisibleWhenNamespaceMemberIssue). Its message bytes are unchanged; the lint: a select option'svisibleWhenreading a member the option check never binds (os.org.id,os.env,ctx.locale) passesos buildand the save door, and the server's option gate then faults open #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
buildScopegiven the option check's context,{ record, previous, user, permissions }:recordvalidateExpression'sunknown fieldcheck (field index)previousctxbuildScope: theusermember onlyOPTION_VISIBLE_WHEN_BOUND_MEMBERS(#22274)osbuildScope: theusermember onlyOPTION_VISIBLE_WHEN_BOUND_MEMBERS(#22274)current_userEvalUserSchema, as far as the option check binds itoptionVisibleWhenUserMembers(this PR)userctx.useros.userpermissionsmounts no receiver: it answerscurrent_user.can(OBJECT, VERB)through the evaluator's environment (#18783), andcanis a call, not a member read.The test
every receiver the option check binds judges its members against a declared sourcewalks the realbuildScopeoutput and asserts:RECEIVER.zz_undeclared) is refused by the build and faults in the evaluator;EvalUserSchemadeclaration;The readings are pinned: declared
id, name, email, positions, isPlatformAdmin, organizationId; accepted undercurrent_userid, positions, isPlatformAdmin, organizationId.The dispatch's premises, measured
abd254508b:@objectstack/objectql(evaluateValidationRules, insert, authenticated caller{ id, positions, organizationId }, permissions passed): each of these was admitted withpredicate-fault:'admin' in current_user.roles,ctx.user.roles == ['a'],'admin' in user.rolesand'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) andcurrent_user.name != ''(No such key: name);current_user['roles'] == ['a'](No such key: roles).has(current_user.roles)was refused on every write (a cleanfalse), and!has(current_user.roles)was admitted on every write.'member' in current_user.positions,'member' in ctx.user.positions,user.id != '',os.user.organizationId == 'org_1'andcurrent_user.isPlatformAdmin == false.current_user.id == 'nobody'andcurrent_user.can('fx', 'edit')(empty permission map) were refusedVALIDATION_FAILED, which shows the gate runs.validateStackExpressionsgave 0 findings for all nine bodies.@objectstack/lintbuild, went red exactly on (a) x3 and (c) x3, withthe save resolved — the door still accepts the option predicate; (b) stayed green.EvalUserSchemais already exported from@objectstack/specand@objectstack/spec/identity.nameandemail. 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.canis 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.abd254508b, and again on this branch's built@objectstack/lint:*.object.tsunderpackages/**andexamples/**, plus the twoapp-multi-packagesub-stacks. That is 113 files and 118 objects, with 0 import failures.showcase_cascade. Their roots arerecordx4 andcurrent_userx1. The one acting-user member read iscurrent_user.positions, which is bound.roles,role,emailornameunder a user receiver found none. Thecurrent_user.emailhits are row-level securityusing/checkpredicates and a pagevisibleWhen, surfaces this verdict does not judge.EvalUserSchemaandbuildScope, neverEvaluateRulesOptions.currentUser(@objectstack/lintcannot depend on ObjectQL).packages/objectqlis not in this diff, so no cross-lane declaration is owed.OPTION_CHECK_ACTING_USERmirrors the key set of ObjectQL'sbuildEvalUserreturn value. This is the one fact the allowlist cannot read from code. Its docblock carries the rule that it changes in the same change asbuildEvalUser, asOPTION_VISIBLE_WHEN_BOUND_MEMBERSdoes for the call shape.Pins
validate-expressions.test.ts, new describe#22394):error, at the option slot:rolesunder all four spellings,current_user.role,current_user.emailandctx.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.rolesrefusal namesRECEIVER.positionsin the author's spelling. That replacement passes the build and evaluatestrue.RECEIVER.can(...), and arecordfield namedrolesall pass, and each evaluates.current_user.emailin an object action'svisible, a surface this refusal does not judge, is not refused.has(),.?,['roles'],ctx['user'].rolesandhas(os.user.email)are each one finding.SCOPE_ROOTSorder (osbeforecurrent_user), then name order.#5017declared-key meta-test registers five new local names (declaredUserMembers,boundUserMembers,listedNames,tickedNames,membersRead). Each is astring[]of member names, named to stay clear of metadata receivers.protocol.runtime-authoring-gate.test.ts, new#22394block, through the realsaveMetaItem):INVALID_METADATAwith oneexpression-invalidissue at the option. The issue names the member and the bound members, and nothing lands.active.rule,where,path,messageandhintare equal at the door and at the build.Reverse verification (ablation)
The run was made from the committed head
f49bdb2fc8. It went throughscripts/ablation-replace.mjs(HOLD mode,--expect 2) inside a script with atraprestore on EXIT, INT and TERM against the absolute path.!optionVisibleWhenUserMembers().includes(m)), were gated onReflect.has(Object, "ablation22394"), which is always false. The anchor went x2 to x0 and the marker x0 to x2. The blob went7343ff874eddtob85f0f7ad0d3.rolesremedy, the spellings, the ordering and the enumeration pin.visibleWhenreading a member the option check never binds (os.org.id,os.env,ctx.locale) passesos buildand the save door, and the server's option gate then faults open #22274 and finding(lint): a select option'svisibleWhenreadingparentpassesos buildand the object save door, and the server's option gate then faults open ("allowed through; the option's gate was NOT enforced") #22157 pin.src/validate-expressions.test.ts: 11 failed and 369 passed, the predicted eleven.@objectstack/lintwas then rebuilt.ablation-dist-preflightfound the marker in 4 built files (index.js,index.cjs,runtime.js,runtime.cjs).src/protocol.runtime-authoring-gate.test.ts: 6 failed and 127 passed, the predicted six.7343ff874edd, 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 380 of 380, and the protocol file to 133 of 133.Local verification
Measured at
f49bdb2fc8, the head before mergingorigin/main.origin/mainmerge (1d8b879dd5) brought 3 commits. They touchpackages/runtime,packages/verify,packages/qa/dogfood, docs, a changeset, and fivepackages/spec/src/**/*.test.tsfiles (test titles only).@objectstack/lint→formula,spec,sdui-parser;@objectstack/metadata-protocol).pnpm --filter @objectstack/lint test: 128 files and 5894 tests passed.pnpm --filter @objectstack/lint typecheck:tsc --noEmitpassed, andcheck:test-typecheckwas OK.tsc -p tsconfig.test.json --listFilesincludesvalidate-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 --listFilesincludes the protocol test file.1d8b879dd5:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived the same 63 commands as at claim time (abd254508b). 62 exited 0.pnpm check:dual-build-cjs-loadsexited 3 with PREREQUISITE NOT MET: packages unrelated to this diff have nodist/locally. NOT MEASURED: dual-build-cjs-loads, reason: prerequisite not met locally; CI builds the full tree.requireof@objectstack/lint'sdist/index.cjsanddist/runtime.cjssucceeded, so the new@objectstack/spec/identityimport resolves under CJS, and the CJSvalidateStackExpressionsgave the refusal (1 finding).--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.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.mdlists@objectstack/lintand@objectstack/metadata-protocolasminor, as for lint: a select option'svisibleWhenreading a member the option check never binds (os.org.id,os.env,ctx.locale) passesos buildand the save door, and the server's option gate then faults open #22274, since the BREAKING section names that package's doors.fix(lint)!prefix, the Clause-② line above, a BREAKING section with the remedy (roles→positions), 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/22394-option-visible-when-evaluser-members.mdrule-validator.tsandengine.tswere read, not edited. The diff is +631/-49 lines againstorigin/main.Acceptance notes
nameandemail.EvalUserSchemadeclares 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.evaluateOptionVisibilitycontinues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402). A computed key (current_user[k],os['o' + 'rg']), a computed receiver ([os].all(o, o.org.id != '')), rows stored before this change, andOS_ALLOW_UNLINTED_METADATA_WRITES=1writes are not judged statically. The fault-open itself is objectql:evaluateOptionVisibilitycontinues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402's question.record/previousrow of the enumeration has a spelling gap, reported to the seat as its own finding. Their member source is theunknown fieldcheck in@objectstack/formula'svalidateExpression, and that check readsrecord.FIELD/previous.FIELDwith a regex. Sorecord['zz_typo'] == 'a'andprevious['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 fieldrequiredWhenand a validationconditionat 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 typesroles?. It does not feed this allowlist, so it is untouched here. Carrier: objectql:evaluateOptionVisibilitycontinues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402, which works in that file.Generated by Claude Code