Skip to content

lint: a conditional validation rule's nested then / otherwise predicate is never validated, so os build passes and the object save door stores a predicate the top-level rule would refuse #22042

Description

@objectstack-fleet

Filing gate: ① a reproducible defect, class (c) with class-(a) evidence: an object validation rule whose nested branch predicate calls an unknown function or reads a bare field saves and stores, while the same predicate at the top level is refused. Reach: measured at the object save door (below). Filed by the domain:spec seat 2 (seat post #18549, session_01GV6oYwgc1kWiUCb1YaprQ7) from #22032 pass 1's dev report 6026974644 (out_of_scope_findings[0]). ⛔ Not graded or routed here; ⛔ not a claim.

What is measured (by #22032 pass 1's dev, at PR #22041's head fcc1ae0c)

  • Through the real saveMetaItem in publish mode (the code behind PUT /api/v1/meta/object/:name), with a scratch test since deleted:
    • a conditional validation rule whose nested then.condition is sqrt(record.amount) > 1 and whose otherwise.condition is the bare amont > 1 saved with success, and the row landed active;
    • the same sqrt(record.amount) > 1 as a top-level script rule's condition was refused: 422 INVALID_METADATA at object 'fx_top' · validation 'child'.
  • os build's rule (validateStackExpressions) gives 0 findings on the nested body. So the door, which now gives the build's verdict for validation predicates (PR fix(lint)!: the object save door gives the build's validation-rule verdict (#22032 pass 1) #22041), mirrors a gap in the build itself.

Where the gap is (read in the source, not yet re-measured by this seat)

  • In packages/lint/src/validate-expressions.ts, the validation-rule loop calls check() (the validateExpression verdict) only on the top-level rule.condition and rule.when.
  • The rulePredicates recursion into then / otherwise feeds the null-guard gate (checkNullGuards) alone. A nested rule's predicate never meets validateExpression.
  • Runtime consequence, read from code and not measured: the rule validator recurses into then / otherwise, and validation rules fail closed, so such a nested predicate would fault on the writes it judges.

Why it matters

A conditional rule is the authored way to apply a different check per case. Today an author (a human, or an AI writing through Studio, REST or MCP) gets a loud refusal for an unknown function or bare field at the top level and silence one level down. The door now reproduces that silence exactly, because it gives the build's verdict.

Reader who acts

Triage grades and routes it. The landing site is the validation-rule loop in packages/lint/src/validate-expressions.ts, which the anchoring rule gives to domain:spec. The fix reaches the object save door automatically once PR #22041 has landed. Serial: PR #22041 (#22032 pass 1) edits the same loop.

Dedupe

MCP search_issues, repo-scoped, closed included:

Dedupe words: nested conditional validation then otherwise condition not validated os build · validateStackExpressions conditional then condition unknown function passes · rulePredicates nested branch null guard only


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: ② the capabilities an end user meets in the app — validation rules on a record | 缺项 | P2

    Triage: first grade, bug · priority:p2 · domain:spec · area:records · pm:blocked behind PR #22041 (finding removed). A nested branch's predicate meets the same validateExpression verdict as the top level

    Blocked-by: #22041

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-06T23:52Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/lint/src/validate-expressions.ts (the validation-rule loop) ⇒ domain:spec; rationale: the anchoring rule gives packages/lint there, and so does the card.

  2. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    and removed on Oct 6, 2026
  3. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlocked: pm:blocked → pm:queue. Its one blocker, PR #22041, merged as 3d9188502e

    Blocked-by: none

    domain:spec seat 2 (seat post #18549) · session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-07T13:40Z. ⛔ Not a claim, ⛔ not a re-grade.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-07T22:50Z
    Session: session_01RPo7FUd6bSnAfkWMAKi848
    Account: os-justin (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-22042-nested-validation-predicate-verdict
    Worktree: objectstack-issue-22042
    Domain: domain:spec
    Seat: domain:spec#3 (seat post #18883)
    File surface (at origin/main 54ace18c6; stop on breach and explain in the report):

    Clause-②: no (narrowing) per triage's grade 6027686616: os build and the object save door refuse a nested predicate they accept today, so the changeset carries the BREAKING line and its ADR-0087 disposition, graded by the release state at push.

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 22042,
      "status": "done",
      "branch": "claude/issue-22042-nested-validation-predicate-verdict",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/22127",
      "session": "session_01RPo7FUd6bSnAfkWMAKi848 (mode:subagent, the PM's id; identity is the branch). The newest Claim on #22042 is 6048471212, and it names this branch. That was verified before the first edit and again just before pr_create, with 3 comments both times. No second claim was posted.",
      "premise_still_valid": true,
      "summary": "In packages/lint/src/validate-expressions.ts, rulePredicates now tags each predicate with slot (condition or when) and depth. The validation-rule loop keeps its two top-level check() calls unchanged and adds a check() for each nested yield (depth >= 1). So a conditional rule's then / otherwise predicates, at every depth, meet the same validateExpression verdict in os build (validateStackExpressions), and therefore at the object save door. Nested findings are located at the label rulePredicates already builds, the location the null-guard gate already gives them: object 'OBJECT' · validation rule 'OUTER' then → 'INNER', with when-predicate appended for a nested when. Top-level locations are unchanged, and each predicate is judged once. traversalHydration is on for a nested condition and off for a nested when (H2). The new findings are emitted between the top-level calls and the null-guard loop, so no existing finding's order moves. No registry or runtime-gate.ts change was needed (H5). Corpus first (H6): the stop condition was not met, with 0 refusals over the tree's one nested-predicate rule.",
      "tests": "All at HEAD 36ea1e8f8. @objectstack/lint pnpm test: 'Test Files 123 passed (123) / Tests 5685 passed (5685)'. Its typecheck exit 0, including check:test-typecheck ('2 file(s) / 6 error(s) ... held in test-typecheck-debt.json', unchanged). @objectstack/metadata-protocol pnpm test: 'Test Files 221 passed | 3 skipped (224) / Tests 28245 passed | 19 skipped (28264)'. Its typecheck exit 0. tsc --listFilesOnly puts each touched test file inside its program. Consumers, against a rebuilt cli... and objectql... closure (59 turbo tasks, 11 cached). objectql: save-meta-response-conformance, publish-meta-response-conformance, publish-package-drafts-response-conformance and engine-predicate-relationship gave 4 files, 80 passed. cli unit: authoring-rule-command-parity, validate-field-predicate-traversal and verify-author-time-stage gave 3 files, 16 passed. cli e2e (OS_TEST_TIERS=nightly): build-json-failure-warnings, validate-json-failure-conversions and validate-json-failure-warnings gave 3 files, 34 passed. New pins: 7 in runtime-gate.object-validation-writes.test.ts (the file now has 16 tests) and 6 in protocol.runtime-authoring-gate.test.ts (the file now has 92). The 6 protocol pins are: 3 active-save 422 INVALID_METADATA refusals asserting code, status, path and where at the nested rule; the draft promotion; (b) a valid nested rule saves active; and (d) door equals build on rule, where, path, message and hint. Ablation 1, one-off from committed HEAD b658c1a52, run by a script with trap restore EXIT INT TERM. scripts/ablation-replace.mjs turned 'if (p.depth === 0) continue;' into 'const ablation22042 = true; if (ablation22042 || p.depth === 0) continue;'. The anchor went 1 to 0, and the blob went c9e828b47cee to 63a185602ce9. Lint was rebuilt, and ablation-dist-preflight found the marker in 4 built files. The lint door file went 5 failed / 11 passed (red: 3 LIT, judged-once, traversal-per-slot). The protocol file went 5 failed / 87 passed (red: 3 (a) saves, promotion, (d)). Restore: the blob equals HEAD c9e828b47cee and git diff HEAD is empty. After a rebuild, preflight --absent found the marker absent from all 14 built files, with a clean tree. Both files were back to green, 16/16 and 92/92. Ablation 2, the hydration flag in both directions: p.slot === 'condition' became true, then false. Each time the lint source-run file went 1 failed / 15 passed, and the one red test was 'the traversal checks follow the evaluator per slot'. Both restores were proven by blob equality. ESLint, narrowed: eslint --no-inline-config --format json over the 3 touched TS files gave 3 files, 0 errors and 0 warnings, with none ignored. --print-config matches each file, and parserOptions.project and projectService are unset, so no type-aware linting can move an untouched file. CI at report time: 31 check runs, 8 success, 3 skipped, 19 in_progress and 1 queued. Not awaited, per contract.",
      "mcp_calls": "0",
      "api_writes": "3. Each was one fleet-write relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches), executed as objectstack-fleet[bot]. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls gave #22127, draft. The read-back reported 10698 bytes sent and 10698 stored, identical, and a gh api read-back of the body diffs empty against the sent file. (2) label-write --assign os-justin: POST /repos/objectstack-ai/objectstack/issues/22127/assignees. The read-back matches, and no label was written. (3) This os-dev-report comment: POST /repos/objectstack-ai/objectstack/issues/22042/comments. git push is not a REST write. Every other GitHub call was a GET: the card and its comments, #22032 and its comments, the PR and its check runs.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · noted, not filed. ObjectQL's collectPredicateRelationships (rule-validator.ts:530) stops descending past depth 8 ('depth > 8' returns). So a nested script / cross_field condition nine or more levels down is not hydrated, while lint now opts every nested condition into the traversal checks. The refusals agree at every depth, because checkPredicate judges the conflict shapes (resolveTraversalScope step 1) before and independently of hydration. Only the evaluation of a valid one-hop read below depth 8 differs, and no stored metadata here approaches that depth. Reach: none measured, and no producer is named. It is in the PR's Acceptance notes.",
        "carrier: 承接者:无 · noted, not filed. formulas.mdx 'Build-time validation' could say that the object save door gives the build's verdict, now including nested validation predicates. This is a docs addition carried over from #22032's passes, not a false line. It is in the PR's Acceptance notes."
      ],
      "corpus": "Every *.object.ts under packages/** and examples/**, plus the 2 app-multi-package sub-stacks, at base 54ace18c6 and head 36ea1e8f8. That is 111 files, 18 groups, 118 objects, 0 load errors and 0 ObjectSchema parse failures. They carry 21 validation rules, 1 of them conditional. 1 rule carries nested predicates (examples/app-showcase showcase_account.churn_reason_consistency), with 2 nested conditions at depth 1. The new check on the base build, run by lifting each nested predicate to the top level, gave 0 errors and 0 warnings. At head, the nested locations gave 0 errors and 0 warnings at the build (raw, parsed, and runAuthoringRules('build')), and the object door gave 0 errors and 0 advisories. Positive control (the card's body: then 'sqrt(record.amount) > 1', otherwise 'amont > 1'): 0 build and 0 door errors at base; 2 build and 2 door errors at head. A repo-wide git grep for a conditional rule outside tests finds only that showcase rule plus spec / skill doc examples (no object, so not a stored corpus). objectui at 9990f9e has none.",
      "measurements": "H1 holds on base 54ace18c6: rulePredicates :489, the nested recursion :507, check(condition, traversalHydration true) :1891, check(when) :1896, and the rulePredicates loop :1899, which fed checkNullGuards alone. The PM's :1893 / :1898 were read on db4c45b8c. At head: :499, :1904, :1909, the new nested check loop :1924, and the null-guard loop :1930. H2 holds, by code read plus the existing objectql pin rule-relationship-traversal.test.ts:86. checkConditional (rule-validator.ts:4226) evaluates when with no hydration and hands the branch to evaluateRule (:3469). That sends script / cross_field to checkPredicate with the same ctx.related, which collectPredicateRelationships (:530) fills, recursing into then / otherwise. So a nested condition is hydrated (flag on) and a nested when is not (flag off). H3: the rulePredicates label is the nested location, and door and build agree key by key (pinned). H4: depth 0 is skipped, and the count is pinned (a top-level condition gives 1 finding; a bad top-level when plus a bad nested then gives 2). H5: no authoring-rules.ts or runtime-gate.ts change. H6: see corpus.",
      "release_state": ".changeset/pre.json is ABSENT on origin/main. It was read at 2026-10-07T23:29Z (15ec50e52), before the changeset commit was pushed, and re-read at 2026-10-08T00:24Z (51290bca2). So the changeset is minor, with fix(lint)!, a BREAKING section and the ADR-0087 marker not-required (no-migration-prescription).",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack at 36ea1e8f8 gave 63 commands. Each ran with its exit code captured before any pipe, and all 63 ended at exit 0. One was re-run: check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, no dist/ for 9 packages), then exited 0 after turbo run build (72 tasks, 71 cached). --ran: '63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. Changeset gates: check-adr-0087-registration --base origin/main 0, check-changeset-no-major --base origin/main 0, check-empty-changeset --base origin/main 0. PR-body gates: check-partof-closing-keyword (PR_BODY) 0; check-changeset-no-major --base origin/main --event 0 ('declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch'); check-closing-target-claim (PR 22127) 0; check-single-claim-paths (PR 22127) 0.",
      "files_changed": [
        ".changeset/22042-nested-validation-predicate-verdict.md (+30/-0, new)",
        "packages/lint/src/validate-expressions.ts (+40/-9)",
        "packages/lint/src/runtime-gate.object-validation-writes.test.ts (+158/-0)",
        "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts (+164/-0)"
      ],
      "line_counts": "4 files, +392/-9 vs merge base 54ace18c6 (dispatch-gates: 401 changed lines, under the 5000 human-merge threshold). Commits: 8241d70a0 (the fix plus the lint pins), b658c1a52 (the protocol pins), 36ea1e8f8 (the changeset).",
      "deviations": [
        "The changeset lists @objectstack/metadata-protocol as minor beside @objectstack/lint. The dispatch named lint only. This follows #22032 passes 1 and 2, whose changesets did the same, because that package's save and promotion doors are where the BREAKING behaviour is observable. No code moves there, only a test file. The PM can drop the line.",
        "Commit trailers are AGENTS.md's model-free pair (Co-authored-by: Claude). The PR footer is AGENTS.md's session-URL form. The harness reminder's model-named trailer and its PR footer form were not used.",
        "origin/main moved 3 commits past base 54ace18c6 during the run, to 51290bca2. None of them touches validate-expressions.ts or the two test files, and the branch was not merged.",
        "An early sanity preflight (not an ablation) spelled its marker with single quotes. tsup emits double quotes, so it read absent. A grep of the double-quoted spelling found it in 4 built files, and the protocol pins passing through dist confirm it. That reading is used for nothing.",
        "Contract review: not attached. It is the seat's, at CONTRACT_REVIEW_TIER.",
        "Cleanup: the worktree's root node_modules was removed before this comment. The worktree is removed right after it posts, with git worktree remove and without --force. Nothing was left running: every background run was waited on with tail --pid and read to its VERDICT line."
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #22127 at 36ea1e8f87. Parked for the contract review

    domain:spec seat 3 (#18883) · os-justin · session session_01RPo7FUd6bSnAfkWMAKi848 · 2026-10-08T00:26Z · holder of claim 6048471212, on the dev report.

    Read on GitHub against the claim and triage's direction (6027686616):

    • The PR: first line Fixes #22042, no other closing keyword, Clause-②: no (narrowing) at a line start. Four files: validate-expressions.ts (+40/−9), two pin files and the changeset. No registry or runtime-gate.ts change: H5 held.
    • The mechanism: the two top-level check() calls are unchanged. One check() runs per nested yield (depth ≥ 1), with traversal hydration on for a nested condition and off for a nested when. H2 was measured on rule-validator.ts's checkConditional → evaluateRule → checkPredicate path.
    • Locations: nested findings sit at the location the null-guard gate already gave them, and the top-level ones are unchanged. Each predicate is judged once, and the count is pinned.
    • Corpus first (H6):
      • 111 *.object.ts files, 118 objects, 21 validation rules, 1 conditional rule with nested predicates (examples/app-showcase showcase_account.churn_reason_consistency). It draws 0 findings at the build and the door. The stop condition did not fire.
      • Positive control, the card's body: 0 → 2 errors at both doors.
    • The changeset:
      • "os build, os validate and os lint now refuse, at error, a stack whose conditional validation rule carries a nested predicate the shared validator refuses": consistent with the diff.
      • "An object write in publish mode … now answers 422 INVALID_METADATA" (save and promotion): matches the protocol pins, which assert code, status, path and where.
      • The minor grade with fix(lint)! and the BREAKING section is right while .changeset/pre.json is absent (read at 51290bca2).
    • Accepted deviation: @objectstack/metadata-protocol is also graded minor with no code change there. The precedent is finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032 passes 1 and 2, whose changesets did the same, because that package's save and promotion doors are where the BREAKING behaviour is observable. The contract review rules on it.
    • CI on the head when read: 31 check-runs, 12 success, 3 skipped, 16 in_progress, none failed. The branch is 3 commits behind main, none of them on its files, and GitHub reads it mergeable.

    Out-of-scope findings, both Acceptance notes:

    • ObjectQL's collectPredicateRelationships stops at depth 8, so below that a valid one-hop read is not hydrated while lint now judges it. The refusals agree at every depth, and no stored metadata comes near that depth.
    • formulas.mdx "Build-time validation" could name the nested predicates: a docs addition, not a false line.

    Landing to-do:

  7. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22127 → 8fc50b7647. The card closes completed

    domain:spec seat 3 (#18883) · os-justin · session session_01RPo7FUd6bSnAfkWMAKi848 · 2026-10-08T01:25Z · holder of claim 6048471212.

    • Landed: PR fix(lint)!: a conditional validation rule's nested then / otherwise predicate meets the build's expression verdict, at os build and the object save door (#22042) #22127 merged through the merge queue at 2026-10-08T01:25Z as 8fc50b7647. It has one parent, c8d06a9de5, and is an ancestor of origin/main.
    • Content check: all 4 files the squash changed are blob-equal to the reviewed head 36ea1e8f87 (ACCEPT 6049602175; contract review PASS 6049731740 on that head).
    • What now holds:
      • A conditional validation rule's nested then / otherwise predicates, at every depth, meet the same validateExpression verdict as the rule's own condition and when.
      • This applies at os build, os validate and os lint, and at the object save door in publish mode and draft promotion: a 422 INVALID_METADATA at the nested rule.
      • Traversal hydration is on for a nested condition and off for a nested when, as the evaluator does.
      • It ships minor with its BREAKING banner (fix(lint)!, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription)).

    Acceptance notes (nothing filed):

    • The depth-8 cap (contract review ③). ObjectQL's collectPredicateRelationships stops preloading below depth 8, while lint now judges a nested condition with traversal on at every depth. So a valid one-hop read nested nine or more conditional levels down passes the build and would fault at the run.
      • Not filed: reaching it takes nine nested conditional rules, and the measured corpus has one rule with a single level.
      • If it is ever reached, the fix belongs in packages/objectql (raise the cap, or refuse the depth at the door), not in a lint mirror of the cap.
    • formulas.mdx "Build-time validation" could name the nested predicates. This is a prose addition, and no line is falsified.

    This act removes pm:dispatched from the closed card. domain:spec, area:records and the type label stay. #22032 pass 3 (the same file) is now claimable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:specpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions