Skip to content

fix(objectql)!: a faulting option visibleWhen refuses the write instead of admitting it (#22402) - #22436

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22402-option-gate-fails-closed
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22402-option-gate-fails-closed

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22402

Clause-②: no (narrowing)

The server's per-option gate (evaluateOptionVisibility in packages/objectql/src/validation/rule-validator.ts) now refuses a write whose picked option's visibleWhen cannot be evaluated. Before, it logged one warn line and admitted the value. This implements ruling 6074855432, letter A, maintainer 「299 同意」: ADR-0137 D2's submit-time refusal reaches the option gate on the write path, because that gate is the server's enforcement of who may pick the option (ADR-0124 D1). D3's render fail-open is unchanged. No new error code. BREAKING on @objectstack/objectql, shipped as minor under the launch-window convention.

What changed

  • The arm. The predicate-fault arm pushes a refusal into the call's ValidationError, built by the existing unevaluableRuleError through a new unevaluableOptionGateError. The result is VALIDATION_FAILED / 400 with one field entry per faulting pick: code: 'rule_violation', constraint.reason: 'unevaluable', constraint.rule: 'visibleWhen', constraint.fault, and the picked option as value. Example message: Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected. The operator still gets a warn with meta.reason: 'predicate-fault', now saying write rejected.
  • The no-acting-user arm is unchanged. A system write with no acting user whose predicate reads the acting user is still admitted and logged.
  • A system write whose predicate faults WITHOUT reading the acting user is refused too. That case was also in the predicate-fault arm. I checked the field-level D2 precedent before mirroring it, and neither arm exempts a write with no acting user:
    • the requiredWhen block binds no user at all, and refuses every fault. engine-field-predicate-fault.test.ts block (a) inserts with no context and is refused.
    • the readonlyWhen strip runs on isSystem writes and refuses. Its docblock says "isSystem is deliberately still NOT an exemption here".
  • The trailing sentence says what is true for this gate.
    • A read through a reference gets the traversal repair, worded for an option.
    • An unbound root names what the gate binds.
    • A key that is not a column of this record gets no "declare the field" sentence and no missingKey. That covers a computed key, a computed receiver, and a member of ctx / os or below a bound member.
  • The header note (:184 on base) is rewritten to match D2. So are:
    • the "Deliberately NOT changed" parenthetical;
    • the EvaluateRulesOptions.currentUser / permissions docs;
    • the evaluateOptionVisibility and readsUserRoot docblocks;
    • the unevaluableRuleError caller list.
  • A behaviour-preserving refactor for the field rules. The reference-traversal detection moved out of unevaluableFieldRuleError into a shared referenceReadThrough, so the field-rule and option-gate refusals word their own repair. The federated-readers ledger row (federated-injected-column-readers.test.ts) is renamed to the seam's new home.

Population census, measured before flipping the arm

Readings on origin/main 3054516:

  • Option predicates in examples/** and packages/qa/dogfood/**: 5, all in examples/app-showcase/src/data/objects/cascading-select.object.ts. Four are record.country == … cascades and one is the 'org_admin' in current_user.positions role gate.
    • Instrument: git grep -n visibleWhen over both trees. Every other hit is a field-level, row-CRUD, page or view predicate, or a ledger mention.
    • Control: the same grep finds the field-level visibleWhen on invoice.object.ts:152.
  • Did each predicate fault on a write? Measured through the built evaluateValidationRules:
shape verdict on base after
insert, country + matching province, acting user admitted admitted
insert, province without country (acting user or system) invalid_option: null == 'cn' is a clean false over the total record unchanged
update, patch province only, prior row lacks the column invalid_option: previous is made total unchanged
insert tier: restricted, non-admin / org_admin / system write refused / admitted / admitted (no-acting-user) unchanged
validate() in update mode, patch omits country (no prior row is read) predicate-fault, admitted refused (see Acceptance notes)
  • The ruling's cascade question. No write path faults when the column is absent from the payload. Insert, by-id update and per-row bulk update evaluate over a total record. The by-id update always reads the prior row. The bulk path takes the per-row branch for any object with an option gate, because fieldsNeedPrior counts option gates.
  • No seed or dogfood write picks a gated option. The showcase seed has no showcase_cascade rows. The dogfood persona matrix writes showcase_cascade with { name } only.
  • Producers fixed: none needed.

Call sites

  • evaluateOptionVisibility has one caller, evaluateValidationRules. Its engine call sites:
    • insert, single and batch, which includes insertMany (engine.ts:13904);
    • validate() (:13089);
    • by-id update (:15500);
    • bulk update per matched row (:15803);
    • bulk update with no prior row (:15819). This one is unreachable for an object with an option gate, because needsPriorRecord counts the gate.
  • The import's kept-option carve-out (ExecutionContext.keptOptionValues) admits values outside the options list. pickedGatedOptions skips a value that matches no option, so the carve-out is unaffected.

Pins: flipped, and new

Flipped. Each now asserts the refusal envelope, not only that the old assertion is gone.

  • rule-validator.option-visibility.test.ts:
    • "authenticated caller + a genuinely faulting predicate": asserts the full field entry (message, constraint, value) and the write rejected warn.
    • "system write + a predicate naming NO user root": now refused.
    • "NO permission data": now refused as unevaluable, never invalid_option.
  • engine-option-permission-predicate.test.ts "NO resolver ⇒ no permission data": refused, nothing stored, with an ungated pick on the same engine as the control.
  • engine-field-predicate-fault.test.ts block (d), the former "option visibleWhen … stays fail-open" control, is now the real-engine pin. It covers:
    • authenticated insert, system insert, by-id update and bulk update, each refused with the store read back unchanged;
    • validate() previewing the same refusal;
    • controls: an evaluable false gives invalid_option, true writes, and a system write of a user-gated option is still admitted.

New, in rule-validator.option-visibility.test.ts. Each of these refuses:

  • a computed key (os['o' + 'rg'].id);
  • computed receivers: a comprehension variable and a ternary;
  • a read one hop below a bound member (current_user.positions.x, ctx.user.organizationId.y), from the spec seat's cross-lane note 6075512559. Measured: No such key: x / y, refused;
  • a read through a reference;
  • an unbound root (type: Unknown variable: parent);
  • the other half of a user-root predicate, for an acting user;
  • the same predicates on update;
  • one entry per faulting pick of a multi-value field;
  • a fault joining a requiredWhen refusal in one ValidationError.

Controls, new:

  • an unpicked broken option is not judged;
  • a clean false gives invalid_option;
  • true admits with no warn;
  • no-acting-user is still admitted;
  • has() over the below-member hop is a clean false;
  • the shipped cascade shape is a clean false when the payload omits the parent.

Same-semantic pins swept across the repo:

  • packages/lint/src/validate-expressions.test.ts: the option slot's consequence: 'fail-OPEN' becomes the new sentence. Every slot now also asserts its message carries no fail-OPEN / is admitted / never enforced.
  • packages/qa/dogfood/test/expression-conformance.ledger.ts: row cel-select-option-visible goes from fail-soft-log to fail-closed, with its enforcement cell rewritten. Its proof is the flipped file.

Published text this makes false

Changed:

  • packages/lint/src/validate-expressions.ts:
    • FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen'], the sentence objectstack validate prints for a traversal, an unbound root or an unbound member. It now reads "The server REFUSES every write by an acting user that picks this option — an option gate it cannot evaluate refuses the write, naming the option, the field and the fault (ADR-0137 D2)".
    • the doc comments near :1022, :1135, :1170, :1387 and :2283.
  • packages/spec/src/data/field.zod.ts: the option visibleWhen JSDoc (:392 and the enforcement sentence above it) and its .describe(). Regenerated with check:generated --fix: content/docs/references/data/field.mdx, data/picklist.mdx, ui/view.mdx.
  • packages/cli/test/validate-field-predicate-traversal.test.ts: a header comment.

Already compliant:

  • the cascading-select.object.ts JSDoc ("fails open only when current_user is unbound (a system write)");
  • the engine.ts buildEvalUser doc (membership predicates fail open on system writes);
  • content/docs/protocol/objectui/layout-dsl.mdx:926, which lists per-option visibleWhen among the write-path predicates and states no direction;
  • no other content/docs/**, skills/** or README sentence states the option gate's fault direction (git grep over those trees).

Out of scope, with the reason:

  • The pending changesets 22032-object-save-door-option-visible-when.md, 22157-option-visible-when-parent.md and 22274-option-visible-when-members.md. They say a faulting option predicate is logged and admitted. Each records its own landing. Editing them here would make them false for any release cut before this PR lands. This changeset carries one sentence saying earlier entries in the same release describe the gate before this change.
  • packages/*/CHANGELOG.md and content/docs/releases/**. These are release-owned and accurate for their versions.
  • Past-tense history. validate-expressions.ts:2257 and two comments in protocol.runtime-authoring-gate.test.ts describe the hole as it was. They are left as history.

Verification

All on head 0da1d65 unless a sha is named. Local scope is targeted; CI runs the farm.

  • objectql:
    • full vitest run --project local at 95bdaf3: 388/389 files, 7681/7682 tests. The one red was federated-injected-column-readers.test.ts, whose ledger row named the seam's old function; it was fixed in 41c797b and re-run green.
    • objectql non-test sources are byte-identical from 95bdaf3 to head (git diff of the non-test sources prints 0 bytes).
    • the 4 touched files: 89/89 at 41c797b; the 3 option files 87/87 at 0da1d65.
    • typecheck (tsc + check:test-typecheck) exit 0.
  • lint: full suite 128 files, 5881 tests, plus typecheck, exit 0, at 5bee38e. lint is unchanged since then.
  • spec:
    • vitest run --project local: 627 files, 18757 tests passed (1 todo), at 5bee38e.
    • typecheck exit 0.
    • check:generated 15/15 after --fix regenerated check:docs.
  • dogfood: test/expression-conformance.test.ts 7/7. The first attempt failed to resolve @objectstack/verify (not built), so it measured nothing; it was rebuilt and re-run.
  • cli: test/validate-field-predicate-traversal.test.ts is integration-tier and its edit is comment-only. It is declared to CI and NOT MEASURED locally.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 115 families on 0da1d65, and all 115 exit 0.
    • Four of them first exited 3/1 because a stale build I had started was rebuilding dist/ under them: check:api-surface, check:skill-examples, check:dts-closure, check:dual-build-cjs-loads. That measured nothing. I rebuilt client-react and organizations and re-ran all four green.
    • --ran with exit codes: "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN".
    • The derivation warned the tree is 3 commits behind origin/main e02833c. Those commits touch none of this PR's files; CI's merge ref judges the join.
  • Ablation of the refusal. The fix was committed first. The tests import ./rule-validator.js and ./engine.js relatively, so they read src/ and no dist/ rebuild applies.
    • Tool: node scripts/ablation-replace.mjs with anchor errors.push(refusal); → void refusal;, the faulting gate admitted again.
    • The mutation landed: anchor 1→0, blob a08b8561dd03 → ea63d5eaec09.
    • Baseline 87/87. Mutated: 20 failed, 67 passed. Every new or flipped refusal pin went red and every control stayed green.
    • Restore: blob == HEAD (a08b8561dd03) and git diff HEAD is empty.

Acceptance notes

  • validate() in update mode reads no stored row. A field-level predicate reading a column the patch omits therefore faults there. That is an import dry run of a matched row. It already refuses for requiredWhen, with "which this object does not declare" although the column is declared. The option gate now joins it: before, it was a false all-clear; now the preview refuses a row the write may admit. The write itself reads the stored row.
    • Measured on 3054516: validate('showcase_cascade', { province: 'zj' }, { mode: 'update' }). The real by-id update over a stored cn row admits.
    • Reported for the seat to file. The engine's own validate() docblock names this limit for traversing rules only.
  • The stale EvaluateRulesOptions.currentUser type (rule-validator.ts:396, { id?; roles?; organizationId?; [k]: unknown }). The engine's buildEvalUser returns { id, positions, organizationId }, and roles is read nowhere in rule-validator.ts; its only hit is the type itself. Not fixed in place: it is a different defect class (type drift on the exported EvaluateRulesOptions), so condition ① of the bounded in-place fix does not hold.
  • The lint mirror OPTION_CHECK_ACTING_USER (PR 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; not on origin/main e02833c) has no key-set pin against buildEvalUser. Not built here. What this PR changes about that drift:
    • Before: if a key is removed from buildEvalUser while lint still admits it, a predicate reading it faulted open, admitted with a warn.
    • Now: it refuses the write, naming the fault.
    • So the drift turns from a silent open gate into a loud refusal at the first write; os build still passes it. carrier: none.
  • scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json. Its invariant says a broken predicate "still fails open, and requiredWhen / option visibleWhen are untouched", which has been stale since D2. carrier: none.
  • docs/qa/platform-checklist/areas/records-forms.json, the cascade item's source line, says "fail-open on unevaluable". It is a revisioned checklist item. carrier: none.
  • The dogfood ledger row cel-field-rule. Its comment still says fail-soft-log on all three slots, which has been stale since D2 for requiredWhen / readonlyWhen. carrier: none.

Patch round 1

Head 2c1c85aff. Contract review 6076709701 failed on ② only; every ① and ③ judgment there carries over.

  • ② fixed. The changeset's parenthetical "(ADR-0131 D9)" had been copied from another changeset, and it named a decision about organization stamps. It is replaced. The minor bump now cites the record of the launch-window convention (scripts/check-changeset-no-major.mjs), and the governing decision is ADR-0137 D2, extended to the option gate on the write path by the maintainer's ruling on 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 (letter A). Nothing else in the changeset moved.
  • origin/main merged (05c7c3fa3, merge commit 803e74cdf, no conflict). PR 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's lint changes were read:
  • ADR anchor scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json. The invariant no longer says a broken predicate fails open. It now states:
    • D2's refusal for requiredWhen, for readonlyWhen and for the option gate, with the no-acting-user case excepted;
    • what stays fail-open: the render side (ADR-0137 D3), a broken format or json_schema, and a rule that throws.
    • ADR-0137 is added to adrs.
  • Checklist records-forms.cascading-multilevel-and-clear: its source line for evaluateOptionVisibility said "fail-open on unevaluable", which this PR makes false. It is corrected, with revision 1 → 2 and a history entry. No step or oracle moves.
  • The dogfood ledger cel-field-rule row comment is left as it is. It has been stale since D2, not since this PR. Rewriting it honestly means deciding whether that row's failPolicy stays fail-soft-log after D2. That is a classification judgment, not a mechanical text fix, so bounded-in-place condition ② fails.
  • Lint wording (③ ① item 10) is kept: "every write by an acting user". The sentence is true in every message that carries it (a traversal, an unbound root, an unbound member). The broader statement would have to carry the no-acting-user exception, which applies only to predicates that read the acting user.
  • Correction to the round-0 reason for not editing the pending changesets. That reason ("would be false for a release cut before this lands") was backwards, because an edit lands only with this PR. The choice stands for a different reason: each entry records its own landing.

Verification on head 2c1c85aff, after pnpm install --frozen-lockfile and a rebuild of the @objectstack/lint... and @objectstack/objectql... closures:

  • spec check:generated: 15/15 up to date.
  • lint: full suite 128 files, 5894 tests, plus typecheck, exit 0.
  • objectql: full --project local 390 files, 7698 tests, plus typecheck, exit 0.
  • dispatch-gates derived 125 families on 2c1c85aff, including check:adr-anchors, check:platform-checklist and the changeset gates, and all 125 exit 0. check:skill-examples and check:dual-build-cjs-loads first exited 3 on unbuilt packages, which measured nothing. Both ran green after the battery's own full build. --ran with exit codes reports "125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN".

(Section written by the seat from the dev's patch-round report 6077384966.)


Generated by Claude Code

claude added 6 commits October 9, 2026 06:30
…d of admitting it

ADR-0137 D2 reaches the server option gate on the write path: an option
predicate that cannot be evaluated refuses the write through the field-rule
unevaluable envelope (constraint.rule 'visibleWhen', the option as value),
naming the option, the field and the fault. The no-acting-user arm (a system
write whose predicate reads the acting user) stays admitted and loud.

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
…ses the write

The objectstack validate consequence sentence for an option visibleWhen, its
doc comments, the SelectOption.visibleWhen JSDoc and describe, the dogfood
expression-conformance row and the changeset.

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
…leWhen description

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
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 protocol:data 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 3 package(s): @objectstack/lint, @objectstack/objectql, @objectstack/spec, touching 13 documentable anchor(s).

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

  • content/docs/ai/skills-reference.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/api/error-catalog.mdx (via rule_violation (literal, a string literal in a comment on a changed line))
  • content/docs/automation/flows.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/data-modeling/field-types.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/data-modeling/fields.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/data-modeling/formulas.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/data-modeling/schema-design.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/data-modeling/validation-rules.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/deployment/environment-variables.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/protocol/kernel/error-handling.mdx (via rule_violation (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectui/concept.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/protocol/objectui/layout-dsl.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/ui/pages.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/ui/views.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))

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

  • content/docs/releases/implementation-status.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v12.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v15.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v16.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v17/17-0.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v17/17-2.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v17/17-4.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v17/17-5.mdx (via rule_violation (literal, a string literal in a comment on a changed line), visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v17/17-6.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))
  • content/docs/releases/v17/17-7.mdx (via visibleWhen (literal, a string literal in a comment on a changed line; a string literal in unevaluableOptionGateError))

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
  • 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 — 140 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 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from df1f1b53cd944b23c933657f6a7bfe2fbcc5086a — the merge of head 2c1c85aff50aa5cb7505061096db75e8c990faf5 into base 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb, 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 df1f1b53cd944b23c933657f6a7bfe2fbcc5086a && git checkout df1f1b53cd944b23c933657f6a7bfe2fbcc5086a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb 2c1c85aff50aa5cb7505061096db75e8c990faf5 && git checkout -B drift-repro 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb && git merge --no-ff 2c1c85aff50aa5cb7505061096db75e8c990faf5

node scripts/docs-audit/affected-docs.mjs --json 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb

⚠️ 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 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb → 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: 0da1d65ec2616bd9119788b55b7264f8a7aad19d
Local-runs: none

Inputs: card #22402 (body and all five comments, the ruling 6074855432 letter A among them), PR #22436 (body, 14-file list, net diff from merge base 3054516ef1 to the head), the head's check-runs, and origin/main source read with git show / git grep where a consumer or a cited decision had to be judged. Nothing built, run or re-run; one in-memory git merge-tree --write-tree against origin/main (a read of objects, no checkout) because PR #22427 landed on the same lint file after this head was pushed.

Governance: no path in the file list is a governed surface; Governed Surface Queue Guard success. Head repo is the base repo; draft, auto-merge unarmed, 649+143 changed lines. This record exists because the ruling says "contract review before the queue", not because of a tier.

① Derived judgments

Accept-set changes the diff implies, each judged against the ruling (6074855432: the predicate-fault arm refuses with the existing field-rule unevaluable envelope; the no-acting-user arm unchanged; no new code; D3 render fail-open unchanged):

  1. Authenticated write picking an option whose visibleWhen faults (undeclared key, unbound root or member, read through a reference, computed key, computed receiver, a hop below a bound member, can with no permission data) is refused: VALIDATION_FAILED / 400, one field entry code: rule_violation, constraint.reason: unevaluable, constraint.rule: visibleWhen, constraint.fault, the picked option as value; nothing persisted. RIGHT. The envelope is built by the existing unevaluableRuleError through a module-private unevaluableOptionGateError; no error code is added (ADR-0112 untouched).
  2. A system write (no acting user) whose predicate faults WITHOUT reading a user root is refused too. RIGHT. The ruling flips the whole predicate-fault arm, and the decision record's option A spares only the case 无 acting user 且谓词读 current_user; this mirrors the field-level D2 precedent (requiredWhen binds no user and refuses every fault; the readonlyWhen strip refuses isSystem writes). Consequence named in the changeset: a seed carrying a gated value under a broken predicate now fails at seed time instead of logging. Census says no shipped seed or dogfood write picks a gated option.
  3. The no-acting-user arm (no acting user AND the predicate reads a user root, judged off the AST by readsUserRoot) stays admitted and logged. RIGHT, unchanged; the AST reader means the CEL message quirk (a user-root predicate whose other half faults reports No such key) cannot misfile a seeded row into refusal, and the authenticated half of that same predicate IS refused (pinned).
  4. current_user.can() with no effective-permission resolver registered (no security plugin composed): an authenticated pick of a can-gated option is refused naming the missing permission data, where before it was admitted with a warn. RIGHT, consumer-visible, stated in the changeset, pinned at unit level and through the real engine with an ungated control on the same engine.
  5. validate() previews the same refusal in insert mode. In update mode the preview reads no stored row, so a cascade predicate whose parent column the patch omits now refuses the preview row (an import dry run of a matched row: packages/core/src/utils/import-runner.ts previewVerdict marks it failed) where the real by-id update, which reads the stored row, admits. RIGHT to ship as disclosed in the changeset's "preview limit" paragraph: the class pre-exists for requiredWhen, and on the shipped corpus that very row was already refused through note's requiredWhen on base. The root cause is escalated in ③.
  6. Bulk update takes the per-row branch for any object with an option gate: fieldsNeedPrior counts fieldHasOptionVisibility (base rule-validator.ts:2855), so the previous: null branch is unreachable for such an object. RIGHT, verified on base source; the bulk path is pinned through the real engine with the store read back unchanged.
  7. The import kept-option carve-out (ExecutionContext.keptOptionValues): pickedGatedOptions yields only a value matching a declared option, so a kept value outside the list is not judged by this gate. RIGHT, unchanged (base :3031).
  8. Render side: no client change; the lint sentence, the spec description and the ledger row all keep D3's offered option. RIGHT.
  9. Public surface: the export delta over packages/objectql/src, packages/lint/src and packages/spec/src is empty; the four new functions are module-private; EvaluateRulesOptions changes JSDoc only; SelectOptionSchema.visibleWhen changes .describe() and JSDoc only (no shape change), with the three reference pages regenerated. RIGHT.
  10. Lint: FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen'], the author-facing sentence, now states the refusal; no verdict moves; the new pin asserts no slot's consequence still describes an admission. RIGHT. Wording note, not blocking: "every write by an acting user" is narrower than the behaviour (item 2), though not false for the traversal case it describes.
  11. Trailing-sentence logic: detail: '' suppresses both the generic "declare the field" sentence and missingKey through unevaluableRuleError's subject.detail === undefined test (base :3614), so a computed key, a computed receiver and a hop below a bound member carry the fault with no wrong repair; a read through a reference gets the option-worded traversal sentence; an unbound root names what the gate binds. RIGHT, pinned per shape.
  12. Pins the card asked for: computed key os['o' + 'rg'].id, computed receiver (comprehension variable and ternary), controls (clean false stays invalid_option, true admits with no warn, an unpicked broken option is not judged, no-acting-user admitted, the shipped cascade shape is a clean false when the payload omits the parent). Present. Plus: refusal on update, one entry per faulting pick of a multi-value field, a fault joining a requiredWhen refusal in one ValidationError, and the engine-level insert / system insert / by-id update / bulk update / validate() block with the store read back. RIGHT. The dev's ablation (anchor errors.push(refusal) to void refusal: 20 red, 67 green) is read, not re-run.
  13. Published text sweep: git grep over content/docs, skills and package sources at the head finds no remaining sentence saying the option gate admits a faulting predicate; the regenerated references match the new .describe(). RIGHT.

Check-runs on the head as read at 2026-10-09T07:43Z: success — Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate (1/3), (2/3) and (3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (3/6), The card this PR closes must claim this branch, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Auto Label, filter. in_progress — Lint & Repo Gates, Test Core (1/6), (2/6), (4/6), (5/6) and (6/6), Type Check · workspace. skipped — Console Pin Gate, Packed-tarball smoke (opt-in). No run had concluded failure. An in_progress run is a reading, not a pass: of the seven required contexts, Build Core, Governed Surface Queue Guard, Dogfood Regression Gate and Temporal Conformance had concluded success and Lint & Repo Gates, TypeScript Type Check (its workspace leg) and Test Core had not concluded at that reading.

② Semver level

  • .changeset/22402-option-gate-fails-closed.md: @objectstack/objectql: minor with the BREAKING banner (an accept-set narrowing on a published package ships minor during the launch window; scripts/check-changeset-no-major.mjs header). RIGHT. @objectstack/lint: patch (author-facing sentences; no verdict moves). RIGHT. @objectstack/spec: patch (description and JSDoc only; references regenerated). RIGHT. @objectstack/dogfood is private: true and @objectstack/cli gets a comment-only test edit: neither publishes anything from this diff. RIGHT. Check Changeset success.
  • Clause-②: no (narrowing) in the PR body and in the changeset body, matching the claim 6075451633 and the ruling: no new key on any published payload; the set of admitted writes narrows. RIGHT.
  • ADR-0087 marker not-required (no-migration-prescription): a recognised category (scripts/check-adr-0087-registration.mjs:497); no metadata key is removed, renamed or re-shaped, no export moves, so there is nothing for objectstack migrate meta to rewrite and no tombstone. RIGHT.
  • Migration sentence present and verbatim from the ruling: fix the predicate, which os build names for every statically judgeable shape. RIGHT.
  • The supersession sentence ("Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change") covers the pending entries 22032 / 22157 / 22274 named in the PR body AND .changeset/22394-option-visible-when-evaluser-members.md, which landed on main with PR 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 after this head and says at its line 27 that a faulting option predicate "is still logged and admitted". RIGHT, by its generic wording.
  • WRONG, blocking: the changeset sentence "shipped as minor under the repo's launch-window convention for breaking changes (ADR-0131 D9)". ADR-0131 is total organization ownership; its D9 is "A missing stamp is a refused write, in every posture" (docs/adr/0131-total-organization-ownership-no-null-organization-id.md:505), the decision that governed the 15195 changeset family this sentence was copied from (.changeset/15195-objectql-no-organization-refused.md:11, same words), and it decides nothing about an option gate. The decision governing this narrowing is ADR-0137 D2 as extended to the option gate on the write path by ruling 6074855432 (letter A). The changeset body ships to consumers as CHANGELOG.md and is the text an upgrading agent greps; a wrong provenance in a released entry can be amended only by a dedicated docs-only PR afterwards, so it is corrected before landing. Remedy: drop the parenthetical, or replace it with the governing decision and the ruling. Nothing else in the changeset moves. This is the one item the verdict turns on.

③ Boundary flags

The dev's open_questions is empty. Flags read from the PR's Acceptance notes, the report's deviations and out_of_scope_findings, and the cross-lane note 6075512559:

  1. validate() in update mode reads no stored row, so a preview refuses a row the write admits (named producer packages/core/src/utils/import-runner.ts previewVerdict; repro on 3054516ef1 in the report). ESCALATED: the seat files it as a defect card with the dev's repro and dedupe words (PD chore: version packages #10). Until then the changeset's "preview limit" paragraph is the consumer-facing disclosure. Not blocking.
  2. Stale EvaluateRulesOptions.currentUser type (roles?; buildEvalUser returns { id, positions, organizationId }, base engine.ts:5651). ESCALATED: the seat files it (type drift on an exported interface, a different class, correctly left out of this diff). Not blocking.
  3. Cross-lane item 1, the lint mirror OPTION_CHECK_ACTING_USER with no key-set pin against buildEvalUser: PR 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 into main after this head was pushed, so the constant is live on main and the pin is still unowned. The dev's reading is correct: with this PR a removed buildEvalUser key turns from a silent admission into a loud refusal at the first write. ESCALATED: the seat files the pin card (ObjectQL side, naming lint's constant), per the spec seat's ask. Not blocking.
  4. Cross-lane item 2, a read one hop below a bound member: ANSWERED in the diff, pinned for current_user.positions.x and ctx.user.organizationId.y (measured No such key, refused) with the has() control.
  5. Three stale internal texts each marked "carrier: none": (a) scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json, whose invariant still says a broken predicate "still fails open, and requiredWhen / option visibleWhen are untouched" (the gate is a presence check; ADR-0058 and ADR-0124 still appear at the head, 4 and 7 hits, so it stays green while telling the next author the wrong thing); (b) docs/qa/platform-checklist/areas/records-forms.json:3519, "fail-open on unevaluable"; (c) the dogfood ledger cel-field-rule row comment, fail-soft-log on all three slots, stale since D2. None is published, none is governed, (a) and (c) predate this PR. "carrier: none" is not an answer for an anchor on the very file this PR rewrote. ESCALATED to the seat as one docs-only follow-up card covering all three; the anchor edit may equally ride on this PR's next push, the dev's choice. Not blocking.
  6. The pending changesets that say "logged and admitted" are left as each landing's own record and superseded by one sentence here rather than edited. ANSWERED, right. The dev's stated reason (editing them "would make them false for a release cut before this PR lands") is backwards, since an edit lands only with this PR, but the choice stands on its own: an entry records its own landing and the compiled changelog reads coherently in order.
  7. Deviation: the branch was 3 commits behind origin/main at gate time and main has since taken PR 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 (packages/lint/src/validate-expressions.ts, its test, protocol.runtime-authoring-gate.test.ts, one changeset). The in-memory merge-tree returns a tree with no conflicted path and GitHub reports mergeable: true. ANSWERED: the queue builds the join. When the dev pushes the ② fix, merging origin/main first and reading 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's OPTION_CHECK_ACTING_USER docblock once for any "admitted" sentence this flip makes false is the cheap thing to do.
  8. Deviation: files beyond the claim's declared surface. ANSWERED, each declared in the PR body and within the same-semantic pin sweep, a regenerated reference page, a ledger row following the moved seam (federated-injected-column-readers.test.ts names referenceReadThrough), or a comment.
  9. Deviation: the dev killed its own orphaned gate runner by a recorded PID. ANSWERED, within the process discipline.
  10. The report's NOT MEASURED declarations (cli integration test, comment-only; dogfood first attempt unresolved @objectstack/verify, rebuilt and re-run) are read as honest; CI is the measurement.

Resubmission is mechanical: one changeset edit on a new head, origin/main merged in, and a fresh record on that head; every ① and ③ judgment above carries over unless the diff outside .changeset/ changes.

Implemented-by: claude/issue-22402-option-gate-fails-closed
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: FAIL

claude added 2 commits October 9, 2026 07:46
…iant, checklist source line

The changeset names the launch-window convention's record and ADR-0137 D2 as
extended by the ruling, instead of ADR-0131 D9. The rule-validator ADR anchor's
invariant and the cascade checklist item's source line no longer say the option
gate fails open. The lint docblock merged from main says a measured admission
now refuses.

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2c1c85aff50aa5cb7505061096db75e8c990faf5
Local-runs: none

Inputs: card #22402 (body and all six comments: the decision record 6074111128, the ruling 6074855432 letter A, the claim 6075451633, the cross-lane note 6075512559, the round-0 report 6076514445 and the patch-round report 6077384966), PR #22436 (body with its "Patch round 1" section, the 16-file list, the net diff from the merge base 05c7c3fa3b on main to this head), the earlier record 6076709701 on head 0da1d65ec (FAIL on ② only), the head's check-runs, and origin/main source read with git show / git grep where a cited decision or a consumer had to be judged. Nothing built, run or re-run. The merge commit 803e74cdf was checked as an object read: git merge-tree --write-tree of the old merge base 05c7c3fa3b and 0da1d65ec yields tree 835ccfa2cd, which is the merge commit's own tree, so the merge of origin/main carried nothing of its own. The patch commit 2c1c85aff touches four files: the changeset (one sentence), the ADR anchor JSON, the checklist JSON and one lint docblock. The refusal code is byte-identical to the head the earlier record read.

Governance: no path in the file list is on the GOVERNED_SURFACES register (scripts/adr-anchors/ and docs/qa/ are not rows); Governed Surface Queue Guard success. Head repo is the base repo; draft, auto-merge unarmed, 664+150 changed lines, mergeable_state: clean. This record is the contract review the ruling put before the queue.

① Derived judgments

Judged on this head's net diff against the ruling (6074855432: the predicate-fault arm refuses with the existing field-rule unevaluable envelope naming the option, the field and the fault; the no-acting-user arm unchanged; no new code; D3 render fail-open unchanged). Nothing in the patch commit moves an accept set, so each judgment below is re-read on this head rather than inherited.

  1. Authenticated write picking an option whose visibleWhen faults (undeclared key, unbound root or member, read through a reference, computed key, computed receiver, a hop below a bound member, can with no permission data) is refused: VALIDATION_FAILED / 400, one field entry code: rule_violation, constraint.reason: unevaluable, constraint.rule: visibleWhen, constraint.fault, the picked option as value; nothing persisted (errors.push(refusal); continue; at rule-validator.ts:3040). RIGHT. Built by the existing unevaluableRuleError through the module-private unevaluableOptionGateError; no error code added.
  2. A system write (no acting user) whose predicate faults WITHOUT reading a user root is refused too. RIGHT: the ruling flips the whole predicate-fault arm and spares only the case with no acting user AND a predicate that reads one; this mirrors requiredWhen (binds no user, refuses every fault) and the readonlyWhen strip (refuses isSystem writes). Stated in the changeset's "Unchanged" paragraph.
  3. The no-acting-user arm (no acting user AND readsUserRoot true off the AST) stays admitted with the warn, continue before the refusal. RIGHT, unchanged; its pin is kept at unit and engine level, and the authenticated half of the same predicate is pinned refused.
  4. current_user.can() with no permission data (no resolver composed): an authenticated pick of a can-gated option is refused naming the missing permission data. RIGHT, consumer-visible, stated in the changeset, pinned at unit level and through the real engine with an ungated control on the same engine and nothing persisted.
  5. validate() previews the same refusal in insert mode; in update mode the preview reads no stored row, so a cascade predicate whose parent column the patch omits refuses the preview where the by-id write admits. RIGHT to ship as disclosed in the changeset's "preview limit" paragraph; the class pre-exists for requiredWhen. Root cause escalated in ③.
  6. Bulk update takes the per-row branch for any object with an option gate (fieldsNeedPrior counts fieldHasOptionVisibility), so the previous: null branch is unreachable for such an object. RIGHT; pinned through the real engine with both rows read back unchanged.
  7. The import kept-option carve-out (ExecutionContext.keptOptionValues): pickedGatedOptions yields only a value matching a declared option, so a kept value outside the list is not judged. RIGHT, unchanged.
  8. Render side: no client change; the lint sentence, the spec description and the dogfood ledger row all keep D3's offered option. RIGHT.
  9. Public surface: no export moves in packages/objectql/src, packages/lint/src or packages/spec/src; celSourceOfPredicate, referenceReadThrough, readsRecordColumn and unevaluableOptionGateError are module-private; EvaluateRulesOptions changes JSDoc only; SelectOptionSchema.visibleWhen changes .describe() and JSDoc only, with the three reference pages regenerated byte-for-byte to the new description. RIGHT. Type Check · source gates and Lint & Repo Gates (which carries check:api-surface) success.
  10. Lint: FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen'] states the refusal; no verdict moves; the test asserts no slot's consequence still describes an admission. The one docblock from PR 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 that recorded a measured admission (optionVisibleWhenUserMembers) gains the clarifier that the fault now refuses. RIGHT. The wording "every write by an acting user" is kept, by the dev's choice; it is true in every message that carries it and the earlier record already called it non-blocking.
  11. Trailing-sentence logic in unevaluableOptionGateError: detail: '' through unevaluableRuleError's subject.detail === undefined test suppresses both the generic "declare the field" sentence and missingKey for a key that is not a column of this record (readsRecordColumn over record and previous analyses); a read through a reference gets the option-worded traversal sentence; an unbound root names what the gate binds. RIGHT, pinned per shape.
  12. Pins the ruling and the card asked for: computed key os['o' + 'rg'].id, computed receiver (comprehension variable and ternary), a hop below a bound member (the cross-lane note's case, with the has() control), controls (clean false stays invalid_option, true admits with no warn, an unpicked broken option is not judged, no-acting-user admitted, the shipped cascade shape is a clean false when the payload omits the parent). Present. Plus refusal on update, one entry per faulting pick of a multi-value field, a fault joining a requiredWhen refusal in one ValidationError, and the engine block (authenticated insert, system insert, by-id update, bulk update, validate()) with the store read back. RIGHT. The dev's round-0 ablation (20 red, 67 green) is read, not re-run; the refusal code has not moved since.
  13. Published and internal text on this head: git grep over content/docs (release-owned pages excluded), skills, packages/*/src, packages/qa and examples finds no sentence saying the option gate admits a faulting predicate, other than past-tense history (validate-expressions.ts:2469, protocol.runtime-authoring-gate.test.ts:2222, validate-expressions.ts:1274 "it was fail-OPEN until then") and the no-acting-user warn text, which is the arm that is still admitted. Field- and section-level render fail-open sentences (view.zod.ts:4125, object.form.ts:326) are D3 and untouched. RIGHT. The ADR anchor invariant and the checklist source line, the two stale internal texts the earlier record named on the very file this PR rewrote, are corrected on this head: the anchor adds ADR-0137 (34 hits in the file; ADR-0058 D5, ADR-0124 D1/D2/D3, ADR-0137 D2/D3 all resolve to headed decisions) and its invariant now states D2's refusal with the no-acting-user exception and what stays fail-open; the checklist item records-forms.cascading-multilevel-and-clear moves revision 1 to 2 with a matching history entry and no step or oracle change. RIGHT; check:adr-anchors and check:platform-checklist ride in Lint & Repo Gates, success.

Check-runs on the head as read at 2026-10-09T08:43Z: every run concluded; none failed. Success: Build Core, Build Docs, Check Changeset, Check Documentation Links, Dogfood Regression Gate (and 1/3, 2/3, 3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (and 1/6 through 6/6), The card this PR closes must claim this branch, Type Check · workspace / source gates / consumer gates / debt ledger, TypeScript Type Check, filter. Skipped: Console Pin Gate and Packed-tarball smoke (path-filtered and opt-in), and Auto Label and Check PR Size on the body-edit re-run only (both success on the push run). All seven required contexts are success.

② Semver level

  • .changeset/22402-option-gate-fails-closed.md: @objectstack/objectql: minor with the BREAKING banner (an accept-set narrowing on a published package ships minor during the launch window). RIGHT. @objectstack/lint: patch (author-facing sentences and docblocks; no verdict moves). RIGHT. @objectstack/spec: patch (description and JSDoc only; references regenerated). RIGHT. @objectstack/dogfood is private and @objectstack/cli takes a comment-only test edit: neither publishes anything from this diff. RIGHT. Check Changeset success on both runs.
  • Clause-②: no (narrowing) in the PR body and in the changeset body, matching the claim and the ruling: no new key on any published payload; the set of admitted writes narrows. RIGHT.
  • ADR-0087 marker not-required (no-migration-prescription): no metadata key is removed, renamed or re-shaped, no export moves, nothing for objectstack migrate meta to rewrite, no tombstone. RIGHT.
  • Migration sentence present and verbatim from the ruling: fix the predicate, which os build names for every statically judgeable shape. RIGHT.
  • The supersession sentence covers the pending entries 22032 / 22157 / 22274 and the landed 22394-option-visible-when-evaluser-members.md:27, each of which still says a faulting option predicate is logged and admitted. RIGHT by its generic wording; each entry stays its own landing's record.
  • The ② item the earlier record failed on is fixed: the provenance sentence now reads "shipped as minor under the launch-window convention for breaking changes (recorded in scripts/check-changeset-no-major.mjs). The governing decision is ADR-0137 D2, which the maintainer's ruling on 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 (letter A) extended to the option gate on the write path." Verified: that script's header is where the convention and its GA end condition are recorded; ADR-0137 D2 is the decision the ruling extends; the ruling is letter A. The wrong "(ADR-0131 D9)" is gone and nothing else in the changeset moved (git diff 803e74cdf..2c1c85aff on the file is that one line). RIGHT.

③ Boundary flags

The dev's open_questions is empty in both reports. Flags read from the PR's Acceptance notes and "Patch round 1" section, the two reports' deviations and out_of_scope_findings, the earlier record's ③ and the cross-lane note 6075512559:

  1. validate() in update mode reads no stored row, so an import dry run refuses a matched row the write admits (named producer packages/core/src/utils/import-runner.ts previewVerdict; repro in report 6076514445). ESCALATED, unchanged from the earlier record: the seat files it as a defect card with the dev's repro and dedupe words. The changeset's "preview limit" paragraph is the consumer-facing disclosure meanwhile. Not blocking.
  2. Stale EvaluateRulesOptions.currentUser type (roles?; buildEvalUser returns { id, positions, organizationId }). ESCALATED, unchanged: the seat files it (type drift on an exported interface, correctly left out of this diff). Not blocking.
  3. Cross-lane item 1, the lint mirror OPTION_CHECK_ACTING_USER with no key-set pin against buildEvalUser, now live on main through PR 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 and merged into this head: with this PR a removed buildEvalUser key turns from a silent admission into a loud refusal at the first write. ESCALATED, unchanged: the seat files the pin card on the ObjectQL side naming lint's constant. Not blocking.
  4. Cross-lane item 2, a read one hop below a bound member: ANSWERED in the diff, pinned for current_user.positions.x and ctx.user.organizationId.y with the has() control.
  5. The earlier record's three stale internal texts: (a) the ADR anchor invariant and (b) the checklist source line are ANSWERED on this head (① item 13). (c) The dogfood ledger cel-field-rule row comment, fail-soft-log on all three slots and stale since D2, is left with the dev's reason that rewriting it decides that row's failPolicy, a classification, not a mechanical text fix. ESCALATED, reduced to that one row: the seat's docs-only follow-up card now covers (c) alone. Not blocking.
  6. The pending changesets that say "logged and admitted" are left as each landing's own record and superseded by one sentence here. ANSWERED; the dev's corrected reason (an edit lands only with this PR; the choice stands because each entry records its own landing) is the right one.
  7. Lint wording "every write by an acting user" kept. ANSWERED (① item 10): true in every message that carries it; the broader form would have to carry the no-acting-user exception, which applies only to user-reading predicates.
  8. Deviation: this round's file surface beyond round 0 (the anchor JSON, the checklist JSON, one lint docblock that came in with the merge). ANSWERED: the first two were directed by the earlier record and the seat; the third is the one 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 sentence this flip made false, and the dev read the rest of 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's text (its constant's docblock, its member refusals, its test and gate comments as past-tense history) before leaving it.
  9. Deviation carried from round 0: the dev killed its own orphaned gate runner by a recorded PID. ANSWERED, within the process discipline.
  10. The reports' NOT MEASURED declarations (round 0: the cli integration test, comment-only; dogfood first attempt unresolved and re-run; round 1: check:skill-examples and check:dual-build-cjs-loads first exiting 3 on an unbuilt worktree, re-run green after the build) are read as honest; CI is the measurement and every gate-carrying job concluded success.
  11. origin/main has moved one commit past this head's merge base (081e6a09d, docs on ISecurityService); it touches none of this PR's files and GitHub reports the head mergeable and clean. ANSWERED: the queue builds the join.

Landing: the ruling's "contract review before the queue" is satisfied by this record. The PR is draft and unarmed; the owning seat readies it and arms auto-merge on green, and files the four cards above (①5 preview defect, the currentUser type drift, the OPTION_CHECK_ACTING_USER key-set pin, the cel-field-rule ledger row) so none of them rides on memory.

Implemented-by: claude/issue-22402-option-gate-fails-closed
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 08:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 08:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 440bed6 Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22402-option-gate-fails-closed branch October 9, 2026 09:23
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 protocol:data size/l tests tooling

Projects

None yet

2 participants