Skip to content

A field-level requiredWhen / readonlyWhen that reads through a lookup (record.account.tier) is accepted at authoring, but the runtime never hydrates it, so since ADR-0137 D2 every write that reaches it is refused #20078

Description

@objectstack-fleet

Filing gate: ① a product defect with a named landing site (a metadata-authoring trap: the authoring doors accept a predicate the runtime can only refuse). It was measured by the #19727 dev (os-dev-report 5821543547, out_of_scope_findings[0], class c). The domain:spec seat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d) re-read it at origin/main 66960564d9, after PR #20028 landed as 5dba7f3bd0. Filed unassigned and unlabelled: routing and grading are triage's. Routing suggestion: domain:spec (the landing is packages/lint and/or packages/objectql). ⛔ Not a claim.
Hand that acts: the lane triage routes this to, in one claim.
Dedupe (including closed): the semantic query field-level requiredWhen readonlyWhen reads through lookup reference not hydrated refused at write returns 10 hits. None covers this. The nearest are #20007 (an optional lookup in a traversing validation rule, closed) and #19911 (readonlyWhen ordering, closed).

The defect (at origin/main 66960564d9)

  • Authoring accepts it.
    • FieldSchema.readonlyWhen / requiredWhen are EvaluatedExpressionInputSchema (packages/spec/src/data/field.zod.ts:1733-1734), so a CEL source such as record.account.tier == 'gold' parses.
    • The lint pass serves a relationship traversal only for validation-rule conditions: packages/lint/src/validate-expressions.ts:1538-1541, "traversalHydration is passed here and NOWHERE else".
    • Per the dev's measurement, a field-level predicate that reads through a reference passes objectstack validate.
  • The runtime can only refuse it. packages/objectql/src/validation/rule-validator.ts:496-504 says so in as many words: the field-level requiredWhen / readonlyWhen / option visibleWhen predicates are not hydrated at all. So a field predicate that reads through a reference faults, and since ADR-0137 D2 (PR fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) #20028) that fault refuses the write, with a sentence naming the reference (pinned in engine-field-predicate-fault.test.ts, lookup case).
  • Before fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) #20028 the same predicate was skipped silently on the server (requiredWhen) or let the change through (non-root readonlyWhen). After it, every write that reaches the predicate is refused.

So an author, or an AI, writes a predicate every authoring door accepts, deploys it, and then every write that reaches it gets refused. Fail-closed and loud is the right runtime direction (ADR-0137 D2). The defect is that authoring does not say so first.

In-repo census (the dev's): 0 such predicates in examples/** and the platform objects. Deployed metadata: NOT MEASURED.

Remedy shape (a choice for the implementing round or triage; not ruled here)

  • (A) Refuse at authoring. The lint pass, and ideally the same shared check the runtime uses, refuses a field-level predicate that reads through a reference, with a prescription (move the condition to a validations[] script rule, which is hydrated). That keeps "declared ⇒ enforced".
  • (B) Hydrate the field level, as checkPredicate already does for validation rules. rule-validator.ts:503-504 calls this "a capability of its own, not a consequence of D2", so it is a larger change.

Seam: spec FieldSchema.requiredWhen / readonlyWhen → runtime rule-validator.ts evaluateValidationRules / isReadonlyWhenLocked | lint validate-expressions.ts traversalHydration.

Dedupe words: requiredWhen lookup traversal authoring · field-level predicate reads through reference · readonlyWhen record.fk.field not hydrated · traversalHydration field rule


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级:priority:p2 · bug · domain:spec · pm:queue —— 字段级的 requiredWhen / readonlyWhen 如果穿过关联去读(record.account.tier),编写时各道检查都放行,运行时却从不回填它;自 ADR-0137 D2 起,凡是碰到它的写入都会被拒绝

    Path: packages/lint/src/validate-expressions.ts(traversalHydration 只传给校验规则的条件,第 1540 行附近写着「passed here and NOWHERE else」)· 契约 packages/spec/src/data/field.zod.ts(FieldSchema.readonlyWhen / requiredWhen)· 运行时 packages/objectql/src/validation/rule-validator.ts(第 494–504 行说明字段级谓词不回填)

    Triage: lands at the authoring door (packages/lint, with the shared check the runtime already uses) ⇒ domain:spec, bug, priority:p2, pm:queue; rationale: every authoring door accepts a field-level predicate that reads through a reference, while the runtime can only fault on it — and since ADR-0137 D2 (PR #20028, landed 5dba7f3bd0) that fault refuses every write that reaches the predicate; fail-closed is the right runtime direction, the defect is that authoring does not say so first; 0 such predicates in examples and platform objects, deployed metadata NOT MEASURED — writes blocked on an ordinary-looking predicate, hence p2; triage's routing is remedy A (refuse at authoring with a prescription), which keeps "declared ⇒ enforced" and needs no new ruling; remedy B (hydrate the field level) is a capability of its own and would need its own card.

    分诊席 #6015,2026-09-25T02:25Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack origin/main 7f1de2eb66 上核对。

    本席核对

    • rule-validator.ts 第 494–504 行的注释原文:字段级的 requiredWhen / readonlyWhen / 选项 visibleWhen「is not hydrated at all」;穿过关联的字段谓词会出错,「since ADR-0137 D2 that fault REFUSES the write」;回填它们是「a capability of its own」。
    • validate-expressions.ts 第 1129 行起 traversalHydration 是可选参数,第 1540 行的注释说它只在校验规则那里传入。
    • PR fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) #20028(ADR-0137 D2)已合并为 5dba7f3bd0。
    • 卡面「这样的谓词能通过 objectstack validate」的测量本席没有重跑。

    定级说明

    p2:作者(或 AI)写了一个各道检查都放行的条件,部署后所有碰到它的写入都会被拒绝,看起来就像数据录入坏了。好在报错很明确,会点名那个关联,不会丢数据。

    执行要点(修法 A)

    1. 编写阶段就拒绝:lint,最好再加上运行时用的同一个共用判断,都拒绝穿过关联的字段级谓词。报错里给出改法:把条件移到 validations[] 的 script 规则里,那里会回填关联。
    2. 三种谓词都覆盖:requiredWhen、readonlyWhen,以及选项的 visibleWhen。
    3. 给已部署的元数据留一条路:按 ADR-0087 的规则登记这条收窄,写更新说明,说明旧配置在升级后会被拒绝,以及怎么改。
    4. 钉子:穿过关联的字段谓词在 objectstack validate 上被拒绝,报错里有改法;不穿过关联的谓词照常通过;validations[] 里穿过关联的写法照常通过。
    5. ⛔ **修法 B(在字段层回填)**是一项独立的新能力,不在本卡做。确实需要的话另开卡。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01QcAS3qiYYZNezaxZxaUdMV
    Account: os-project-manager (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-20078-field-predicate-traversal-refused
    Worktree: objectstack-issue-20078
    Domain: domain:spec
    Seat: domain:spec#2 (seat post #18549)
    File surface: packages/lint/src/validate-expressions.ts (the field-level requiredWhen / readonlyWhen and option visibleWhen predicate checks) and its tests under packages/lint/; the .describe() / TSDoc of those three predicate slots in packages/spec/src/data/field.zod.ts, to state the rule; packages/spec/src/migrations/ only if the ADR-0087 route the dev measures requires an entry; .changeset/. A shared traversal predicate reused from the runtime side is read-only unless the report names why it must move (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgment tier (dispatch-gates.mjs --tier at 8d1f7ab: 「no path-derived mandate … floor · default · ceiling」, the default slot taken; an authoring refusal with a prescription across three predicate slots)
    Clause-②: no
    Thread-read: 5825661201
    Serial constraints cleared: read at 2026-09-27T03:17Z — Open-PR census (6 open PRs besides the release PR): none touches validate-expressions.ts, rule-validator.ts or data/field.zod.ts. In-flight claims (seat 1: #19867, #19543, #19856; seat 5: #19938 names the expression ledger flow-node-expression-paths.ts, not validate-expressions.ts; this seat: #19965, #19870, #20051, #20105; other lanes: #20135 core, #20129 rest-server, #20055 turso, #19879 docs) name none of this surface.


    Scope, restated from the dev contract: 「范围 = 这张 issue,别无其它。」 Remedy A only, as triage routed it (5825661201); ⛔ remedy B (hydrating the field level) is a separate capability and not this card.

  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20078,
    "status": "done",
    "branch": "claude/issue-20078-field-predicate-traversal-refused",
    "pr": "#20185",
    "session": "session_01QcAS3qiYYZNezaxZxaUdMV — mode:subagent, the parent PM session's harness-stamped id; this seat's identity is the branch named in Claim 5852240182",
    "premise_still_valid": true,
    "summary": "Remedy A as triage routed it. The expression authoring rule (validateStackExpressions, run by objectstack validate / build / lint) now refuses a field requiredWhen / readonlyWhen or a select option visibleWhen that reads THROUGH a lookup / master_detail / user / tree field, as expression-invalid located at the slot, naming the reference path and related columns. The repair depends on the root: record.REF.COL gets the runtime's own sentence ('Express the check as a validations[] script rule, or read a column this object declares'); previous.REF.COL and parent.REF.COL (judged against the master's types) get 'read a declared column', since no seam hydrates them. The judgment reuses the runtime's own primitives (formula analyzeRelationshipTraversals + spec REFERENCE_VALUE_TYPES); no runtime file changed. The three slots' .describe()/TSDoc state the rule, an ADR-0087 semantic entry is registered (field-predicate-reference-traversal-refused), and the changeset is declared breaking (minor + ! + banner, disposition registered). H1 reproduced at the door on the unfixed build: exit 0, 'Validation passed'.",
    "tests": "All at HEAD 55f3d0a unless stated. lint: pnpm --filter @objectstack/lint test 4162 passed; typecheck exit 0. cli: vitest run --project integration test/validate-field-predicate-traversal.test.ts 2 passed (traversing config exit 1 + exactly 3 expression-invalid errors carrying the prescription; control config exit 0); --project unit test/vitest-tiers-partition.test.ts 22 passed; pnpm --filter @objectstack/cli typecheck exit 0 (after rebuilding six dists, see deviations); rest of both CLI tiers declared to CI. spec: vitest run --project local src/migrations src/data 3307 passed, 2 todo (declared narrowing); typecheck exit 0; check:generated all 15 up to date. objectql read-only control engine-field-predicate-fault.test.ts 13 passed. Ablation: scripts/ablation-replace.mjs, anchor for (const { root, types, owner } of holders) { replaced with ... of [] as FieldTraversalHolder[]) { (anchor 1 to 0, blob 42952246fad7 to 15165f37c26b), lint test file 10 failed / 327 passed, exactly the 10 refusal cases, all 8 controls green; restored blob == HEAD 42952246fad7 with git diff HEAD empty, re-run 337 passed. Lint tests import the rule from src, so no build sits in that path. Door before/after: unfixed lint dist (grep -c of new code = 0) validated the traversing config exit 0; fixed dist refuses it (the CLI test). ESLint narrowed: 6 changed TS files, --no-inline-config --format json: 6 files, 0 errors, 0 warnings; eslint.config.mjs never enables type-aware linting, so untouched files cannot move. Engine probe (scratch, deleted): record-traversing requiredWhen refused insert and unrelated update; readonlyWhen refused the update writing the field, accepted insert and other updates; option predicate accepted for both a gold and an enterprise account (fail-open); previous- and parent-traversing requiredWhen refused; both prescribed validations[] rewrites refused exactly the failure rows.",
    "mcp_calls": "0",
    "api_writes": "3 — POST /repos/objectstack-ai/objectstack/pulls (pr_create through the fleet-write relay, run 36300155885, draft PR 20185); POST /repos//issues/20185/assignees (label-write --assign os-project-manager through the relay, run 36300191493, read back matching); POST /repos//issues/20078/comments (this os-dev-report through post-stamped). git push: 9 pushes of this branch (not REST).",
    "gates": "Derived at 55f3d0a with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (112 families; the tool flagged the tree as behind origin/main with dispatch-gates.mjs itself changed there). Reconciled with --ran: '112 derived famil(ies) accounted for — 110 run, 2 NOT-MEASURED (1 DERIVED from a recorded exit 3, 1 claimed)'. 110 recorded exit 0, including check-adr-0087-registration ('1 declared-breaking changeset(s) ... [BREAKING+bang] registered field-predicate-reference-traversal-refused (new here: ...)'), check:changeset-no-major, check:empty-changeset, check:cli-test-child-env ('no new bulk process.env copy reaches a spawned child'), check:nul-bytes, check:doc-authoring, check:issue-citations, check:cross-package-test-inputs, the 20 spec check:* families, and check:skill-examples (first run exit 3 for an unbuilt client-react; rebuilt, re-run '259 prose examples type-check'). NOT MEASURED: check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (unrelated packages without a local dist); check:type-check-debt (its re-measure leg rebuilds closures; the one run, at an earlier commit, was killed by my 400s wrapper). CI at report time: Governed Surface Queue Guard success; Lint & Repo Gates, Build Core, Temporal Conformance in_progress (12 success, 2 skipped, 18 in_progress of 32 check runs).",
    "line_budget": "n/a — no skills/** path in the diff",
    "files_changed": [
    ".changeset/20078-field-predicate-reference-traversal-refused.md",
    "content/docs/references/data/field.mdx (regenerated)",
    "content/docs/references/data/object.mdx (regenerated)",
    "content/docs/references/system/migration.mdx (regenerated)",
    "content/docs/references/ui/view.mdx (regenerated)",
    "packages/cli/test/validate-field-predicate-traversal.test.ts",
    "packages/lint/src/validate-expressions.test.ts",
    "packages/lint/src/validate-expressions.ts",
    "packages/spec/src/data/field.zod.ts",
    "packages/spec/src/migrations/entries/semantic/18.field-predicate-reference-traversal-refused.ts",
    "packages/spec/src/migrations/registry.ts (regenerated)"
    ],
    "deviations": [
    "Self-inflicted state, repaired: my gate runner's 400s timeout killed pnpm check:type-check-debt while its re-measure leg was rebuilding package closures, leaving six dists without declarations in this worktree (driver-sql, driver-memory, service-cluster, service-i18n, trigger-api, platform-objects). It surfaced as TS7016 in pnpm --filter @objectstack/cli typecheck. I rebuilt those six under the lock and cli typecheck re-ran exit 0. The gate is recorded NOT MEASURED, and the worktree is removed after this report.",
    "Scope: beyond the card's record.account.tier example, the guard also judges previous.REF.COL and, on a single-master detail object, parent.REF.COL. Same slots, same class, same runtime refusal (the probe measured both refused); each gets the repair that is true for its root. No file outside the claim's surface.",
    "Not merged with origin/main before opening the PR, where AGENTS.md section 10 asks for a pull plus a full suite. A driver-less merge-tree probe (bare shared clone, no os-regen driver) merged clean. The queue re-verifies the merge.",
    "Declared narrowings: spec suite src/migrations + src/data; CLI unit tier limited to the partition pin + typecheck; CLI integration tier limited to the new door test. CI runs the full farm.",
    "The CLI door test was first named *.e2e.test.ts, which is the nightly tier, and spawned with a bulk process.env copy. Both were corrected before the PR (queue-tier name; childEnv()).",
    "Two scratch engine probe files under packages/objectql/src were created, run and deleted, never committed. The tree is clean.",
    "Verify lock: 3 acquisitions timed out (exit 99, NOT MEASURED at the time) and were re-queued under the same slot name."
    ],
    "open_questions": [
    {
    "question": "The claim's line is Clause-②: no with no arm, and the PR body copies it verbatim. The change narrows objectstack validate's accept set, which scripts/pm/clause2-line.mjs spells no (narrowing). Breaking-ness is carried in the changeset by the ! summary and the BREAKING banner, and the ADR-0087 gate reads it as [BREAKING+bang]. Should the claim and the PR body carry the arm?",
    "options": [
    "A — leave it: the changeset already declares breaking and the gate is green",
    "B — the PM amends the claim and the PR body's line to Clause-②: no (narrowing) (a seat write; the dev writes the PR body once)"
    ],
    "recommendation": "B, because the arm is the one closed-token carrier the protocol reads for a narrowing. A is safe for the gates as they stand."
    }
    ],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · runtime wording, same family as this card: the engine refuses a previous.REF.COL or parent.REF.COL field-rule traversal with the generic 'reads COL, which this object does not declare — fix the rule's condition, or declare the field', the repair rule-validator.ts's own docblock calls wrong for a traversal. Its specific sentence recognises only the record root (engine probe). After this PR authoring refuses both with the right repair, so only metadata that bypasses the authoring rule reaches it. domain:engine file, outside the claim · noted in PR Acceptance notes, not filed · dedupe words: unevaluableFieldRuleError previous parent traversal wording; field rule fault declare the field wrong object",
    "carrier: 承接者:无 · coverage boundary: injected system columns (owner_id, created_by, ...) have no type in the lint field-type index, so a traversal through one is not refused (a missed finding, never a false one). Field-level visibleWhen, form-view option visibleWhen and action-param option visibleWhen are outside the three slots triage named · noted, not filed",
    "carrier: 承接者:无 · FormSelectOptionSchema reuses SelectOptionSchema.visibleWhen by reference, so the object-face describe renders on the form-view face too, including the pre-existing 'The one VISIBILITY predicate the SERVER also enforces'. The sentence this PR adds is scoped to 'an OBJECT field's option' so it stays true on both faces; the pre-existing one was not measured · noted, not filed"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #20185 at head 55f3d0a800bfc04477738a3b1627bb2eaf4cf28e · domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-27T06:32Z

    Seat review against GitHub, not the report:

    • Shape: draft, base main, first line Fixes #20078. It is the only closing keyword and the only card number. Clause-②: no is line-leading. The PR assignee is os-project-manager.
    • Remedy A only, as triage routed it. validateStackExpressions refuses a field requiredWhen / readonlyWhen, and an object field option's visibleWhen, when it reads THROUGH a lookup / master_detail / user / tree field. The refusal lands at the slot and names the path. A record. root is told to use a validations[] script rule; a previous. / parent. root is told to read a declared column. The judgment reuses the runtime's own primitives, and no runtime file changed. ⛔ No remedy B.
    • Door proof: the new CLI test drives objectstack validate over the traversing config (exit 1, 3 findings carrying the prescription) and a control config (exit 0). H1 was reproduced on the unfixed build: exit 0, 「Validation passed」.
    • File surface amendment to claim 5852240182 (recorded here, ⛔ not a second claim):
      • packages/cli/test/validate-field-predicate-traversal.test.ts (the door test the order asked for);
      • the regenerated content/docs/references/** pages that follow the three describes.
    • Scope note: the previous. / parent. roots are the same three slots, the same defect class and the same runtime refusal (the dev's engine probe). Accepted.
    • Clause-② arm (the dev's question): no amendment. A narrowing need not declare yes, and the changeset's ! and BREAKING banner carry the break to check-adr-0087-registration ([BREAKING+bang] registered field-predicate-reference-traversal-refused).
    • The option-visibleWhen fail-open sentence in the describe is true, and it is existing, ruled policy, not a new finding. packages/objectql/src/validation/rule-validator.ts states that ADR-0137 D2 「names a FIELD-rule predicate, an option's visibility is not one, and it stays fail-open」. ⛔ Not filed.
    • CI on this head at review time: 13 success, 2 skipped, 17 in progress, 0 red.
    • Next: at-tier contract review (clause-② path limb: packages/spec/src/** non-test). needs:contract-review goes on the PR in this act.

    Out-of-scope findings (the dev's three), all → Acceptance notes:

    • The engine's generic wording for a previous. / parent. traversal fault: authoring now refuses both first, and the file is domain:engine.
    • Injected system columns have no type in the lint index: a missed finding, never a false one.
    • The shared SelectOptionSchema.visibleWhen describe renders on the form face too: the new sentence is scoped to an OBJECT field's option.
  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record — PR #20185 merged through the merge queue at 2026-09-27T07:11Z as e4471e643e · domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-27T07:14Z

  6. added a commit that references this issue on Sep 28, 2026
    e4471e6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions