Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22394-option-visible-when-evaluser-members
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22394-option-visible-when-evaluser-members

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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 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.

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

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


Generated by Claude Code

claude added 3 commits October 9, 2026 05:07
…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>
@github-actions github-actions Bot added the size/l label Oct 9, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 15 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/automation/hooks.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/data-modeling/seed-data.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/deployment/seed-tenancy-repair.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/kernel/events.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/kernel/runtime-services/audit-service.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/permissions/authentication.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/permissions/system-context.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/protocol/kernel/config-resolution.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/releases/v16.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))
  • content/docs/releases/v17/17-5.mdx (via organizationId (symbol, a field of const object OPTION_CHECK_ACTING_USER), organizationId (literal, a string literal in optionVisibleWhenMemberIssue; a string literal in optionVisibleWhenNamespaceMemberIssue))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (5 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 33 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 27a8b33dec2eb98b73b3e5146e740067cdbd7806 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0337db666956cb9c47642d563eea07983595b441 — the merge of head 1d8b879dd5bbc3c02598319b8cd37d9a2600e43e into base 27a8b33dec2eb98b73b3e5146e740067cdbd7806, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 27a8b33dec2eb98b73b3e5146e740067cdbd7806 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1d8b879dd5bbc3c02598319b8cd37d9a2600e43e
Local-runs: none

Rendered 2026-10-09T06:16Z by an isolated at-tier subagent of the dispatching seat. Inputs: card #22394 (body and all 5 comments, the os-dev-report 6075290404 read as claims), PR #22427 (body, 4-file list, net diff origin/main...1d8b879dd5 = +631/−49, merge-base 27a8b33dec), and the head's check-runs. Every "verified" below is a read of the base sources at origin/main with git show / git grep, nothing built or run.

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 judgments

Accept-set changes the diff implies, each judged against the base sources:

  1. validateStackExpressions option visibleWhen slot: a first-member read of current_user / user outside {id, positions, isPlatformAdmin, organizationId} becomes an error finding — RIGHT. Verified the two derivation inputs: EvalUserSchema (packages/spec/src/identity/eval-user.zod.ts, a lazySchema whose Proxy forwards .shape) declares exactly id, name, email, positions, isPlatformAdmin, organizationId; buildScope (packages/formula/src/stdlib.ts) mounts toEvalUser → createEvalUser, which spreads name / email only when non-null and organizationId only when not undefined, and always sets isPlatformAdmin; so for the mirror {id:'', positions:[], organizationId:null} the mounted keys are exactly the four. Intersection = the four the tests pin.
  2. The same verdict one hop below ctx.user / os.user — RIGHT. buildScope hangs the one createEvalUser object on current_user, user, ctx.user, os.user; membersReadThrough(found,'user') reads traversals.get('user') of analyzeRelationshipTraversals, whose RelationshipTraversalAnalysis is traversals / bareFields / multiHopFields and is exported from @objectstack/formula's index together with buildScope.
  3. The extension actually fires: the caller passes roots = collectCelRootIdentifiers(source).roots, which carries current_user / user; the new if (!roots.includes(root)) continue; … if (namespaceMembers === undefined && !isUserRoot) continue; is equivalent to the old guard for ctx / os and adds the two user roots — RIGHT.
  4. Every spelling is one read (.?, ['x'], has(), ctx['user'].roles) — RIGHT, the same reader lint: a select option's visibleWhen reading a member the option check never binds (os.org.id, os.env, ctx.locale) passes os build and the save door, and the server's option gate then faults open #22274 uses; a computed key names no member and is not judged — RIGHT (carrier objectql: evaluateOptionVisibility continues 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, per the ruling on the card).
  5. The ctx / os refusal is a pure refactor, message bytes unchanged — RIGHT, verified from the diff: the old inline tail (…; a has() test … the read's default passes. ${prescription}) equals the new one (…; ${OPTION_MEMBER_GUARD_CONSEQUENCE}. ${prescription}, where the constant holds that same sentence) character for character.
  6. Ordering claim (os before current_user; namespace's own member before the acting user's) — RIGHT: SCOPE_ROOTS in cel-engine.ts lists os at index 4, before user, ctx and current_user; the within-namespace order follows from the helper's code order.
  7. Declared-but-unbound name / email are refused — RIGHT as a judgment. The card's purpose is "an option gate that is never enforced"; buildEvalUser (engine.ts 5651) returns {id, positions, organizationId} only, so email faults open there exactly as roles does. The declaration prose is the issue, not the judgment (② and ③).
  8. Enumeration pin — RIGHT. For the option check's context {record, previous, user, permissions} (verified at rule-validator.ts evaluateOptionVisibility, 2976–2985), buildScope mounts exactly record, previous, current_user, user, ctx{user}, os{user} — 8 receivers; permissions mounts none. The table matches the source.
  9. #5017 meta-test, five new PLUMBING names — RIGHT. The scan is \b([a-z][\w$]*)\??\.[A-Za-z_$]; each of declaredUserMembers, boundUserMembers, listedNames, tickedNames, membersRead is a string[] whose only member reads are Array methods; EvalUserSchema.shape and OPTION_CHECK_ACTING_USER start uppercase and are not scanned; optionVisibleWhenUserMembers().includes is not matched (a ) precedes the dot).
  10. Door — RIGHT. No packages/metadata-protocol source moves; the door already runs the same lint pass (lint: a select option's visibleWhen reading a member the option check never binds (os.org.id, os.env, ctx.locale) passes os build and the save door, and the server's option gate then faults open #22274's precedent); parity (c) pins rule, where, path, message, hint equal.
  11. Public surface — RIGHT, none moves. New symbols are unexported const / function; validateStackExpressions(stack) keeps its signature; @objectstack/spec/identity is an existing subpath export and @objectstack/spec, @objectstack/formula are already workspace:* deps of lint; no API-surface baseline exists for lint to regenerate.
  12. Governed surfaces: none of the four files is one (file-list judgment); the queue guard agrees (success).

One judgment is not derived and is recorded as such: OPTION_CHECK_ACTING_USER hand-mirrors buildEvalUser's key set (lint cannot import objectql). The dev discloses it ("Not caught mechanically"). No cross-package pin exists for it — nor did #22274 add one for OPTION_VISIBLE_WHEN_BOUND_MEMBERS (verified: OPTION_VISIBLE_WHEN appears nowhere outside packages/lint). Drift direction: a key ADDED to buildEvalUser makes lint over-refuse (fail-closed at build, safe); a key REMOVED reopens the hole for that member silently. Precedent-consistent, so not a FAIL; escalated in ③.

② Semver level

  • .changeset/22394-option-visible-when-evaluser-members.md: @objectstack/lint: minor, @objectstack/metadata-protocol: minor, fix(lint)! prefix, a BREAKING section naming what moves (the four bound members; roles, role, email, name refused; the three doors PUT /api/v1/meta/object/:name / publishMetaItem / publishPackageDrafts), a Remedy section with the FROM → TO mapping (roles → positions in the author's spelling; name / email → a bound member or a column), and exactly one ADR-0087 marker, not-required (no-migration-prescription). This is byte-for-shape the lint: a select option's visibleWhen reading a member the option check never binds (os.org.id, os.env, ctx.locale) passes os build and the save door, and the server's option gate then faults open #22274 precedent (b1f7a7a73c, same two packages, same grade, same disposition) — RIGHT. The gates that read it (check-empty-changeset, check-adr-0087-registration, check-changeset-no-major) run in the Check Changeset job, success on this head.
  • Bumping @objectstack/metadata-protocol with only its test file in the diff follows the precedent: the BREAKING section names that package's doors, whose answer changes through the lint dependency.
  • Clause-②: no (narrowing: …) — form right, arm right (a narrowing is BREAKING; minor is the ceiling under no-major). The parenthetical gloss is under-inclusive: it says "an EvalUser member the schema does not declare", while the diff also refuses name and email, which the schema declares. The arm and the grade do not move, and the BREAKING list in the same changeset names both members, so no consumer is misled about what moves; the gloss is still the PR's one-line declaration and should say what it narrows — answered as flag 1 below.

③ Boundary flags

Every dev flag and open_questions entry, answered or escalated:

  1. open_questions[0] — Clause-② wording. Answer: A. Amend the gloss to name both classes (undeclared such as roles, and declared-but-never-bound such as email). The PR body line can be edited without moving the head; the changeset line moves with the next push, if any, and a new head takes a new record. Not C: the measured fault-open on email is the family's class.
  2. open_questions[1] — record['zz_typo'] / previous['zz_typo'] pass the build at the option slot (and at requiredWhen / validation condition) because formula's checkFieldExistence reads the dot spelling only. Answer: A, file it as its own class (a) card against packages/formula/src/validate.ts — the shared unknown-field check spans every record-scoped slot; an option-only arm would double the dot-spelling finding. This reviewer writes nothing but this record; the seat files it.
  3. Mirror constant OPTION_CHECK_ACTING_USER (dev: "Not caught mechanically"). Escalated to the seat: objectql: evaluateOptionVisibility continues 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 (domain:engine, works in rule-validator.ts / engine.ts) should carry a key-set pin on buildEvalUser's return naming lint's constant, so a removed key cannot reopen the hole silently. Precedent-consistent today; not blocking.
  4. Reviewer's own residual, same family, not this card's level: a read BELOW a bound member — current_user.positions.x, ctx.user.organizationId.y — is not judged (the first-hop member is bound, so membersReadUnder passes it) and faults open at the option check. The card's contract is the first member of the EvalUser; this is a declared member read at a depth it has no members. Seat to add to objectql: evaluateOptionVisibility continues 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 carrier list or file.
  5. Suites measured at f49bdb2fc8, not re-run after the origin/main merge. Answered: verified the three merged commits (ca135dcc40, 961d365f17, 27a8b33dec) touch packages/runtime, packages/verify, packages/qa/dogfood, docs, a changeset and five packages/spec/src/**/*.test.ts — nothing in lint, metadata-protocol, formula, sdui-parser or spec non-test source. CI at the merged head is the measurement of record; Test Core shards are in progress as read.
  6. Example apps not built locally; dual-build-cjs-loads NOT MEASURED locally. Answered by the check-runs: Build Core success; Lint & Repo Gates in progress as read. The seat reads it green.
  7. Trailers "model-free, not the harness reminder's". Verified compliant: AGENTS.md (the Commit message rule) prescribes exactly Claude-Session: plus Co-authored-by: Claude; both branch commits carry them. The merge commit carries git's default message; the squash landing writes its own.
  8. #5017 PLUMBING additions — judged right in ① 9.
  9. P4 / cross-lane: packages/objectql is not in the file list; the stale EvaluateRulesOptions.currentUser roles? type does not feed the allowlist; no cross-lane declaration owed — consistent with the claim's conditional. Carrier objectql: evaluateOptionVisibility continues 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.
  10. Lock queue-timeouts, worktree cleanup — process notes, no artefact effect.

Implemented-by: claude/issue-22394-option-visible-when-evaluser-members
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

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.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 06:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 06:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 46692c1 Oct 9, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22394-option-visible-when-evaluser-members branch October 9, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants