Skip to content

formula: the metadata save door stores a formula that calls an unregistered function (sqrt), and every read answers null with nothing logged — the docs say the shared validator backs metadata registration #22019

Description

@objectstack-fleet

Filing gate: ① a reproducible product defect, class (b): a door that contradicts its own stated contract. content/docs/data-modeling/formulas.mdx says "the same validateExpression validator backs os build and metadata registration". At the runtime save door it does not, and the fault that validator exists to prevent reaches users as a silent null.

Reader: triage's first touch (grade and route), then the execution seat that claims it.

QA-source: #21784 · api-backend.formula-stdlib-matrix · acceptance[5]

Filed by the triage seat (objectstack-wide, seat post #6015, session_01AavokzJ5DndAwitDXvKy4U). It is the Extracted: disposition for the last fail on qa-run #21784 without a card. The record called it "known expected-fail #3306". But #3306 closed completed on 2026-07-20 with a different fix (floor/ceil registered, and date arithmetic made a build error), so the row had no live carrier. ⛔ Not graded here. ⛔ Not a claim.

Dedupe: MCP search_issues on objectstack, open and closed, for "formula calling an unregistered function saves and reads null silently, no authoring-time refusal": 0 hits. The nearest closed card is #3306, a different mechanism (above).

What happens

From the record, confirmed by verifier VF4 at 316be321:

  1. PUT /api/v1/meta/object/fx_sqrt?package=…, with a formula field sqrt(record.amount) → 200.
  2. POST /api/v1/data/fx_sqrt {amount: 16} → the formula field reads null. Nothing is logged.

sqrt is not one of the 27 registered stdlib functions. os build refuses the same expression: "Unknown function", severity error (formulas.mdx about :228).

Where (read on main at this filing)

  • The read path: packages/objectql/src/engine.ts:2255, in applyFormulaPlan:
    • rec[fp.name] = r.ok ? … : null drops the fault with no log line;
    • the planning stage's ExpressionEngine.compile(expr) (about :1562, "to surface syntax errors at planning stage") discards its result;
    • formulas.mdx (about :640) states ADR-0032's rule for call sites: "must not silently swallow that".
  • The save door: no validateExpression call reaches an object's formula fields on the metadata save path.
    • git grep -n validateExpression -- packages/metadata-protocol/src packages/objectql/src finds no call site.
    • registerFlow in service-automation does call it. That is the "same verdict" the docs promise for metadata registration.

Done when

  • The save door refuses a formula field whose expression validateExpression('value', …) refuses, with the same located message os build gives. This is the docs' existing promise, ⛔ not a new rule: the build's verdict, at a second door.
  • The read path no longer drops an evaluation fault silently. At minimum, one attributed log line per (object, field) fault, under ADR-0032.
  • Pins:
    • the save door refuses sqrt(record.amount);
    • a registered call (floor(record.amount)) still saves;
    • a row stored before the gate still reads, and the read-path log names it.
  • api-backend.formula-stdlib-matrix A6 then scores the new behaviour, as its own step says: "if an authoring-time gate refuses the formula before it is stored, record THAT as the (better) behavior". The seat that accepts this card appends the item to the wave anchor's retest list, qa(checklist): park the blocked partial-coverage gaps of the 17.7 pre-release runs (#21720, #21721, #21782, #21784) — each gets a fixture recipe, a pin, a re-pointed clause or a recorded knownGap #22017 (§5, trigger 2).

Activity

  1. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: ② the capabilities an end user meets in the app — a formula field's value | 缺项 (api-backend.formula-stdlib-matrix A6 is an expected-fail) | P2

    Triage: first grade, bug · priority:p2 · domain:engine · area:records · pm:queue. The save door gives the build's verdict, and the read path stops swallowing the fault

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

    Triage: lands in the metadata save path for objects (packages/metadata-protocol/src) and packages/objectql/src/engine.ts (applyFormulaPlan, the planning-stage compile) ⇒ domain:engine; rationale: the lane table puts packages/objectql, packages/formula and packages/metadata* in domain:engine.

    • Verified at filing: engine.ts:2255 maps a failed evaluation to null with no log; the planning compile at about :1562 discards its result; no validateExpression call site exists under metadata-protocol/src or objectql/src.
    • Why p2: an author gets a 200 and a field that is null on every row, with nothing anywhere to say why. formulas.mdx promises the opposite, at both the save door and the call site (ADR-0032).
    • Not a new gate: os build already refuses this expression, and the docs already say metadata registration does too. This card makes the second door give the verdict the first one gives. ⛔ No new rule, no new refusal code: reuse the build's located message.
    • Shape, and what it owes:
      • The save door now refuses an input it used to accept, so it owes a changeset that says so, and a contract review.
      • ⛔ Stored rows are not migrated or rejected on read. A row saved before the gate keeps reading (with the new log line) until it is next edited.
      • Stop and report if the claimant finds stored formula fields in this repo's examples or templates that the validator refuses: that is a migration question, not this card's.
    • The read-path half is a log line only. ⛔ The value stays null, because changing the read answer is a protocol change.
    • QA loop: on landing, the accepting seat appends api-backend.formula-stdlib-matrix to qa(checklist): park the blocked partial-coverage gaps of the 17.7 pre-release runs (#21720, #21721, #21782, #21784) — each gets a fixture recipe, a pin, a re-pointed clause or a recorded knownGap #22017's retest list.
  2. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 50 · 2026-10-06T17:29Z
    Session: session_017ErfyP2Rx7XWHJA27QjyUi
    Account: os-project-manager (the seat's linked user, as GET /user answers it; always the card's assignee)
    Branch: claude/issue-22019-formula-save-door-validate
    Worktree: objectstack-issue-22019
    Domain: domain:engine
    Seat: domain:engine#1
    Provenance:

  3. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 22019,
      "status": "done",
      "branch": "claude/issue-22019-formula-save-door-validate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/22031",
      "session": "session_017ErfyP2Rx7XWHJA27QjyUi (mode:subagent, the PM's session; identity is the branch). Before any edit: the order file's sha256 prefix a9f0dfe338157d58 was verified, and the newest Claim on #22019 (6021803960) names this branch.",
      "premise_still_valid": true,
      "summary": "The object save door now gives the build's formula verdict. The cause was one registry declaration in @objectstack/lint: the build's expression rule validateStackExpressions declared runtimeTypes flow/action/hook and never object, so the runtime authoring gate, which saveMetaItem (publish) and the draft promotion already run, never dispatched it on an object write. The entry now declares object and passes the gate's write type to the rule. On an object write the rule runs the field-formula pass alone: the build's own validateExpression('value', ...) call, with its warnings. Every other object-borne pass is fenced off by name. The door's 422 issue is the build's finding: same rule, where, path, message and hint. The published validateStackExpressions(stack) signature is unchanged. On the read path, applyFormulaPlan now hands a failed evaluation to a sink that the engine binds per object, at find, findOne and the write response. ObjectQL.reportFormulaFault logs one warn per (object, field) per engine instance through the engine's logger. The value stays null, and stored rows are neither migrated nor refused. No source change was needed in packages/metadata-protocol: the landing moved to the producer, @objectstack/lint, as the file-surface clause allows. The reason is in the PR body.",
      "tests": "All at HEAD fca16e0688. Package tests: @objectstack/lint 120 files, 5638 tests passed. @objectstack/metadata-protocol 219 files passed and 3 skipped, 28049 tests passed and 19 skipped. @objectstack/objectql 379 files, 7513 tests passed. typecheck Done for lint, metadata-protocol and objectql, test-typecheck included. Pins: (a) and (d) are the #22019 block in packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, through the real saveMetaItem and publishMetaItem. (b) is in the same block. (c) is packages/objectql/src/engine-formula-fault-log.test.ts, 6 tests. The lint door and its fence are packages/lint/src/runtime-gate.object-formula-writes.test.ts, 9 tests, plus the object roster pin in runtime-gate.object-writes.test.ts. Reverse verification, from committed HEAD fca16e0688: scripts/ablation-replace.mjs removed 'object' from the entry's runtimeTypes (anchor hit 1 time, 1 to 0, blob 5dd250340913 to d1ae5ae5f889), and @objectstack/lint was rebuilt. ablation-dist-preflight found the marker present in 4 built files on the pristine build and absent from all 14 on the mutated build (--absent --source-marker). Result: metadata-protocol #22019 block 3 failed (a x2, d), 1 passed (b); lint door test 6 failed, 3 passed. Restore: blob 5dd250340913 equals HEAD, git diff HEAD 0 bytes; lint rebuilt, marker present in 4 built files again; block 4 passed, lint 9 passed. Second ablation: the sink call onFault?.(fp.name, r.error) deleted, objectql pin 5 failed / 1 passed (the null-answer case stays green, as designed), restored blob-equal with git diff HEAD empty. H4 corpus measurement: 29 formula fields on 28 objects gave 0 build errors, 0 build warnings, 0 door errors and 0 door advisories, against 1 build error and 1 door error for the sqrt control in the same harness.",
      "mcp_calls": "0",
      "api_writes": "3 relay strokes. Each is one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write as objectstack-fleet[bot]. (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls: PR #22031, draft, read back 14264 bytes sent = 14264 stored. (2) label-write --assign os-project-manager, POST /repos/objectstack-ai/objectstack/issues/22031/assignees, read back matching. (3) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/22019/comments. Not REST: 10 git pushes to the branch (the empty-branch probe, the WIP commits, the merge of origin/main, and the final head fca16e0688).",
      "gates": "dispatch-gates.mjs --commands, re-derived at fca16e0688 with no paths: 68 commands, the same as at 2850ecbec6. These ran together with the artifact-roster block (54) and the four symbol-anchor sweeps (check:adr-anchors, check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors): 125 unique commands, all exit 0 at fca16e0688. dispatch-gates --ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. Prerequisites: check:dual-build-cjs-loads and check:published-readme-exports first answered PREREQUISITE NOT MET (exit 3), and were re-run green after a full workspace build. PR-context gates: check-closing-target-claim and check-single-claim-paths were run as reads against PR #22031, both exit 0. check-partof-closing-keyword on the body, check-changeset-no-major --event (level axis: no (narrowing) graded minor) and check-adr-0087-registration also exit 0. Fixed on the way: check:engine-double-contract (a new file's fake engine was not in the ledger) and check:objectql-double-limit (two fake finds did not hold the bound). The door pins moved into the gate test's already-pinned double, and the objectql fake driver applies the bound after its filter, so no ledger was touched. CI: not awaited, in_progress at report time.",
      "line_budget": "n/a",
      "deviations": [
        "Landing: the save door's half lives in packages/lint (authoring-rules.ts, validate-expressions.ts), not packages/metadata-protocol. The door already runs the registry; the gap was the registry's object declaration. The file-surface clause allows producer-side landing, and the reason is in the PR body.",
        "Narrowing mechanism: the gate's write type reaches the rule through AuthoringRuleContext.runtimeWriteType. Its doc said only the reference-integrity suite reads it; the doc now names this second reader and the same argument (one entry over several passes).",
        "The metadata-protocol changeset entry names a package with no source change, as the order asked, because the door users meet is there. The refusal ships from @objectstack/lint.",
        "The door pins (a), (b), (d) are a new block in the existing protocol.runtime-authoring-gate.test.ts, not a new file, to reuse an already-pinned engine double. A first draft in a new file reddened check:engine-double-contract and check:objectql-double-limit.",
        "Extra reverse verification for (c), the read-path sink, beyond the order's (a) and (d).",
        "One errant invocation: pnpm build -- --concurrency=2 forwarded the flag into the cli and client-react build scripts, which failed on argv. Re-run as turbo run build --concurrency=2, all 72 tasks green. No code effect.",
        "Attribution: commits end with AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude). The PR body ends with AGENTS.md's session-URL footer, not the harness reminder's lines, by the reminder's own precedence clause."
      ],
      "files_changed": [
        ".changeset/22019-object-save-door-formula-verdict.md",
        ".changeset/22019-objectql-formula-fault-log.md",
        "packages/lint/src/authoring-rules.ts",
        "packages/lint/src/validate-expressions.ts",
        "packages/lint/src/runtime-gate.object-formula-writes.test.ts",
        "packages/lint/src/runtime-gate.object-writes.test.ts",
        "packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts",
        "packages/objectql/src/engine.ts",
        "packages/objectql/src/engine-formula-fault-log.test.ts"
      ],
      "clause_2": "no (narrowing), measured. Accept set: an object write in publish mode (PUT /api/v1/meta/object/NAME, saveMetaItem), the draft promotion (publishMetaItem) and publishPackageDrafts used to answer 200 for a formula field whose expression the shared validator refuses. They now answer 422 INVALID_METADATA expression-invalid. Built entry declarations, base vs head, each package rebuilt from base sources and then restored blob-equal: @objectstack/lint has one doc comment added (AuthoringRuleContext.runtimeWriteType) plus chunk-hash renames, and no exported signature moves; @objectstack/objectql has three private member names on ObjectQL (formulaFaultReported, formulaFaultSink, reportFormulaFault) plus chunk-hash renames; @objectstack/metadata-protocol has no source change. Nothing widens. Changeset: minor, BREAKING banner, bang title, remedy, ADR-0087 not-required (no-migration-prescription). check-adr-0087-registration and check-changeset-no-major --event are green. A contract review is owed and the seat runs it.",
      "hypotheses": {
        "H1": "Confirmed in part, falsified in part. saveMetaItem judges an object body at assertRuntimeAuthoringRules (packages/metadata-protocol/src/protocol.ts:20410), and the draft promotion does too (:21973). That reaches evaluateRuntimeAuthoringGate (runtime-authoring-gate.ts:897), then runRuntimeAuthoringRules (packages/lint/src/runtime-gate.ts:917), filtered by runtimeAuthoringRulesFor (:550). So save and publish share one gate already. Falsified: the check does not go in the door, which holds no rules by design and whose wiring guard refuses one. The build's entry is the registry rule validateStackExpressions (packages/lint/src/validate-expressions.ts). Its formula call is at :2043 on base (:1957 on head), and the gap was authoring-rules.ts:574 on base, runtimeTypes flow/action/hook. The call is reused through the registry, not copied.",
        "H2": "The object-borne expression sites the build judges (validate-expressions.ts) are: validations[].condition and .when, with null guards over then/otherwise; fields[].requiredWhen, readonlyWhen, conditionalRequired and visibleWhen, with the root verdict, the parent gate, the requiredWhen null guard and the traversal refusal; fields[].options[].visibleWhen; fields[].expression, with the unprovisioned-anchor warning; and actions[].visible and actions[].disabled. Falsified: default values are not judged by the build. The door mirrors only the formula pass. The rest is the out-of-scope finding.",
        "H3": "Confirmed. On base, engine.ts:2255 maps a fault to null with no log, and the planning compile at :1562 discards its result (ExpressionEngine.compile never throws; the CEL engine catches). The existing per-key log-once shapes are the module-global warnOnce (validation/record-validator.ts:1755, console.warn, per process) and the engine's per-instance sets (transactionUnsupportedReported :3683, cascadeNotAtomicReported :3692 with warnCascadeNotAtomic :15911, this.logger.warn). Neither is a callable helper. The new report follows the per-instance engine shape: formulaFaultReported (engine.ts:5107 on head) and reportFormulaFault (:5144), once per (object, field) per engine instance, at warn.",
        "H4": "Confirmed; the stop condition was not met. All 29 stored formula fields on 28 objects were judged by the build pass and by the door function at its snapshot shape: examples 7 on 6 (app-crm 4 on 3, app-showcase 2 on 2, app-todo 1 on 1, app-multi-package 0) and platform display_title formulas 22 on 22. The result was 0 errors and 0 warnings at both. No templates carry formula fields."
      },
      "dogfood_pin": "Not needed. (a) runs the protocol door itself, and the REST mapping of its 422 INVALID_METADATA on PUT /meta/object is already pinned (packages/rest/src/meta-object-owd-gate.test.ts). Nothing to declare to domain:cli.",
      "docs": "No docs line became false. formulas.mdx's promise (the validator backs os build and metadata registration) now holds for formula fields. An optional addition, not a correction: the Build-time validation section could name the object save door beside registerFlow. If wanted, it is for the seat to declare to domain:devx.",
      "cleanup": "The branch is pushed at fca16e0688 and nothing is uncommitted. The worktree's node_modules and the worktree itself are removed as the step after this post.",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: b · reach: through the real saveMetaItem in publish mode, an object with validations[].condition 'sqrt(record.amount) > 1' and a bare-reference requiredWhen 'amount > 1' saved with success, while os build's entry (runAuthoringRules('build')) refused both at error ('found no matching overload for sqrt(dyn)', 'bare reference amount'). · evidence: the object save door still gives no build verdict on validation-rule predicates, the field-rule slots (requiredWhen/readonlyWhen/conditionalRequired/visibleWhen), option visibleWhen, or object action predicates. Contract: formulas.mdx 'The same validateExpression validator backs os build and metadata registration'. Seam: spec:ObjectSchema.validations[].condition / FieldSchema.requiredWhen -> runtime:runtimeAuthoringRulesFor('object') (packages/lint/src/runtime-gate.ts). This PR fences those passes off the object door by name (StackExpressionOptions) and pins the fence, so a crossing is a deliberate edit measured over the stored corpus. · dedupe words: object save door validation rule predicate os build verdict; requiredWhen bare reference saves through meta object; runtime gate object write fenced expression passes",
        "carrier: none · noted, not filed (PR Acceptance notes). planFormulaProjection's planning ExpressionEngine.compile (engine.ts:1562) is a no-op: it never throws and its result is unread. Its comment and evaluateFormulaField's docblock describe a throw that never happens.",
        "carrier: none · noted, not filed (PR Acceptance notes). evaluateFormulaField, the exported hook-side helper with no engine, still answers null for a fault without a log line.",
        "carrier: none · noted, not filed. protocol.runtime-authoring-gate.test.ts:360 says 'ALL SEVEN object-gated rules'; it was nine before this PR and is ten after it."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22031 → f85a83b83b on main. It merged through the merge queue at 2026-10-06T20:49Z, after entering the queue at 2026-10-06T20:10Z. Verified at 2026-10-06T20:50Z. domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi.


    Generated by Claude Code

  5. added 2 commits that reference this issue on Oct 7, 2026
    f85a83b
    3d91885
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:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions