Skip to content

fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads an unbound ctx/os member (#22274) - #22392

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

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22274-option-visible-when-members

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22274
Clause-②: no (narrowing: a select option's visibleWhen that reads an unbound member of a bound root 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 record, previous and the acting user. Under ctx and os it binds only their user member: it passes no organization and no environment. The root verdict from the sibling card #22157 accepted ctx and os as whole roots, so an option predicate reading os.org.id, os.env or ctx.locale passed os build and the object save door. At write time each one faulted and the value was admitted.

  • The option verdict now judges members too. optionVisibleWhenRootIssue in packages/lint/src/validate-expressions.ts keeps its root test. When no unbound root is read, it hands off to optionVisibleWhenMemberIssue, which checks each member read under ctx and os against OPTION_VISIBLE_WHEN_BOUND_MEMBERS ({ ctx: ['user'], os: ['user'] }). Any other member is refused at error, at the option slot, one finding per option.
  • The members are read by the platform's own reader. @objectstack/formula's analyzeRelationshipTraversals reads the member. So os.org, os.?org, os['org'] and has(os.org) count as one read. A computed key (os[k]) names no member and is not judged.
  • The message names what IS bound. It names the member and says that under that root the option check binds the user member and nothing else. Then it gives a remedy for that member:
    • os.org: compare current_user.organizationId. The engine builds the acting user with the caller's organization id (null outside one), so that fact IS bound at the option check. Measured: it evaluates there, a clean true admits the value and a clean false refuses it.
    • os.env: the option check has no environment. Gate on a column or on current_user.
    • Any other member, such as ctx.locale: rewrite against record / previous or current_user.
  • One pass, two doors. The object save door runs this same pass, so the door's finding is the build's finding. No second judge.
  • The runtime is unchanged (ruling). evaluateOptionVisibility binds no org or env, and its fault-open stays as it is. That behaviour is domain:engine's question.
  • A stale docblock is corrected. The field-rule verdict's docblock said the option surface "binds the whole os namespace". That is false at the server, and this change made it contradict the new verdict. Comment only.

The dispatch's premises, measured

  • P1 held. Through the built @objectstack/objectql (evaluateValidationRules, insert, authenticated caller { id, positions, organizationId }, permissions passed), at base bb4f5cc005:
    • os.org.id != '' was admitted, with predicate-fault / No such key: org.
    • os.env == 'prod' was admitted, with predicate-fault / No such key: env.
    • ctx.locale == 'en' was admitted, with predicate-fault / No such key: locale.
    • Controls: os.user.id != '', ctx.user.id != '', current_user.id != '', user.id != '', record.x == 'a' and current_user.can('fx', 'edit') evaluated cleanly. os.user.id == 'nobody' was refused VALIDATION_FAILED (option 'gold' is not available), which shows the gate runs.
  • P2 held: no corpus hit, so no fork.
    • Corpus A: every git-tracked *.object.ts under packages/** and examples/**, plus the two app-multi-package sub-stacks. At base bb4f5cc005 that is 112 files and 119 objects. At merged head 4e0f473df6 it is 111 files and 118 objects, because origin/main 117d34de3f retired one. There were 0 import or parse failures.
    • Corpus B: the example stacks as defineStack composes them, 33 objects.
    • Both carry the same 5 option predicates, all on showcase_cascade. Their roots are record x4 and current_user x1. The members read under os and ctx are none, at base and at head.
    • Option findings were 0 at the build and 0 at the door, at both trees.
    • A tree-wide text grep for visibleWhen with an os. or ctx. member read found no option predicate. It found 5 lines: a lint test fixture, a page visibleWhen, and three spec .describe() strings.
  • P3 held. The allowlist mirrors three things in code:
    • the one call evaluateOptionVisibility makes, ExpressionEngine.evaluate(expr, { record: merged, previous, user, permissions });
    • @objectstack/formula's buildScope, which from that context mounts ctx = { user } and os = { user }, and mounts os.org / os.env only from an org / env in the context;
    • ADR-0068 D1's aliases (user, ctx.user, os.user are the same EvalUser).
    • current_user and user ARE the EvalUser, and its members are the same at every site, so they are not in the member map. That is also why ctx.user.positions is not judged here.
    • How drift surfaces: a new lint test drives the real buildScope and ExpressionEngine.evaluate with the option check's context. Every member mounted under ctx / os must be accepted and evaluate. Every member mounted only when an org and env are ALSO given must be refused and fault. The exact accepted/refused lists are pinned. So buildScope mounting a new member there, or dropping user, turns it red.
    • Not caught mechanically: a change to ObjectQL's call shape, such as the option check starting to pass org. @objectstack/lint cannot 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 own USER_SCOPE_ROOTS docblock, which states the same exactness claim. This is named in the report.

Pins (Zone 3)

  • Build side (validate-expressions.test.ts, new describe #22274):
    • os.org.id != '', os.env == 'prod' and ctx.locale == 'en' are each refused at error, at the option slot. The message names the option, the field, the member path, and the user member as what is bound.
    • The os.org refusal names current_user.organizationId. That replacement passes the build, and it evaluates true in the option check's context.
    • CONTROL: current_user.id, os.user.id, 'org_admin' in ctx.user.positions, user.id, record.x, previous.x, current_user.can(...), and a record field spelled like a refused member (record.locale) all pass.
    • POSITIVE CONTROL: the same os.org.id != '' as a formula field's expression, a site whose evaluator binds os.org (applyFormulaPlan), is not refused. So the refusal belongs to the option slot, not to every slot.
    • Every member spelling is judged as one read: has(os.org), os.?org, os['org'] and has(ctx.locale).
    • One finding per option. An unbound root wins over a member, and members are ordered by SCOPE_ROOTS.
    • The buildScope parity test (P3 above).
  • Door side (protocol.runtime-authoring-gate.test.ts, new #22274 block, through the real saveMetaItem):
    • (a) Each of the three bodies: a publish save answers 422 INVALID_METADATA with one expression-invalid issue at the option that names the member and the bound user member, and nothing lands.
    • (b) Control: the eight accepted bodies save and land active.
    • (c) PARITY: rule, where, path, message and hint are equal at the door and at the build, for each body.

Reverse verification (ablation)

The run was made from the committed head f270d45a7e through scripts/ablation-replace.mjs in WRAP mode, with an outer trap restore on EXIT, INT and TERM against the absolute path. The restore was checked by comparing the file's blob hash with the HEAD blob.

  • Mutation. The member arm's call was gated on Reflect.has(Object, "ablation22274"), which is always false. The anchor went x1 to x0, and the blob went 256380b4d7c8 to 057b663b2c3a. On disk, the anchor count was 0 and the marker count was 1.
  • Prediction, recorded before the run.
    • Lint: 7 red. They are the three refusals, the replacement test, the spelling test, the second half of the ordering test, and the parity test. CONTROL, POSITIVE CONTROL and every other test stay green.
    • Protocol: 6 red, (a) x3 and (c) x3. (b) and every other block stay green.
  • Observed.
    • Lint src/validate-expressions.test.ts: 7 failed, 360 passed, the predicted seven.
    • @objectstack/lint was then rebuilt. ablation-dist-preflight found the marker in 4 built files (index.js, index.cjs, runtime.js, runtime.cjs).
    • Protocol: 6 failed, 120 passed, the predicted six.
  • Restore. The blob is 256380b4d7c8, 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 367 of 367, and the protocol file to 126 of 126.

Local verification (at 65ac7df278, after merging origin/main 117d34de3f)

  • The merge brought one commit that touches @objectstack/metadata-protocol, metadata-core, metadata, platform-objects and spec. None of this PR's files changed. After pnpm install --frozen-lockfile and 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 full test task, 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 --noEmit passed, and check:test-typecheck was OK, with validate-expressions.test.ts carrying no debt entry.
  • pnpm --filter @objectstack/metadata-protocol typecheck: OK. tsc --listFiles includes the protocol test file.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 65ac7df278 derived the same 63 commands as at claim time.
    • 62 exited 0.
    • pnpm check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET: 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.
    • --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.

Grade and changeset

  • .changeset/22274-option-visible-when-members.md lists @objectstack/lint and @objectstack/metadata-protocol as minor. metadata-protocol was added in patch round 1 at dffc512cf2, per contract review 6072804991: the BREAKING section names that package's doors. It has the fix(lint)! prefix, 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].
    • check-changeset-no-major and check-empty-changeset are green.
  • No export or signature moves. The new constant and the two 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/22274-option-visible-when-members.md

rule-validator.ts was read, not edited. The diff is +412/-7 lines against the merge base 117d34de3.

Acceptance notes

  • Deviation from the dispatch's pin list. Zone 3 lists ctx.user.roles as 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.roles fault with No such key: roles: ADR-0090 D3 renamed roles to positions. Pinning it as an accepted case would endorse a gate that is never enforced, so the control pins 'org_admin' in ctx.user.positions instead. The EvalUser member level is reported to the PM as the next finding in this family.
  • Residual reachable paths to the fault-open, reported to the PM for the card (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 with predicate-fault / No such key: org. Not measured: rows stored before this change, writes under OS_ALLOW_UNLINTED_METADATA_WRITES=1, and the EvalUser member level above.
  • Two-step prescription, noted. The field-rule verdict's user tier tells an author with a field-level os.org.id predicate to move it to an option's visibleWhen. There it now meets this refusal, which names current_user.organizationId. The author gets there in two steps, and no message is false. Carrier: none.
  • Docs, noted. The variable-scope table in content/docs/data-modeling/formulas.mdx lists os.org / os.env as available in "predicates". That is broader than what the option check binds. Carrier: none.

Generated by Claude Code

claude added 4 commits October 9, 2026 01:07
…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>
@github-actions github-actions Bot added the size/m 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 6 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 (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/automation/hooks.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/data-modeling/seed-data.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/deployment/seed-tenancy-repair.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/kernel/events.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/kernel/runtime-services/audit-service.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/permissions/authentication.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/permissions/system-context.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/protocol/kernel/config-resolution.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))

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

  • content/docs/releases/index.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/releases/v16.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))
  • content/docs/releases/v17/17-5.mdx (via organizationId (literal, a string literal in optionVisibleWhenMemberIssue))

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 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 33 pages)
  • 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 00bee2724d5d5dcfab902f5208f1576be6ff1da3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cda028da584d176c5c2e17f14cda31747dead187 — the merge of head dffc512cf2bef65a8e3af54dd4350f0c017bcec0 into base 00bee2724d5d5dcfab902f5208f1576be6ff1da3, 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 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

⚠️ 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 00bee2724d5d5dcfab902f5208f1576be6ff1da3 → 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: 65ac7df278ede369d260ed195b0690a4e34f866d
Local-runs: none

Stamp: 2026-10-09T02:08Z · isolated at-tier reviewer, read-only: the card (#22274, body and comments 6059289972, 6071954817, 6072659815, 6072710367), PR #22392 (body, file list, the net diff git diff 117d34de3f 65ac7df278), the head's check-runs, and the binding text at 117d34de3f. Adversarial by design; the seat's ACCEPT was read as a claim to test, not as a finding.

Head, as found. origin/claude/issue-22274-option-visible-when-members is 65ac7df278, one changeset-wording commit on top of the merge 4e0f473df6 (origin/main 117d34de3f into f270d45a7e). The merge base is 117d34de3f. The merge commit's own diff against 117d34de3f is exactly the PR's four files (+412 / −7), so the merge carried main cleanly and touched nothing of the PR's.

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 judgments

Each one derived from the binding text at 117d34de3f, not from the report.

  1. OPTION_VISIBLE_WHEN_BOUND_MEMBERS = { ctx: ['user'], os: ['user'] } is exactly what buildScope mounts from the option check's context — HELD. evaluateOptionVisibility (rule-validator.ts) makes one call, ExpressionEngine.evaluate(expr, { record: merged, previous, user, permissions }). buildScope (formula/src/stdlib.ts) creates scope.ctx only inside if (ctx.user !== undefined) and fills it with user alone (the spread of a prior scope.ctx is empty here); it fills os.user in the same branch, os.org only from ctx.org, os.env only from ctx.env, and permissions mounts no variable (it reaches can through the environment). ctx.extra is not passed. So under ctx and os exactly one member is bound, user. Nothing bound is refused (every *.user read resolves to member user, accepted) and nothing unbound is accepted (org, env, locale, permissions, any other name is refused). The USER_SCOPE_ROOTS docblock beside the evaluator states the same exactness, independently.

  2. The member arm runs only when no unbound root is read, and picks one finding per option in a stable order — HELD. optionVisibleWhenRootIssue keeps its root test; the only change is that the kept.length === 0 return now hands off to optionVisibleWhenMemberIssue, so an unbound root still wins (pinned: ctx.locale == 'en' && input.k == 1 names input). The member arm walks SCOPE_ROOTS in order, skips roots not in the member map or not read, and within a root sorts the member names and takes the first unbound one. os sits at index 4 of SCOPE_ROOTS and ctx at index 22, so ctx.locale && os.env names os.env (pinned). Never AST walk order.

  3. The members are read through analyzeRelationshipTraversals, and that covers the claimed spellings — HELD. asMember in formula/src/relationship-traversal.ts recognises ., .?, [] with a literal string key and [?]; has(os.org) is reached through the inner member node of the macro and lands in bareFields. The arm unions traversals keys, bareFields and multiHopFields, and multi-hop is attributed to the FIRST hop, so ctx.user.positions and os.user.organizationId resolve to member user and pass. A computed key (os[k], os['o' + 'rg']) and a computed receiver (a ternary, a comprehension variable such as [os].all(o, o.org.id != '')) name no member and are not judged; the docblock and the changeset say so, and the computed-key half is on lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394. The comprehension-variable shape belongs to the same class and should be folded into lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394's statement of what no static verdict can judge.

  4. The verdict stops at the first member, so ctx.user.X is judged as the EvalUser — HELD, and the hole is filed. current_user, user, ctx.user and os.user are one object (ADR-0068 D1, buildScope), so its members are the same at every site and belong to no per-surface map. ctx.user.roles therefore passes this verdict while faulting at runtime (EvalUser has positions, ADR-0090 D3; createEvalUser emits no roles). lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394 is open and names exactly that level plus the computed key. One piece of evidence for lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394 from this read: EvaluateRulesOptions.currentUser in rule-validator.ts still TYPES the acting user as { id?, roles?, organizationId? } while buildEvalUser in engine.ts builds { id, positions, organizationId }; the stale type is the engine lane's, not this PR's surface.

  5. The current_user.organizationId remedy for os.org is true at the option check — HELD. engine.ts buildEvalUser returns { id, positions, organizationId: tenantId != null ? String(tenantId) : null } for every authenticated write and feeds it to evaluateValidationRules at all four call sites; toEvalUser passes a string or null through and createEvalUser keeps the key when it is not undefined. So current_user.organizationId is bound (string or null) wherever current_user is, and the system-write case takes the evaluator's no-acting-user branch by design. The lint test's OPTION_CHECK_CONTEXT user shape matches buildEvalUser's exactly.

  6. The message's claims match rule-validator.ts — HELD, one wording nit. The consequence clause (FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen']: fail-open, fault logged, value admitted, gate never enforced) is the evaluator's predicate-fault branch: logger.warn(... "the option's gate was NOT enforced on this write" ...) then continue; // fail-open. The changeset quotes that log line verbatim. Nit, not blocking: the tail "a has() test or an optional read of it finds it unset on every write" is true, but for that spelling the outcome is the opposite of "admitted": has(os.org) evaluates cleanly to false and the value is REFUSED on every write. Either way the gate is never the one the author declared, so the refusal is right; only the sentence conflates the two directions.

  7. The save door and the build give one verdict — HELD. The door's code is untouched; saveMetaItem runs runAuthoringRules, which runs the same validateStackExpressions pass at the build's position in the field walk. The protocol block pins 422 INVALID_METADATA with one expression-invalid issue at the option for each measured body, nothing landing, eight controls landing active, and door-versus-build equality on rule, where, path, message, hint. The test file notes correctly that metadata-protocol reaches lint through its built dist.

  8. The drift pin derives from the real buildScope and evaluator — HELD, with its one named blind spot. The lint test builds optionScope from the option check's context and fullScope from that context plus org and env, then for every member of fullScope[root] asserts accepted-and-evaluates when optionScope mounts it and refused-and-faults when it does not, pinning accepted = [ctx.user, os.user] and refused = [os.org, os.env]. A member buildScope starts mounting from { record, previous, user, permissions }, or user being dropped, turns it red. Not caught mechanically: ObjectQL's call shape changing (the option check starting to pass org), because lint cannot import ObjectQL; that direction rests on the two docblock rules (lint's constant and ObjectQL's USER_SCOPE_ROOTS). Named in the report and the PR; accepted as the honest limit.

  9. The corrected field-rule docblock is now true — HELD. The old sentence ("the option surface named by the first prescription binds the whole os namespace, not only its user member") was false at the server, per item 1. The new one says the server's option check fills os with user only and that a predicate moved there meets the member verdict. Comment only; the user-tier placement of os.org / os.env is unchanged.

② Semver level

  • @objectstack/lint minor with a BREAKING section — correct grade. AGENTS.md: (narrowing) is BREAKING; scripts/check-changeset-no-major.mjs states that in the launch window a breaking change ships as minor with the BREAKING banner and the ADR-0087 disposition as its carriers. The changeset has the fix(lint)! prefix, the BREAKING section naming what moves at the three doors, the remedy, and the Clause-② line. Check Changeset is green on the head.

  • @objectstack/metadata-protocol owes its own entry — NOT HELD. This is the finding that fails the record. The published behaviour that moves is the door's: the changeset's own BREAKING section names PUT /api/v1/meta/object/:name, saveMetaItem, publishMetaItem and publishPackageDrafts, all of them @objectstack/metadata-protocol surfaces, answering 422 where they answered 200. AGENTS.md: "Add a changeset for anything that publishes", and packages/*/CHANGELOG.md "ships inside the npm tarball as the text an upgrading agent greps". With updateInternalDependencies: "patch" and the fixed group, metadata-protocol's version moves either way, but its CHANGELOG would carry only an "Updated dependencies" line and never the BREAKING sentence a consumer of saveMetaItem greps for. The repo's practice in this exact seam is unanimous: .changeset/22019-object-save-door-formula-verdict.md, the four 22032-object-save-door-*.md entries and 22157-option-visible-when-parent.md all list "@objectstack/metadata-protocol": minor beside lint, finding(lint): a select option's visibleWhen reading parent passes os build and the object save door, and the server's option gate then faults open ("allowed through; the option's gate was NOT enforced") #22157 with test-only changes in metadata-protocol, as here. The dev raised it (deviation 2) and the seat deferred it to this review; the answer is that the sibling's shape is the rule. Fix: add the line "@objectstack/metadata-protocol": minor to the changeset frontmatter. One line, no other change; the BREAKING text already describes that package's doors.

  • Clause-②: no (narrowing: …) against what is published — HELD. The PR body's line 2 is the claim's line verbatim, and the changeset carries the same line. No export or signature moves: OPTION_VISIBLE_WHEN_BOUND_MEMBERS, celMemberPath and optionVisibleWhenMemberIssue are module-private, validateStackExpressions(stack) keeps its signature, and the only import change is in a test file (buildScope, ExpressionEngine, both already exported from formula's index). An accept set narrows; nothing widens. The reading is no (narrowing), the arm that clause2-line.mjs reserves for "not a widening, but breaking".

  • ADR-0087 not-required (no-migration-prescription) — HELD. No authorable key, spelling, export or stored shape moves; no stored row is read, rewritten or converted; a stored object whose option predicate is now refused keeps loading until next saved, and the repair is the author's rewrite of the predicate, which no ledger entry can derive. The gate refuses this category only when the body carries a consumer-code migration prescription, and it read this one as [BREAKING+bang+clause-②-narrowing] and passed (Check Changeset green). Same category, same argument and same gate reading as finding(lint): a select option's visibleWhen reading parent passes os build and the object save door, and the server's option gate then faults open ("allowed through; the option's gate was NOT enforced") #22157.

③ Boundary flags

Every deviation and out-of-scope finding in report 6072659815, answered or escalated:

  1. positions pinned instead of roles (deviation 1) — answered, correct. The dispatch's Zone 3 named ctx.user.roles as an accepted case; it IS accepted by this verdict (item ①-4) but faults at runtime since ADR-0090 D3, so pinning it as accepted would have pinned a never-enforced gate as a control. 'org_admin' in ctx.user.positions is the right control; the hole is escalated as lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394. Nothing for this PR.

  2. Lint-only changeset (deviation 2) — escalated to this record and answered in ②: the metadata-protocol entry is owed. One-line fix named above.

  3. Trailers (deviation 3) — answered, no deviation. AGENTS.md requires the model-free pair Claude-Session: plus Co-authored-by: Claude and says the pre-push hook refuses a model identifier in it; the repo's instructions take precedence over a harness attribution reminder. All three authored commits (69cb2dd5f5, f270d45a7e, 65ac7df278) carry exactly that pair; the merge commit carries none, as a merge does. The PR body ends with the session-URL footer AGENTS.md prescribes. Nothing to change.

  4. lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394 (EvalUser member level and computed key) — escalated, confirmed open. Its body matches the report's two findings (current_user.roles / ctx.user.roles faulting No such key: roles; os['o' + 'rg'] with no static verdict possible) and routes the build half to spec and the runtime fail-closed half to engine. Two additions for its thread, from this read: the stale roles? type on EvaluateRulesOptions.currentUser (①-4), and the computed-receiver shapes (①-3) that belong to the same "no static verdict" class as the computed key. It carries no labels yet; triage is pending, not this PR's.

  5. formulas.mdx scope table (noted, carrier none) — escalated as a docs item, not blocking. content/docs/data-modeling/formulas.mdx lines 61 to 62 and 211 to 212 say os.org and os.env are available in "predicates", which is broader than the option check binds, and this PR now refuses exactly that reading at one predicate slot. The docs-drift bot did not list this page (it matched ten other pages on the literal organizationId, the precision-first advisory it describes as its own blind spot for pages that state a rule by its inputs). A dedicated docs-only PR, or a row on the family closing card, should qualify the table by slot. Not a rider on this code PR.

  6. The two-step field-rule prescription (noted, carrier none) — answered, acceptable. The user tier's first prescription still sends a field-level os.org.id predicate to the option's visibleWhen, where the member verdict now names current_user.organizationId. Two steps, no false message, and the corrected docblock says so. A later lint UX pass could special-case os.org / os.env in the user tier to name the current_user remedy directly; it is not owed here.

  7. The main commits after f270d45a7e's base, which the merge carries — answered, clean. Exactly one: 117d34de3f (feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: sys_view_definition retires as inert (ADR-0131 D13, C5 stage S1) #22374, sys_view_definition retires as inert, ADR-0131 D13, touching metadata-core, metadata-protocol, metadata, platform-objects and spec). It touches none of this PR's files, the merge commit's diff against it is the PR's four files alone, and the dev re-ran both touched packages after the merge. The follow-up commit 65ac7df278 only removed tree-dependent corpus counts from the changeset, which is the right response to a count that moved under the merge.

Not in the report, found here, not blocking: the has() / optional-read wording nit (①-6).

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: claude/issue-22274-option-visible-when-members
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dffc512cf2bef65a8e3af54dd4350f0c017bcec0
Local-runs: none

Stamp: 2026-10-09T02:29Z · delta review of patch round 1, by the same isolated at-tier reviewer as record 6072804991 (FAIL on 65ac7df278). Read-only. Inputs: the delta git diff 65ac7df278 dffc512cf2, the dev's patch-round report 6072975174, the PR body as it now stands, the check-runs on dffc512cf2 read once, and the binding text at 117d34de3f where the reworded message needed judging. The seat's ACCEPT 6073001745 was read as a claim, not a finding. This record supersedes 6072804991 for the whole PR at this head; the earlier record's judgments ①-1 through ①-9 and its ② and ③ items are re-adopted below where the delta does not touch them.

Head, as found. origin/claude/issue-22274-option-visible-when-members is dffc512cf2, exactly one commit on 65ac7df278, no rebase, amend or force-push. Merge base is still 117d34de3f. Delta: 2 files, +3 / −1 (one changeset frontmatter line, one string literal). Net against the merge base: the same four files, +414 / −7. Commit trailers are the AGENTS.md model-free pair.

Check-runs on dffc512cf2, read once. No failure. Completed green: Build Core, Test Core (3/6), Test Core (4/6), Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Check Changeset (with the new frontmatter), Check PR Size, Dogfood Regression Gate (1/3, 2/3, 3/3 and rollup), Dogfood Verify CLI, Governed Surface Queue Guard, the four PR guards, Auto Label, Check Documentation Links, Flag docs affected by code changes. Skipped by filter: Build Docs, Console Pin Gate, Packed-tarball smoke (and one duplicate Check PR Size / Auto Label pair from a second workflow run). Still in progress at read time: Test Core (1/6), (2/6), (5/6), (6/6) and Type Check · workspace. Their conclusions are the gate verdicts when they land; the dev's local full runs at this head (lint 5871 passed, protocol 28325 passed, both typechecks OK) are the only evidence on them until then.

For the record on the superseded head: 65ac7df278's rollup Test Core reads failure because Test Core (1/6) was cancelled at 02:15:30Z, after the dffc512cf2 push at 02:14:32Z started its own runs at 02:14:42Z. That is the superseding push cancelling an in-flight shard, not a red test: shards 2 through 6, Temporal Conformance, Type Check · workspace and Lint & Repo Gates on 65ac7df278 all concluded green.

① Derived judgments

  1. Finding 1 of 6072804991 is closed by the frontmatter line — HELD. .changeset/22274-option-visible-when-members.md now lists "@objectstack/metadata-protocol": minor beside "@objectstack/lint": minor; nothing else in the changeset moved. The shape is now the one every door-crossing changeset in this seam carries (formula: the metadata save door stores a formula that calls an unregistered function (sqrt), and every read answers null with nothing logged — the docs say the shared validator backs metadata registration #22019, the four finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032 entries, finding(lint): a select option's visibleWhen reading parent passes os build and the object save door, and the server's option gate then faults open ("allowed through; the option's gate was NOT enforced") #22157), and the BREAKING section's named doors (PUT /api/v1/meta/object/:name, saveMetaItem, publishMetaItem, publishPackageDrafts) now reach that package's own CHANGELOG. Check Changeset is green on this head, so the no-major and ADR-0087 gates read the two-package frontmatter and the unchanged not-required (no-migration-prescription) disposition without complaint.

  2. The reworded message tail is true at the option check in both directions — HELD. The new tail: "a has() test or an optional read of it never finds it set, so the option is refused on every write instead, or admitted on every write when the test is negated or the read's default passes." Judged against the binding text, not the report:

    • The evaluator: every environment in cel-engine.ts is built with enableOptionalTypes: true, and relationship-traversal.ts's own docblock states that has(...), .? and [?] read a missing key as an ordinary false or default. At the option check buildScope mounts ctx and os as plain maps holding user only, so org, env and locale are missing keys there, never faults: has(os.org) and has(ctx.locale) are a clean false, os.?org is none.
    • rule-validator.ts: a clean false is the res.value === false branch, which pushes invalid_option and refuses the value; a clean true admits it; only !res.ok takes the fail-open branch.
    • So: has(os.org) refuses on every write (the leading clause); !has(os.org) is true and admits on every write (the negated test); os.?org.orValue({}) == {} is true and admits (the read's default passes); os.?env.orValue('dev') == 'prod' is false and refuses (covered by "refused on every write instead"). Both directions are stated and both are true. The dev's qualifier beyond the wording it was given was necessary: the leading clause alone would have been the inverse of the truth for the negated and defaulted spellings, exactly as the round-1 sentence was for the positive ones. The qualifier was measured by the dev through ExpressionEngine.evaluate with the option check's context, and the reading above derives the same answers from the source.
    • Scope, unchanged from the root message: on a system write ctx and os are not mounted at all, so these spellings fault and take the evaluator's no-acting-user branch, which this verdict does not judge by design.
    • The sentence now composes with the consequence clause before it without contradiction: a plain member read faults and is admitted; a null-safe spelling evaluates to a constant and the gate is never the one the author declared, in either direction. The refusal is right for both.
  3. Nothing else moved in the code — HELD. The delta touches no test. No pin asserted the old tail (a grep of both test files at dffc512cf2 for the old and the new wording finds nothing); the lint pins assert the stable fragments (reads \os.org`, the `user` member and nothing else, the current_user.organizationIdremedy), and the protocol parity block compares the door's and the build'smessagedynamically, so it stays a true parity test.OPTION_VISIBLE_WHEN_BOUND_MEMBERS, the member arm's control flow, the ordering, the spellings judged, the drift pin, the docblock correction and the door-side pins are byte-identical to 65ac7df; judgments ①-1 through ①-5 and ①-7 through ①-9 of 6072804991` stand unchanged, and ①-6's nit is resolved by item 2 above.

  4. The PR body matches the head — HELD, one stale line. The seat updated "Grade and changeset": the first bullet now names both packages and the round that added metadata-protocol, and the bullet that said metadata-protocol was not listed is gone. The dev did not PATCH the body, per os-dev, and named the exact edit in its report instead; that is the correct division. The "Local verification" section still names 65ac7df278; the dffc512cf2 runs are in report 6072975174. Cosmetic, not blocking.

② Semver level

  • @objectstack/lint minor and @objectstack/metadata-protocol minor, one changeset, BREAKING section, remedy, Clause-②: no (narrowing: …) line, ADR-0087 not-required (no-migration-prescription) — correct and complete. The grade is the launch-window grade for a narrowing (check-changeset-no-major.mjs; AGENTS.md: (narrowing) is BREAKING). The second package is the one whose published doors move, as its own BREAKING text says. The fixed group already moved its version; what the line adds is the CHANGELOG sentence in the metadata-protocol tarball, which AGENTS.md names as the changeset's purpose.
  • Clause-② reading unchanged: no (narrowing). No export or signature moves; the delta changes a string literal inside a module-private function and a frontmatter line.
  • ADR-0087 disposition unchanged and still honest: the delta moves no authorable key, export or stored shape, and the gate re-read it as [BREAKING+bang+clause-②-narrowing] with Check Changeset green.

③ Boundary flags

  1. Deviation 1 of the patch-round report (qualifier beyond the given wording) — answered: accepted, and required. Item ①-2. Had the dev shipped the sentence as handed to it, the message would have been false for !has(os.org) and for a defaulted read, and this record would have had to fail it on the same ground as round 1's nit. The one-string fallback the dev offers is declined.

  2. Deviation 2 (PR body not edited by the dev) — answered: correct under os-dev, and the seat made the edit. The body now agrees with the frontmatter. The stale 65ac7df278 in "Local verification" is noted in ①-4; the seat may touch it or leave it, since the report of record carries the dffc512cf2 runs.

  3. Deviation 3 (origin/main moved, not merged this round) — answered, clean. origin/main is 8 commits past 117d34de3f (now 11d119ab18). A diff of this PR's four files between 117d34de3f and origin/main is empty: none of those commits touches them. One (fix(spec,lint): one-line functional-completeness verdicts; os explain RULE_ID carries their reasoning #22383) touches other files under packages/lint (rule-explanations.ts, a functional-completeness test). The order asked for one commit on 65ac7df278 with no rebase, so not merging was right; the queue rebuilds on main, and the merge base stays 117d34de3f for this record.

  4. The lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394 additions (computed receivers, the stale roles? type on EvaluateRulesOptions.currentUser) — escalated by the dev to that card's thread, not this PR. Correct carrier; nothing for this PR.

  5. The formulas.mdx scope table and the two-step user-tier prescription — unchanged from 6072804991 ③-5 and ③-6: a docs-only follow-up or a family-card row, and a possible later lint UX pass. Neither is owed here.

  6. The main commits the merge carries — unchanged: exactly 117d34de3f (feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: sys_view_definition retires as inert (ADR-0131 D13, C5 stage S1) #22374), touching none of this PR's files.

Standing condition on landing, not on this verdict: the four in-progress Test Core shards and Type Check · workspace must conclude green on dffc512cf2; their conclusions are the gate verdicts. Nothing in the delta can move them (one frontmatter line, one string literal no test asserts), and the same shards concluded green on 65ac7df278 save the one the push cancelled.

Implemented-by: claude/issue-22274-option-visible-when-members
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 02:51
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 02:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit b1f7a7a Oct 9, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22274-option-visible-when-members branch October 9, 2026 03:29
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/m tests tooling

Projects

None yet

2 participants