Skip to content

formula: label(record.FIELD) on a field that declares no options is admitted at the active authoring door, then reads null on every row #28659

Closed

Description

@objectstack-fleet

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Filed by the PM seat for the W3-RT runner session_017xRBcRX4wwwVw5bEckffdp, whose own write was refused by its session permissions (RUNNER rule 2: staged text, posted verbatim through the same door).

QA-source: #28656 · api-backend.formula-stdlib-matrix · observed beside the clauses

What happens

The active object-authoring door refuses a formula that names an unknown field (label(record.nope) → 422 expression-invalid) or calls an unregistered function (sqrt(4) → 422). But label() pointed at a field that exists and declares no options (a text field) is admitted with 200. Every row then reads the formula as null, and a WARN line appears in the server log. A compound expression goes through the same way: label(record.kind) + label(record.first) → 200.

Expected: refused at author time with 422 expression-invalid naming the field. The door has the object schema in hand, and label() is only meaningful on a select or option-bearing field.

Measured at framework 6e11a929 (main, 2026-11-01), on a stock showcase boot with a runtime-installed scratch package.

Reproduction

  1. As admin, install a writable scratch package: POST /api/v1/packages with manifest {"id":"com.objectstack.qa.formula","namespace":"qaformula",…} and enableOnInstall → 201.
  2. PUT /api/v1/meta/object/qaformula_p4?package=com.objectstack.qa.formula with {"name":"qaformula_p4","sharingModel":"private","fields":{"first":{"type":"text"},"kind":{"type":"select","options":[{"value":"clean","label":"Clean"}]},"pf":{"type":"formula","expression":"label(record.first)"}}} → 200 (expected 422).
  3. POST /api/v1/data/qaformula_p4 {"first":"hello","kind":"clean"} → 201 with "pf": null. The boot log shows WARN "formula field 'pf' on 'qaformula_p4' could not be evaluated … first declares no options".

Mechanism

No lint or expression rule checks the target of label() (a grep of packages/lint/src finds no label-target or option check). The refusal exists only at evaluate time, in optionLabelOf in packages/formula/src/stdlib.ts (around L412–417). The authoring gate (expression-invalid, #22019) type-checks the call and does not look up whether its field argument carries options.

Not a reopen of #22019, which covered unregistered functions; this is a registered macro pointed at the wrong kind of field. Related: #26608 introduced label().

Activity

  1. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: 错的必须被响亮拒绝并给处方 | none | P②

    Triage: p3 · bug · domain:server · area:formula · 2026-11-01T13:00Z

    ⛔ Not a claim.

    • Read fresh. label() on a field that declares no options is admitted at the authoring door and reads null on every row. The door refuses unknown fields and functions, but not this. p3.

    Generated by Claude Code

  2. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

    Claim: PM loop round 4 · card thread · 2026-11-01T13:31Z
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5pQB2Jwsce3RvocUcS
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-m6mfzr (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#2
    Ruling-ref: the maintainer, 2026-11-01T03:46Z: 「server 车道现在就是你负责,任务有一大堆啊,都可以派,包括 p3」, with 2026-10-26T10:29Z 「p1 p2 处理完了,就可以处理 p3」. Triage p3 (6558290991) stands; offered to this seat by seat 1.
    File surface: packages/lint/src/authoring-rules.ts (the expression-invalid gate), packages/formula/src/ read-only or minimal, tests; changesets
    Container & model: M, mode:thread, model: opus (--tier names no path mandate: PM's call, default), effort: medium
    Clause-②: no (narrowing) expected — a formula whose label() argument names an optionless field, admitted before, is refused
    Metadata-protocol: none (expected; the dev measures and declares)
    Responsibility: the dev extends the one existing authoring gate to refuse label() on an optionless field, with red-first, controls and ablation; the seat runs the contract review owed and lands
    Thread-read: none
    Premise: measured at 6e11a929 by the QA runner; the dev re-measures on origin/main first
    Serial constraints cleared: seat 1's PR #28679 (#27904) touches packages/formula/src/; seat 2's #28660 touches stdlib.ts in another function; the dev merges origin/main if either lands first
    Resume-from: none — fresh from origin/main

    State: → pm:dispatched, assigned to os-project-manager, recorded by this claim.


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-project-manager · session_01FEkQ5pQB2Jwsce3RvocUcS
    os-dev-report
    {
    "issue": 28659,
    "status": "done",
    "branch": "claude/project-thread-m6mfzr",
    "pr": "#28714",
    "session": "session_01FEkQ5pQB2Jwsce3RvocUcS",
    "premise_still_valid": true,
    "summary": "Premise re-measured on origin/main: validateExpression('value','label(record.first)', ...) ok:true, and the live showcase door saved it 200 and read pf null with one WARN. The expression-invalid gate now refuses label(record.FIELD) on a declared field that declares no options: formula's validateExpression takes an opt-in ExprSchemaHint.optionFields and refuses under a new closed-set code label-field-no-options (params {field}) in the evaluator's own fault sentence (optionlessLabelSentence, shared with optionLabelOf). 'Declares options' has one spelling, declaresOptions (formula, exported), now read by objectql's label map and by lint's formula pass, which passes optionFields. No new rule id. File surface beyond the claim: the gate body is lint/validate-expressions.ts (authoring-rules.ts unchanged), plus objectql/formula-option-labels.ts for the one-predicate reuse and one docs sentence.",
    "tests": "Head 0cd2057. formula test exit 0 (62 files / 1590 tests), lint test exit 0 (185 / 7730), objectql test exit 0 (512 / 9898); typecheck exit 0 for all three. New: formula validate-option-label-target.test.ts (11), lint runtime-gate.object-formula-label-target.test.ts (12, os validate/build/lint + runtime gate); expression-refusal-codes pin + sweep extended; #5017 receiver meta-test excuses two plumbing locals. Red on base (six src files at base, formula rebuilt, preflight marker absent): formula 7 failed/30 passed, lint 8 failed/4 passed; restored, git diff HEAD empty. Ablation via ablation-replace.mjs (anchor 1->0, blob e7327b51ea43->991a89c6da33, marker hit dist/index.js; DTS step exit 1 from lost narrowing, JS emitted): formula 5 failed/6 passed, lint 8 failed/4 passed; restored blob == HEAD, rebuilt, marker absent. An earlier ablation leg broke the formula build and was discarded. Live REST (showcase, scratch package): base PUT label(record.first) 200, compound 200, publish of a draft 200, data pf null; branch PUT 422 expression-invalid naming first for both bodies, draft _diagnostics (GET ?state=draft) carries the error, publish 422; controls label(record.kind) 200 reading 'Clean', label(record.nope) 422 unknown field unchanged.",
    "gates": "dispatch-gates --commands: 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN (--ran exit 0), all exit 0; check:type-check-debt first exit 3 (queued behind the dogfood lock holder), exit 0 on re-run. Record: /mnt/project-files/pm-orders/server/28659-gates-ran.txt. check-changeset-no-major local: no major (level axis is PR-scoped, read in CI). CI check-runs on #28714: in_progress at report time.",
    "dogfood_battery": "0cd20573 · os-verify-lock VERDICT command-exit 0 (held 2142s, shared box) · pnpm --filter @objectstack/dogfood test exit 0 · Test Files 369 passed, 3 skipped (372) · Tests 2981 passed, 28 skipped (3009) · no failures",
    "mcp_calls": "6 — issue_read x3, pull_request_read x2 (get #28679, get_files #28681), no write tools",
    "api_writes": "2 — fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches -> run 41319006331 -> PR #28714, no assignees, no labels); this os-dev-report comment (POST /repos//issues/28659/comments). Plus git push x2 (not REST).",
    "open_questions": [
    {
    "question": "Changeset level: the order asked for patch; AGENTS.md step 3 makes a door now refusing a value it stored 'narrowed', which takes minor (check-changeset-no-major refuses narrowed without a minor). Shipped formula+lint minor, objectql patch.",
    "options": [
    "A keep minor + Metadata-protocol: narrowed (as shipped)",
    "B patch + none (the gate would refuse it, and it misdeclares the narrowing)"
    ],
    "recommendation": "A, because the declaration must be the measured kind and the gate enforces the pairing."
    }
    ],
    "out_of_scope_findings": [
    "class: b · reach: MCP validate_expression tool (site formula) answers ok for label(record.first) on a text field where the object save door answers 422 · evidence: packages/mcp/src/mcp-http-tools.ts builds the hint from bridge.describeObject, and both bridges (packages/runtime/src/domains/mcp.ts describeObject, packages/mcp/src/stdio-data-bridge.ts describeObject) project fields to name/type/label/required, so options never reach validateExpression · Seam: spec:ExprSchemaHint.optionFields -> runtime:mcp-http-tools validate_expression · dedupe words: validate_expression optionFields, describeObject options, MCP label() parity",
    "carrier: none · Studio live CEL lint (ui/packages/app-shell/src/views/metadata-admin/celAuthoring.ts lintCelPredicate) passes fields/scope only, so it cannot pre-warn label() on an optionless field; the save door's 422 still reaches Studio. Noted, not filed."
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

    2026-11-01T15:04Z · claim 6558630190 amended (file surface)

    The card thread measured that the expression-invalid gate's body for formula fields is packages/lint/src/validate-expressions.ts, not authoring-rules.ts. It also measured that the "declares options" reading the evaluator uses had to be shared rather than spelled twice.

    • The amendment:
      • The file surface is packages/lint/src/validate-expressions.ts (the gate);
      • a new packages/objectql/src/formula-option-labels.ts (the shared declaresOptions predicate, which the evaluator's option-label reader and the gate both use);
      • packages/formula/src/ for the reader's call;
      • one docs sentence;
      • the tests and the changesets.
      • packages/lint/src/authoring-rules.ts is not touched.
    • Metadata-protocol: narrowed, so @objectstack/formula and @objectstack/lint ship minor and @objectstack/objectql patch, as the no-major gate pairs narrowed with minor. That replaces the claim's expected patch.
    • Clause-②: no (narrowing), as expected. The contract review is running before the queue.
    • Unchanged: the rest of the claim.

    Generated by Claude Code

  5. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-project-manager · session_01FEkQ5pQB2Jwsce3RvocUcS
    os-dev-report
    {
    "issue": 28659,
    "status": "done",
    "branch": "claude/project-thread-m6mfzr",
    "pr": "#28714",
    "session": "session_01FEkQ5pQB2Jwsce3RvocUcS",
    "premise_still_valid": true,
    "summary": "Review round 1 (contract review FAIL, comment 6559777722) fixed on head c41843d: lint's formula pass read the AUTHORED body through declaresOptions (served form), so it refused label(record.tier) on a select bound to a shared picklist with no inline options, which works at runtime once objectql resolves the list. Added declaresOptionsOnceServed (formula, exported beside declaresOptions): inline options OR typeof picklist === 'string' && picklist !== '' (R8's reading). Lint optionFieldNames now reads it; declaresOptions stays the served-form predicate objectql's label map reads, so the evaluator is unchanged for an unresolved picklist. Comments, changeset Unchanged bullet and Remedy, docs sentence ('a field that declares options or names a picklist') and the PR body list restated; the changeset names every door that runs the active authoring gate (save door, per-item publish, history restore, draft _diagnostics). Round 0: Premise re-measured on origin/main: validateExpression('value','label(record.first)', ...) ok:true, and the live showcase door saved it 200 and read pf null with one WARN. The expression-invalid gate now refuses label(record.FIELD) on a declared field that declares no options: formula's validateExpression takes an opt-in ExprSchemaHint.optionFields and refuses under a new closed-set code label-field-no-options (params {field}) in the evaluator's own fault sentence (optionlessLabelSentence, shared with optionLabelOf). 'Declares options' has one spelling, declaresOptions (formula, exported), now read by objectql's label map and by lint's formula pass, which passes optionFields. No new rule id. File surface beyond the claim: the gate body is lint/validate-expressions.ts (authoring-rules.ts unchanged), plus objectql/formula-option-labels.ts for the one-predicate reuse and one docs sentence.",
    "tests": "Head c41843d. formula test exit 0 (62 files / 1593 tests), lint test exit 0 (185 / 7732), objectql test exit 0 (513 / 9901); typecheck exit 0 for all three. Round 1 adds: picklist-bound control (tier: select, picklist 'qa_tiers', no inline options; label(record.tier) and label(record.tier) + ' / ' + label(record.kind)) at os validate/build/lint and runRuntimeAuthoringRules (runtime-gate test now 14); declaresOptionsOnceServed truth table, authored/served picklist pair and validator acceptance in validate-option-label-target.test.ts (now 14). Ablation of the picklist half (ablation-replace anchor 'fieldList.filter(([, f]) => declaresOptionsOnceServed(f))' -> declaresOptionsOnceServed({ ...f, picklist: undefined }), anchor 1->0): runtime-gate 2 failed / 12 passed, exactly the two picklist controls; restored blob 511b9f3952a6 == HEAD, git diff HEAD empty. No live REST probe for the picklist control (unit door test drives the same runRuntimeAuthoringRules). Round 0: Head 0cd2057. formula test exit 0 (62 files / 1590 tests), lint test exit 0 (185 / 7730), objectql test exit 0 (512 / 9898); typecheck exit 0 for all three. New: formula validate-option-label-target.test.ts (11), lint runtime-gate.object-formula-label-target.test.ts (12, os validate/build/lint + runtime gate); expression-refusal-codes pin + sweep extended; #5017 receiver meta-test excuses two plumbing locals. Red on base (six src files at base, formula rebuilt, preflight marker absent): formula 7 failed/30 passed, lint 8 failed/4 passed; restored, git diff HEAD empty. Ablation via ablation-replace.mjs (anchor 1->0, blob e7327b51ea43->991a89c6da33, marker hit dist/index.js; DTS step exit 1 from lost narrowing, JS emitted): formula 5 failed/6 passed, lint 8 failed/4 passed; restored blob == HEAD, rebuilt, marker absent. An earlier ablation leg broke the formula build and was discarded. Live REST (showcase, scratch package): base PUT label(record.first) 200, compound 200, publish of a draft 200, data pf null; branch PUT 422 expression-invalid naming first for both bodies, draft _diagnostics (GET ?state=draft) carries the error, publish 422; controls label(record.kind) 200 reading 'Clean', label(record.nope) 422 unknown field unchanged.",
    "gates": "Head c41843d: dispatch-gates --commands 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN (--ran exit 0), all exit 0; check:type-check-debt (lock contention), check:skill-examples and check:dual-build-cjs-loads (before full build) first answered non-zero without measuring, exit 0 on re-run. Record: /mnt/project-files/pm-orders/server/28659-gates-ran.txt. PR body v2 for the seat to apply: /mnt/project-files/pm-orders/server/28659-pr-body-v2.md.",
    "dogfood_battery": "c41843d6 · os-verify-lock VERDICT command-exit 0 (held 2306s, shared box) · pnpm build exit 0 · pnpm --filter @objectstack/dogfood test exit 0 · Test Files 371 passed, 3 skipped (374) · Tests 2994 passed, 28 skipped (3022) · no failures",
    "mcp_calls": "6 — issue_read x3, pull_request_read x2 (get #28679, get_files #28681), no write tools",
    "api_writes": "Round 1: 1 — this os-dev-report comment (POST /repos//issues/28659/comments). Plus git push x1 (not REST). PR body not patched by the dev. Round 0: 2 — fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches -> run 41319006331 -> PR #28714, no assignees, no labels); this os-dev-report comment (POST /repos//issues/28659/comments). Plus git push x2 (not REST).",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: b · reach: MCP validate_expression tool (site formula) answers ok for label(record.first) on a text field where the object save door answers 422 · evidence: packages/mcp/src/mcp-http-tools.ts builds the hint from bridge.describeObject, and both bridges (packages/runtime/src/domains/mcp.ts describeObject, packages/mcp/src/stdio-data-bridge.ts describeObject) project fields to name/type/label/required, so options never reach validateExpression · Seam: spec:ExprSchemaHint.optionFields -> runtime:mcp-http-tools validate_expression · dedupe words: validate_expression optionFields, describeObject options, MCP label() parity",
    "carrier: none · Studio live CEL lint (ui/packages/app-shell/src/views/metadata-admin/celAuthoring.ts lintCelPredicate) passes fields/scope only, so it cannot pre-warn label() on an optionless field; the save door's 422 still reaches Studio. Noted, not filed."
    ],
    "round": 2,
    "head": "c41843d6"
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

    Landing record · #28659 · PR #28714

    domain:server seat 2 · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-01T17:29Z

    Reading Probe Result
    Merged PR #28714 state merged, merge commit 86fb6458
    On main git merge-base --is-ancestor 86fb6458 origin/main yes
    Queue refs git ls-remote origin refs/heads/gh-readonly-queue/* matching 28714 0
    Content git grep -c declaresOptionsOnceServed origin/main -- packages/lint/src/validate-expressions.ts 4

    Release: claim 6558630190 (amended 6559703599; branch claude/project-thread-m6mfzr) · landed.

    • State: pm:dispatched removed; assignee cleared; the card closed on merge.
    • Resume-from: none.

    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions