Repository navigation
fix(objectql)!: a faulting option visibleWhen refuses the write instead of admitting it (#22402) - #22436
Conversation
…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>
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>
…e seam 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>
…ite path Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 10 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: 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 Governance: no path in the file list is a governed surface; ① Derived judgmentsAccept-set changes the diff implies, each judged against the ruling (6074855432: the
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
③ Boundary flagsThe dev's
Resubmission is mechanical: one changeset edit on a new head, Implemented-by: VERDICT: FAIL |
Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…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>
Contract reviewServed-tier: 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 Governance: no path in the file list is on the ① Derived judgmentsJudged on this head's net diff against the ruling (6074855432: the
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
③ Boundary flagsThe dev's
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 Implemented-by: VERDICT: PASS |
Fixes #22402
Clause-②: no (narrowing)
The server's per-option gate (
evaluateOptionVisibilityinpackages/objectql/src/validation/rule-validator.ts) now refuses a write whose picked option'svisibleWhencannot 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 asminorunder the launch-window convention.What changed
predicate-faultarm pushes a refusal into the call'sValidationError, built by the existingunevaluableRuleErrorthrough a newunevaluableOptionGateError. The result isVALIDATION_FAILED/ 400 with one field entry per faulting pick:code: 'rule_violation',constraint.reason: 'unevaluable',constraint.rule: 'visibleWhen',constraint.fault, and the picked option asvalue. Example message:Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.The operator still gets awarnwithmeta.reason: 'predicate-fault', now sayingwrite rejected.no-acting-userarm is unchanged. A system write with no acting user whose predicate reads the acting user is still admitted and logged.predicate-faultarm. I checked the field-level D2 precedent before mirroring it, and neither arm exempts a write with no acting user:requiredWhenblock binds no user at all, and refuses every fault.engine-field-predicate-fault.test.tsblock (a) inserts with no context and is refused.readonlyWhenstrip runs onisSystemwrites and refuses. Its docblock says "isSystemis deliberately still NOT an exemption here".missingKey. That covers a computed key, a computed receiver, and a member ofctx/osor below a bound member.:184on base) is rewritten to match D2. So are:EvaluateRulesOptions.currentUser/permissionsdocs;evaluateOptionVisibilityandreadsUserRootdocblocks;unevaluableRuleErrorcaller list.unevaluableFieldRuleErrorinto a sharedreferenceReadThrough, 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/main3054516:examples/**andpackages/qa/dogfood/**: 5, all inexamples/app-showcase/src/data/objects/cascading-select.object.ts. Four arerecord.country == …cascades and one is the'org_admin' in current_user.positionsrole gate.git grep -n visibleWhenover both trees. Every other hit is a field-level, row-CRUD, page or view predicate, or a ledger mention.visibleWhenoninvoice.object.ts:152.evaluateValidationRules:invalid_option:null == 'cn'is a clean false over the total recordinvalid_option:previousis made totaltier: restricted, non-admin /org_admin/ system writeno-acting-user)validate()inupdatemode, patch omitscountry(no prior row is read)predicate-fault, admittedfieldsNeedPriorcounts option gates.showcase_cascaderows. The dogfood persona matrix writesshowcase_cascadewith{ name }only.Call sites
evaluateOptionVisibilityhas one caller,evaluateValidationRules. Its engine call sites:insert, single and batch, which includesinsertMany(engine.ts:13904);validate()(:13089);update(:15500);updateper matched row (:15803);updatewith no prior row (:15819). This one is unreachable for an object with an option gate, becauseneedsPriorRecordcounts the gate.ExecutionContext.keptOptionValues) admits values outside the options list.pickedGatedOptionsskips 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:constraint,value) and thewrite rejectedwarn.unevaluable, neverinvalid_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.tsblock (d), the former "optionvisibleWhen… stays fail-open" control, is now the real-engine pin. It covers:validate()previewing the same refusal;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:os['o' + 'rg'].id);current_user.positions.x,ctx.user.organizationId.y), from the spec seat's cross-lane note 6075512559. Measured:No such key: x/y, refused;type: Unknown variable: parent);requiredWhenrefusal in oneValidationError.Controls, new:
invalid_option;no-acting-useris still admitted;has()over the below-member hop is a clean false;Same-semantic pins swept across the repo:
packages/lint/src/validate-expressions.test.ts: the option slot'sconsequence: 'fail-OPEN'becomes the new sentence. Every slot now also asserts its message carries nofail-OPEN/is admitted/never enforced.packages/qa/dogfood/test/expression-conformance.ledger.ts: rowcel-select-option-visiblegoes fromfail-soft-logtofail-closed, with its enforcement cell rewritten. Itsproofis the flipped file.Published text this makes false
Changed:
packages/lint/src/validate-expressions.ts:FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen'], the sentenceobjectstack validateprints 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)".:1022,:1135,:1170,:1387and:2283.packages/spec/src/data/field.zod.ts: the optionvisibleWhenJSDoc (:392and the enforcement sentence above it) and its.describe(). Regenerated withcheck: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:
cascading-select.object.tsJSDoc ("fails open only whencurrent_useris unbound (a system write)");engine.tsbuildEvalUserdoc (membership predicates fail open on system writes);content/docs/protocol/objectui/layout-dsl.mdx:926, which lists per-optionvisibleWhenamong the write-path predicates and states no direction;content/docs/**,skills/**or README sentence states the option gate's fault direction (git grepover those trees).Out of scope, with the reason:
22032-object-save-door-option-visible-when.md,22157-option-visible-when-parent.mdand22274-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.mdandcontent/docs/releases/**. These are release-owned and accurate for their versions.validate-expressions.ts:2257and two comments inprotocol.runtime-authoring-gate.test.tsdescribe 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.
vitest run --project localat 95bdaf3: 388/389 files, 7681/7682 tests. The one red wasfederated-injected-column-readers.test.ts, whose ledger row named the seam's old function; it was fixed in 41c797b and re-run green.git diffof the non-test sources prints 0 bytes).typecheck(tsc + check:test-typecheck) exit 0.typecheck, exit 0, at 5bee38e. lint is unchanged since then.vitest run --project local: 627 files, 18757 tests passed (1 todo), at 5bee38e.typecheckexit 0.check:generated15/15 after--fixregeneratedcheck:docs.test/expression-conformance.test.ts7/7. The first attempt failed to resolve@objectstack/verify(not built), so it measured nothing; it was rebuilt and re-run.test/validate-field-predicate-traversal.test.tsis integration-tier and its edit is comment-only. It is declared to CI and NOT MEASURED locally.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 115 families on 0da1d65, and all 115 exit 0.dist/under them:check:api-surface,check:skill-examples,check:dts-closure,check:dual-build-cjs-loads. That measured nothing. I rebuiltclient-reactandorganizationsand re-ran all four green.--ranwith exit codes: "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN".origin/maine02833c. Those commits touch none of this PR's files; CI's merge ref judges the join../rule-validator.jsand./engine.jsrelatively, so they readsrc/and nodist/rebuild applies.node scripts/ablation-replace.mjswith anchorerrors.push(refusal);→void refusal;, the faulting gate admitted again.git diff HEADis empty.Acceptance notes
validate()inupdatemode 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 forrequiredWhen, 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.validate('showcase_cascade', { province: 'zj' }, { mode: 'update' }). The real by-id update over a storedcnrow admits.validate()docblock names this limit for traversing rules only.EvaluateRulesOptions.currentUsertype (rule-validator.ts:396,{ id?; roles?; organizationId?; [k]: unknown }). The engine'sbuildEvalUserreturns{ id, positions, organizationId }, androlesis read nowhere inrule-validator.ts; its only hit is the type itself. Not fixed in place: it is a different defect class (type drift on the exportedEvaluateRulesOptions), so condition ① of the bounded in-place fix does not hold.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 onorigin/maine02833c) has no key-set pin againstbuildEvalUser. Not built here. What this PR changes about that drift:buildEvalUserwhile lint still admits it, a predicate reading it faulted open, admitted with a warn.os buildstill 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.cel-field-rule. Its comment still saysfail-soft-logon all three slots, which has been stale since D2 forrequiredWhen/readonlyWhen. carrier: none.Patch round 1
Head
2c1c85aff. Contract review 6076709701 failed on ② only; every ① and ③ judgment there carries over.minorbump 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:evaluateOptionVisibilitycontinues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402 (letter A). Nothing else in the changeset moved.origin/mainmerged (05c7c3fa3, merge commit803e74cdf, 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:OPTION_CHECK_ACTING_USERdocblock states no admission.FIELD_TRAVERSAL_CONSEQUENCE['option visibleWhen'], which already states the refusal.optionVisibleWhenUserMembers) records an admission measured before that verdict existed. It gains: "(Since objectql:evaluateOptionVisibilitycontinues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402 such a fault refuses the write instead, ADR-0137 D2; the build verdict still names it first.)"protocol.runtime-authoring-gate.test.tscomments are past-tense measurements, left as history.22394-option-visible-when-evaluser-members.md:27) stays as its own landing's record. The supersession sentence here covers it.scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json. The invariant no longer says a broken predicate fails open. It now states:requiredWhen, forreadonlyWhenand for the option gate, with the no-acting-user case excepted;formatorjson_schema, and a rule that throws.ADR-0137is added toadrs.records-forms.cascading-multilevel-and-clear: its source line forevaluateOptionVisibilitysaid "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.cel-field-rulerow comment is left as it is. It has been stale since D2, not since this PR. Rewriting it honestly means deciding whether that row'sfailPolicystaysfail-soft-logafter D2. That is a classification judgment, not a mechanical text fix, so bounded-in-place condition ② fails.Verification on head
2c1c85aff, afterpnpm install --frozen-lockfileand a rebuild of the@objectstack/lint...and@objectstack/objectql...closures:spec check:generated: 15/15 up to date.typecheck, exit 0.--project local390 files, 7698 tests, plustypecheck, exit 0.dispatch-gatesderived 125 families on2c1c85aff, includingcheck:adr-anchors,check:platform-checklistand the changeset gates, and all 125 exit 0.check:skill-examplesandcheck:dual-build-cjs-loadsfirst exited 3 on unbuilt packages, which measured nothing. Both ran green after the battery's own full build.--ranwith 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