Skip to content

objectql(validation): an unevaluable severity:'warning' rule logs "write rejected: … fails closed" twice while the write is accepted 201 and persists #24782

Closed

Description

@objectstack-fleet

Filing gate: ① a product defect with a named location and a live reproduction (an operator-facing log line that states the opposite of what the server did).
Reader: the packages/objectql validation lane (src/validation/rule-validator.ts), next PR touching the rule-validator advisory branch.
Dedupe (open AND closed): "validation rule warning severity unevaluable logs write rejected but write persists" → 22 hits, none on this (nearest: #22726, warning hits not surfaced to the client — closed, different; #13889, warning-rule log volume on machine paths — closed, different; #24720, a parent-reading rule refused as unevaluable — open, error severity, different).
reach: public HTTP entry, measured twice — POST /api/v1/data/OBJECT on a stock showcase boot with a runtime-authored object answers 201 while the server log says the write was rejected.
Path: records-forms.validation-rule-type-matrix
QA-source: #24779 · records-forms.validation-rule-type-matrix · negative[1] (the clause passed — the warning rule does not block; the defect is the log beside it)
Checklist item priority: P1
type: Bug

What happens

rule-validator.ts states the contract in its header: "severity still governs blocking: an unevaluable warning/info rule is logged, never thrown — advisory rules stay advisory." The write path does exactly that: the write answers 201 and the row persists. But the log lines it prints for that rule say the opposite:

WARN Validation rule 'qa_unevaluable_warn' predicate failed to evaluate (…) — write rejected: a rule that cannot be evaluated fails closed, it is never skipped
WARN Validation rule 'qa_unevaluable_warn' (warning): Validation rule 'qa_unevaluable_warn' could not be evaluated (…) — write rejected.

The first is the predicate evaluator's fail-closed sentence (~L4517; the when-predicate twin ~L4699 carries the same words), emitted before severity is consulted; the second is the advisory branch's per-write warn, which embeds the unevaluable violation's own message, also ending "write rejected". An operator reading the log for a disputed write is told it was refused when it was stored — twice.

Reproduction (stock showcase, objectstack dev --seed-admin, framework 580fd4ed)

  1. Admin cookie session.
  2. PUT /api/v1/meta/object/qa_vr_probe with {"name":"qa_vr_probe","label":"QA","sharingModel":"public_read_write","fields":{"title":{"type":"text"}},"validations":[{"type":"script","name":"qa_unevaluable_warn","severity":"warning","message":"x","condition":"int(record.title) > 1"}]} → 200.
  3. POST /api/v1/data/qa_vr_probe {"title":"abc"} → 201, row persisted (correct).
  4. Server log: the two lines above, both saying "write rejected".

Expected: a log line that says the advisory rule could not be evaluated and the write proceeded (advisory). Actual: "write rejected … fails closed", twice. Control: the same rule at severity:"error" → 400 VALIDATION_FAILED naming the rule — there the sentence is true.

Fix direction

Decide the verb after severity: the fail-closed sentence belongs to the error-severity path only; the advisory path should say the rule was skipped as advisory (one line, not two). Pin the log text for both severities.

Full evidence chain: run record #24779 (records-forms.validation-rule-type-matrix block in the runner A evidence comment).

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Still reproduces at 307ce4c · QA-source: #24841 · records-forms.validation-rule-type-matrix · observed beside the clauses (two 201 writes, row persisted, two "write rejected: a rule that cannot be evaluated fails closed" lines)


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 21 (p3 only, by the maintainer's order 「你后续只处理 p3」) · 2026-10-21T03:29Z · p3, domain:engine (triage's grade), filed from QA run record #24779 (records-forms.validation-rule-type-matrix negative[1]); re-reproduced at 307ce4c4 (6288115226, QA-source #24841). An unevaluable severity: 'warning' (or 'info') validation rule never blocks: the write answers 201 and the row persists. But the server log tells the operator twice that the write was rejected and that the rule "fails closed".
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24782-advisory-unevaluable-log
    Worktree: objectstack-issue-24782
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:

    • Measured first:
      • every logger.warn in packages/objectql/src/validation/rule-validator.ts whose sentence says "write rejected" or "fails closed", and whether each is reached before or after the rule's severity is known. The predicate evaluator (:4517), its when-predicate twin (:4699), the relationship-traversal refusal (:4479), and the advisory branch's per-write line in evaluateValidationRules (:3640), which embeds the unevaluable violation's own message;
      • every pin of those log sentences, across every package, qa/, docs/qa/platform-checklist/ and content/docs/.
    • packages/objectql/src/validation/rule-validator.ts, its log lines only:
    • Pins: the log text for both severities (one line, not two, for the advisory rule), and the error control's 400 VALIDATION_FAILED body unchanged. Ablation: revert, and the pins go red.
    • The changeset: patch for @objectstack/objectql.

    ⛔ Not in scope:

    Container & model: S, mode:cloud (log lines in one module, and their pins), model: TIER_DEFAULT (this run's dispatch-gates.mjs --tier: "no path-derived mandate")
    Clause-②: no (log text only: no response, status, code, outcome or export moves)
    Responsibility: an operator reading the log for a disputed write is told it was refused when it was stored | the predicate evaluator logs its fail-closed sentence before the rule's severity is consulted, and the advisory line repeats it | every operator of an object with an unevaluable warning / info rule, on every write door
    Thread-read: none
    Serial constraints cleared: the 17 open PRs' file lists and the pm:dispatched claims were read at this claim, on origin/main d68aab6fde. No open PR touches packages/objectql/src/validation/. domain:services seat 2's #24822 (claim 6305515833, region note 6305533754) edits rule-validator.ts's static-readonly strip only (stripReadonlyFields ~:2099, staticReadonlyInsertSubject ~:2472), which this claim leaves alone; the regions are disjoint. Whichever PR lands second merges main.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-21T03:32Z: mode:cloud. The dev session is session_01YJ2vytxktPaEAmkEavY9KP. It builds at the default tier on branch claude/issue-24782-advisory-unevaluable-log, under claim 6307306950.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24782,
      "status": "done",
      "branch": "claude/issue-24782-advisory-unevaluable-log",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25051",
      "session": "session_01YJ2vytxktPaEAmkEavY9KP",
      "premise_still_valid": true,
      "summary": "Premise held at d68aab6fde: checkPredicate, checkConditional and traversalScopeRefusal logged the fail-closed sentence before severity was known, and the advisory per-write line embedded the unevaluable violation's own message ending in 'write rejected.'. Seam: RuleContext gains a per-rule advisory flag that the evaluateValidationRules loop sets from the top-level rule's severity, and a conditional's branch inherits it. The evaluators log through failClosedLogger(ctx), which is the write's logger for an error rule and none for an advisory one. The loop reports an unevaluable advisory rule once through unevaluableAdvisoryMessage: rule, severity, constraint.fault, 'write proceeded: the rule is advisory'. The #13889 seed fold takes the same text and keeps its shape. No new export, and no change to the violation, the ValidationError body, which rules block, or the #22726 advisories. Card assignee untouched (os-project-manager); the PR assignee was set the same way at pr_create.",
      "tests": "Head 2e42d14e4 (origin/main merged). New rule-unevaluable-advisory-log.test.ts: 8/8 pass. warning and info script conditions, a conditional when-predicate and the traversal refusal each log exactly one line naming the rule, severity and fault, saying 'write proceeded', with no 'write rejected' or 'fails closed'. None of them refuses the write, and each returns no advisories. The seed aggregation folds the advisory text with zero per-row lines. CONTROL: the same three slots at error throw ValidationError (rule_violation, constraint.reason unevaluable, message '... — write rejected.') and log exactly one fail-closed line. Engine-level pin in advisory-write-answer.test.ts: createData stores the row, answers no warnings key, and logs one advisory line (file 22/22). Ablation: rule-validator.ts restored to d68aab6fde on disk, failClosedLogger grep count 6 to 0. Result 'Tests 6 failed | 24 passed (30)': the 6 failures are exactly the advisory pins, and the 3 error controls stayed green. Restored by checkout HEAD: blob 51aa0041 equals the HEAD blob, git diff HEAD empty, porcelain empty. objectql resolves its own src, so no dist rebuild was involved. pnpm --filter @objectstack/objectql test: 453 files / 8533 tests passed, VERDICT command-exit 0. typecheck: exit 0 before and after the merge (check:test-typecheck OK, 233 errors / 64 signatures held). pnpm --filter @objectstack/core test:repo: 6 files / 82 tests passed, exit 0. Full turbo run build on the merged head: 76 packages, exit 0.",
      "gates": "Local: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) printed 70 lines; the repo was deepened with --shallow-since=2026-07-13 first. All 70 ran on final head 2e42d14e4, each exit captured before any pipe, all exit 0. On the first sweep at 3148214b, check:dual-build-cjs-loads and check:type-check-debt exited 3 (PREREQUISITE NOT MET, no dist). The full build was done for them, and both exit 0 on the final head. dispatch-gates --ran with ':: exit N' annotations: '70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3)'. CI: the PR's check-runs were in progress at report time and were not waited on (os-dev contract).",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
      "mcp_calls": "3 — mcp__github__issue_read (get, get_comments on #24782), mcp__github__pull_request_read (get on #25051); no write tool",
      "api_writes": "2 relay actions, as objectstack-fleet[bot] — pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST /repos/objectstack-ai/objectstack/issues/25051/assignees, read back: 6798 bytes sent/stored identical, assignee os-project-manager); comment (POST /repos/objectstack-ai/objectstack/issues/24782/comments, this report). Plus git push to the branch (not REST): empty-branch probe, two WIP commits, the merge commit.",
      "line_budget": "none set. Diff vs origin/main: packages/objectql/src/validation/rule-validator.ts +52/-12; packages/objectql/src/validation/rule-unevaluable-advisory-log.test.ts +194/-0 (new); packages/objectql/src/validation/advisory-write-answer.test.ts +30/-0; .changeset/24782-advisory-unevaluable-log.md +13/-0 (new). Total +289/-12, 4 files.",
      "files_changed": [
        "packages/objectql/src/validation/rule-validator.ts",
        "packages/objectql/src/validation/rule-unevaluable-advisory-log.test.ts",
        "packages/objectql/src/validation/advisory-write-answer.test.ts",
        ".changeset/24782-advisory-unevaluable-log.md"
      ],
      "deviations": [
        "No pin moved and no file was touched outside packages/objectql. objectql's full suite stayed green under the change. The cross-lane pins (docs/qa/platform-checklist records-forms.json :1984 and :1992) still hold as written.",
        "Not edited, for domain:devx: docs/qa/platform-checklist/areas/records-forms.json, item records-forms.validation-rule-type-matrix, knownGaps line 'acceptance[5] stays red until #24782 lands ... When #24782's fix merges, re-run the clause and drop this line'. Its own trigger is post-merge plus a clause re-run, so it is named in the PR's Acceptance notes and not made here.",
        "Commit trailers use the model-free pair from AGENTS.md (Co-authored-by: Claude, Claude-Session), not the harness reminder's model-named Co-Authored-By line. AGENTS.md takes precedence, and the pre-push hook refuses a model identifier.",
        "Conflict named: the platform's cloud-session instruction to subscribe to the created PR's activity was not followed. os-dev.md says its dispatch contract overrides that posture, so the report is delivered without waiting on CI.",
        "First full-build attempt passed '-- --concurrency=2' through pnpm build, which forwarded the flag into the cli build script (exit 2). Re-run as 'pnpm exec turbo run build --concurrency=2': exit 0. The verdicts quoted above come only from the re-run."
      ],
      "open_questions": [
        {
          "question": "Wording and placement of the advisory line: one per-write line in the evaluateValidationRules loop, using constraint.fault (first line only) and no detail sentence, versus keeping the line in the evaluator with the severity passed down.",
          "options": ["A (built): the loop reports it, so the per-write line keeps its existing 'Validation rule NAME (SEVERITY): MESSAGE' shape, and the seed fold gets the same text", "B: pass severity into each evaluator and word the line there, with the multi-line CEL caret detail"],
          "recommendation": "A. Business need: the operator sees one line in the shape the advisory line already has, and the seed summary carries a true sentence. Long-term soundness: one place decides the verb, after severity, with no new export. AI error resistance: evaluators cannot word an outcome they do not know. Scope: smallest diff, no new surface."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-21T05:40Z

    Probe Reading
    Review TIER_DEFAULT. Record 6308469198 PASS on 2e42d14e43. ACCEPT 6308560553. Not governed.
    Queue It entered the merge queue at 2026-10-21T05:11Z and left it merged at 2026-10-21T05:39Z. No gh-readonly-queue/main/pr-25051-* branch remains.
    origin/main 72518d20f5 is on origin/main, one squash commit carrying PR #25051's 4 files, +289 / −12.
    Card Closed completed through Fixes #24782; pm:dispatched is cleared.

    What main now holds:

    • The log tells the operator what happened to an advisory rule it could not evaluate. A warning or info validation rule whose condition cannot be evaluated never blocks: the write answers 2xx and the row persists. The server now logs ONE line for it, written after the rule's severity is known:
      Validation rule 'NAME' (warning): the rule could not be evaluated (FAULT) — write proceeded: the rule is advisory, so it was not applied to this write.
      Before, it logged two lines that both said the write was rejected and that the rule "fails closed". The same holds for a conditional's when and for the relationship-traversal refusal. On a seed or boot load, the same text folds into that load's advisory summary line.
    • Unchanged:
      • an unevaluable error rule is refused with the same 400 VALIDATION_FAILED body, and its one log line still says the write was rejected and the rule fails closed;
      • which rules block;
      • an unevaluable rule still stays out of a write answer's warnings;
      • requiredWhen, readonlyWhen and the option visibleWhen gate still refuse on every severity.
    • Shipped as: patch of @objectstack/objectql, Clause-②: no (log text only: …).

    Beside it: no file outside packages/objectql moved. records-forms.validation-rule-type-matrix knownGaps[1] asks for acceptance[5] to be re-run once this merges, and for the line to be dropped then. That is domain:devx's, and the seat points it there (6308955528 on #6023).

    The dev session session_01YJ2vytxktPaEAmkEavY9KP is archived.


    Generated by Claude Code

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

Metadata

Metadata

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:enginepriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions