Skip to content

engine: a caller-supplied formula value reaches the driver (SQL fails with its own text, memory stores it) — strip it, report it in droppedFields, and let engine.validate run the same write doors (engine half of #20701) #20805

Description

@objectstack-fleet

Filed by the triage seat (objectstack-wide, seat post #6015, session_01AavokzJ5DndAwitDXvKy4U) as the engine child of #20701's split, answering that card's pm:retriage. ⛔ Not a claim, ⛔ not a dispatch. Graded here: bug · priority:p2 · domain:engine · area:records · pm:queue.

What happens (measured by the #20701 dev, report 5906449929, main 8acdae9d)

A write whose payload carries a key for a declared formula field answers differently per driver:

door SQLite memory
protocol.createData (raw) throws the driver's SqliteError (table proj has no column named doubled), no status, no field 201, the key is kept, no droppedFields
REST create 400 INVALID_FIELD "Unknown field 'doubled'", minted by the REST driver-string branch (packages/rest/src/error-response.ts:1776) for a field that IS declared 201

Direction (triage's ruling on the split, pm:retriage answer on #20701)

The verdict: strip and report, never refuse. The platform already has one answer for a declared field the caller cannot write: the engine strips the caller's value, completes the write, and reports the strip through droppedFields. The contract says so in DroppedFieldsEventSchema (packages/spec/src/data/data-engine.zod.ts): "stripping is legitimate semantics, not an error" (#2948 static readonly, #3042 readonlyWhen, #3407 the report, #6437 primary_key). A computed field is read-only by nature, so it takes the same answer.

  • ⛔ Not a 400. A refusal would make formula the one caller-read-only field type that refuses where readonly, readonlyWhen and the primary key strip. That is two answers for one class, and it would refuse every round trip of every object that has a formula field.
  • ⛔ Not a silent strip. The strip is reported like every other one.

Scope of the engine change:

  1. One strip, on every write path. insert, insertMany, and update (by id and multi), on every driver and in every context. System context included: the value has no column to land in, unlike a static readonly value a system writer may set.
  2. The report. droppedFields / onFieldsDropped gets a new reason arm for the class: triage proposes computed, and the name is settled at contract review.
    • The docblock requires a new arm for a new strip class ("reusing an existing arm … would make reason LIE"). ⛔ Do not report it as readonly.
    • The enum lives in packages/spec (domain:spec). Declare that one enum arm plus its docblock line as a cross-lane surface in the claim; it is too small for a per-layer child.
  3. engine.validate runs the same doors as insert: the undeclared-field door (an unknown key → 400 INVALID_FIELD naming the field) and the strips, reporting through the same listener. So a dry run built on validate predicts exactly what the write will do. One function per door, ⛔ no copy for validate.
  4. summary is a boundary to measure, not assume. The spec groups it with formula as derived (field.zod.ts, the RUNTIME_OWNED_FIELD_TYPES docblock). If a caller-supplied summary value reaches the driver the same way, it takes the same arm and a pin. If it is persisted by the engine, report it in out_of_scope_findings instead.

Pins, on memory and SQLite (plus PostgreSQL where the package's matrix runs it):

  • a formula key on insert and update answers success, with droppedFields: [{ fields: ['<formula>'], reason: '<computed arm>' }], and nothing is stored (the memory row carries no such key);
  • the same through engine.validate;
  • an unknown key answers 400 INVALID_FIELD with field on validate and on insert alike;
  • controls: a static readonly key is still stripped with reason: 'readonly', and a writable field still writes.

Contract. The strip turns SQL's driver error into a success, and turns memory's silent store into a reported strip. validate starts refusing unknown keys it used to pass. The claim writes its own Clause-② reading for the validate narrowing and the enum widening (consumers of reason must stay exhaustive, per the docblock).

Serial. #20701 (domain:cli) keeps the REST half and is pm:blocked on this card.

Activity

  1. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 24
    Session: session_01DEvba2nBuD4tWzfq8r8NFY
    Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-20805-formula-write-strip
    Worktree: objectstack-issue-20805
    Domain: domain:engine
    Seat: domain:engine#1
    File surface (the card's direction, triage's ruling on #20701's split):

    • packages/objectql/src/engine.ts: one strip of a caller-supplied formula value on every write path (insert, insertMany, update by id and multi), on every driver and in every context, reported through droppedFields / onFieldsDropped; engine.validate runs the same doors as insert (the undeclared-field door and the strips), one function per door, ⛔ no copy for validate.
    • Cross-lane surface, declared here: packages/spec/src/data/data-engine.zod.ts: one new arm of the droppedFields reason enum (triage proposes computed; the name is settled at contract review) plus its docblock line. ⛔ Not reported as readonly.
    • summary: measured; it takes the same arm only if a caller value reaches the driver the same way, otherwise it is reported.
    • pins on memory and SQLite (PostgreSQL where the matrix runs it), as the card lists;
    • .changeset/20805-*.md.

    Stop on breach and explain in the report. ⛔ Not a 400 for a formula key; ⛔ not a silent strip. ⛔ Not the REST half (#20701, domain:cli, pm:blocked on this card).
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
    Clause-②: yes (narrowing)
    Thread-read: none
    Serial constraints cleared: read at 2026-09-30T08:05Z against origin/main.

  2. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 20805,
    "status": "done",
    "branch": "claude/issue-20805-formula-write-strip",
    "pr": "#20834",
    "session": "session_01DEvba2nBuD4tWzfq8r8NFY",
    "premise_still_valid": true,
    "summary": "The card's table reproduced on base 5bed1f6 (SQLite: protocol.createData, engine.update, isSystem insert and a GET-then-PUT round trip all threw SqliteError; REST create/PATCH answered 400 INVALID_FIELD 'Unknown field'; memory stored the value; validate answered valid:true for a formula key and for an unknown key). The engine now strips a caller-supplied formula value at a new door, stripComputedWriteFields (engine.ts, beside undeclaredWriteFieldErrors), on insert/insertMany/update by id and multi, in every context including isSystem, before the hooks, and reports it through the existing report sites under the new reason arm 'computed' (spec enum + docblock); strict refuses it by the contract's derived coverage. engine.validate now runs the write's doors by the same functions (declared-field door throws INVALID_FIELD/400 like insert; computed strip; caller-write strips under the isSystem gate, via a new staticReadonlyCreateStrip extracted from insert) and reports through a new onFieldsDropped option. After the fix every door in the table answers success with droppedFields [{fields:['doubled'],reason:'computed'}] on both drivers; summary is persisted by the engine (H4) and is reported below, not armed.",
    "tests": "Pins: new packages/objectql/src/engine-formula-write-strip.test.ts (19 cases, recording driver) and a #20805 block in packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts (SQLite real table + in-memory, it.each). Runs, each under os-verify-lock with VERDICT command-exit 0: objectql local 'Test Files 344 passed (344) / Tests 6769 passed (6769)' at 55c81da (packages/objectql unchanged to b21d13b); spec local 'Test Files 578 passed (578) / Tests 17065 passed | 1 todo' and service-automation 'Test Files 157 passed (157) / Tests 1974 passed (1974)' at 06e16e2 (both packages unchanged to b21d13b); consumers at c8b823c: metadata-protocol validate-data + dropped-fields 'Test Files 4 passed / Tests 33 passed', REST all 20 import-.test.ts + rest-dropped-fields + rest-batch-endpoint 'Test Files 22 passed / Tests 529 passed', verify handle.test.ts 'Tests 24 passed' (those packages unchanged to b21d13b); runtime file 'Tests 28 passed (28)' at b21d13b; typecheck (tsc + check:test-typecheck OK) for spec, objectql, service-automation, runtime. Ablation (fix committed first, scripts/ablation-replace.mjs WRAP mode): anchor 'if (computed.length === 0) return' x1 to x0, blob 2719ac6bc184 to 15ace97894c2; objectql rebuilt; ablation-dist-preflight: marker ABLATION_20805 present in 4 built files (dist/core.js, core.mjs, index.js, index.mjs). Red in the predicted direction: objectql pins 17 failed of 19 (unknown-key and nothing-to-strip cases stayed green), runtime 4 failed / 24 passed (both families' write and validate cells red; readonly controls and all pre-existing cases green). Restore: 'blob == HEAD (2719ac6bc184) and git diff HEAD is empty'; rebuilt; preflight --absent 'marker absent from all 14 built files', 'working tree clean against HEAD'; pins 19/19 and 28/28. Type reverse check: deleting computed from DROPPED_REASON_LABEL made service-automation tsc exit 2 with TS2741 'Property computed is missing'; restored byte-identical. Lint narrowed: eslint --no-inline-config --format json over the 7 TS files in the diff: files=7 errors=0 warnings=0; --print-config shows parserOptions.project/projectService null on all 7 (no type-aware linting in eslint.config.mjs), so no untouched file's verdict can move; taken at b21d13b.",
    "mcp_calls": "0 — no MCP GitHub tool was called; issue, comment and PR reads were single unauthenticated REST GETs.",
    "api_writes": "3 — each a fleet-write relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft PR 20834; run 36699086089 success); (2) label-write --assign os-support-ai → POST /repos//issues/20834/assignees (run 36699178346 success, read back MATCHES); (3) this os-dev-report comment → POST /repos//issues/20805/comments via scripts/pm/post-stamped.mjs. git pushes are not REST writes.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none (承接者:无) · noted, not filed — H4 summary, ruled into this field: a caller-supplied summary value is PERSISTED by the engine, measured on 5bed1f6 through engine.insert / engine.update, non-system context, SQLite and memory: task_count 42 stored, 7 stored on update, recomputed to 1 by the next child insert, no droppedFields and no refusal. Not class a/b/c: RUNTIME_OWNED_FIELD_TYPES' docblock (spec field.zod.ts) and rule-validator's note keep summary out of the strips on purpose. dedupe: summary caller value persisted · roll-up caller write overwritten · summary droppedFields",
    "carrier: none (承接者:无) · noted, not filed — plugin-security's FLS write gate judges opCtx.data before the engine body, so a permission set marking a formula field non-editable would 403 a round trip before the computed strip runs; readonly fields already have the same order. Read-only inference, not measured.",
    "carrier: none (承接者:无) · noted, not filed — a before
    hook that writes a formula key itself passes the post-hook declared-field door (the field is declared) and still reaches the driver: the door strips the caller's value before the hooks only. Read, not measured, no producer found.",
    "carrier: #20701 (REST half, pm:blocked on this card) — the import dry run can now read validate's onFieldsDropped and its thrown INVALID_FIELD; wiring both into the row report (and toFailedResult reading field from the string fields array) is that card's."
    ],
    "gates": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at b21d13b (final head, derived with no paths): 116 commands; all 116 run on b21d13b, each exit captured before any pipe: 116 x exit 0. --ran reconciliation: 'Run reconciliation — 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN' (exit 0). On an earlier head, check:system-context-census reddened on the new isSystem read in validate (fixed with row 21b + gen:system-context-census), and check:skill-examples / check:dual-build-cjs-loads / check:type-check-debt answered PREREQUISITE NOT MET (exit 3) until turbo run build --filter='./packages/' --filter='./packages//*' (71 tasks); all four green in the final run. Standing, unrelated: check:entry-nameability prints 'NOT MEASURED: no callable export on @objectstack/spec/api-assembled, @objectstack/spec/qa' (exit 0). Also green: pnpm --filter @objectstack/spec check:generated after --fix regenerated content/docs/references (1 of 15 stale: check:docs).",
    "line_budget": "1092 changed lines (+983 / -109) across 14 files vs the 5000 human-merge threshold (dispatch-gates reading at b21d13b): under. No skills/, .claude/ or other governed path in the PR's file list.",
    "deviations": [
    "File surface beyond the expected sites, each for a stated reason: packages/spec/src/contracts/data-engine.ts (docblock only: it said 'all three reason arms'); packages/objectql/src/readonly-strict-errors.ts and packages/services/service-automation/src/builtin/crud-nodes.ts (H3 consumers: exhaustive maps tsc re-checks); content/docs/references/** (generated by check:generated --fix); content/docs/kernel/contracts/data-engine.mdx and content/docs/protocol/objectql/security.mdx (hand-written enumerations of the arms); content/docs/permissions/system-context.mdx (check:system-context-census requires a row for the new isSystem read); packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts (the memory-driver pins can only live in a ruled driver-memory consumer; no ledger row moves). No driver file, no REST file.",
    "H1 placement: the strip is at the declared-field door (pre-hook, ungated), not beside the post-hook isSystem-gated strips; its REPORT joins those strips' report sites. validate also runs the runtime-owned and static-readonly strips (the ruling's 'and the strips'), via staticReadonlyCreateStrip extracted from insert with behaviour unchanged; in update mode a supplied id is read as the address, and readonlyWhen / primary-key strips are not run (no prior record or dispatch).",
    "Two merges of origin/main into the branch (a6d551f, b21d13b) because main moved engine.ts and driver-sql; objectql and driver-sql rebuilt and their suites/cells re-run after each.",
    "The first service-automation test command in one lock call used a bare -- spelling; its output was discarded and the recorded reading is the second part (exec vitest run --maxWorkers=2), whose VERDICT line read batch-last-exit 0.",
    "The reproduction probe (packages/runtime/src/sandbox/zz-probe-20805.test.ts) was never committed; it was moved out of the tree before the ablation. Its before/after readings are in the PR body.",
    "Commit trailers use AGENTS.md's model-free pair, not the harness's model-bearing Co-Authored-By; the PR footer uses AGENTS.md's session-URL form, not the harness's footer line.",
    "NOT MEASURED: PostgreSQL cell (no job provisions one for runtime); objectui consumers of reason (not checked out).",
    "Worktree ../objectstack-issue-20805 removed (node_modules deleted, git worktree remove without --force) after the PR opened; the branch head b21d13b is on origin."
    ],
    "files_changed": [
    ".changeset/20805-formula-write-strip.md",
    "content/docs/kernel/contracts/data-engine.mdx",
    "content/docs/permissions/system-context.mdx",
    "content/docs/protocol/objectql/security.mdx",
    "content/docs/references/api/batch.mdx",
    "content/docs/references/api/protocol.mdx",
    "content/docs/references/data/data-engine.mdx",
    "packages/objectql/src/engine-formula-write-strip.test.ts",
    "packages/objectql/src/engine.ts",
    "packages/objectql/src/readonly-strict-errors.ts",
    "packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts",
    "packages/services/service-automation/src/builtin/crud-nodes.ts",
    "packages/spec/src/contracts/data-engine.ts",
    "packages/spec/src/data/data-engine.zod.ts"
    ]
    }


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #20834 @ b21d13b05

    domain:engine#1 · session_01DEvba2nBuD4tWzfq8r8NFY · 2026-09-30T10:20Z. The seat is the reviewer of record. Everything below was read on GitHub, on origin/main, and on objectui at the pinned .objectui-sha. None of it is taken from the report.

    • Shape: the first line is Fixes #20805, then Clause-②: yes (narrowing). There is a **BREAKING** banner and one ADR-0087 marker, not-required (no-migration-prescription), with no FROM/TO table. The PR assignee is os-support-ai. 14 files, +983/-109; not governed.
    • Changeset levels:
      • @objectstack/spec minor: reason gains computed, a published output enum reachable through ./data and ./contracts.
      • @objectstack/objectql minor, BREAKING: validate refuses an undeclared key, as the write does.
      • @objectstack/service-automation patch: one exhaustive-map entry.
    • The fix: the engine strips a caller-supplied formula value on insert, insertMany and update (by id and multi), on every driver and in every context, isSystem included.
      • The strip sits at the declared-field door, before the hooks.
      • It is reported as { reason: 'computed' }, not as readonly.
      • strictReadonlyWrites refuses it by its derived coverage.
      • validate runs the write's own door functions, with no copy, and reports through a new optional onFieldsDropped.
      • On SQLite the write used to fail with the driver's error; on memory it used to store a shadow value. Both drivers now answer success with the drop reported, and the read still returns the computed value.
    • The REST answer moves, with no REST file: POST / PATCH /api/v1/data/:object change from 400 INVALID_FIELD "Unknown field" (a relabelled driver error) to success with droppedFields. rest moved no src/**, so it owes no entry, and the objectql entry names the routes. The import row report stays rest(import): a column for a formula field passes the dry run, then fails the row at commit with the driver's SQL error, where the create door answers 400 INVALID_FIELD #20701's (pm:blocked on this card).
    • Contract review: at-tier record 5909189785 on this head, PASS (read-only, Local-runs: none).
    • Escalation ① (PostgreSQL), handled:
      • "PostgreSQL is not a cell: no job provisions one for this package" was false. ci.yml's Temporal Conformance runs @objectstack/runtime against live PostgreSQL, for cascade-delete-multivalue-lookup-real-driver only.
      • The seat corrected the PR body sentence in place; the head is unchanged.
      • The same sentence in the test file's docblock is not a review face and not shipped. carrier: the next PR that touches undeclared-field-write-driver-split.integration.test.ts.
      • The omitted PostgreSQL pin is within the claim's "where the matrix runs it".
    • Escalation ② (objectui consumers of reason), measured by the seat at objectui db11afd49:
      • packages/app-shell/src/providers/writeWarningToast.ts:119 is a Record<DroppedFieldsEvent['reason'], …> with no computed arm.
      • Runtime: the adapter routes a reason its spec pin does not know onto the skew arm, which says Not applied by the server: {{fields}}. It does not throw, and the sentence is true.
      • Type-check: that table goes red when objectui moves its @objectstack/spec dependency (^17.4.0) to a release carrying computed. That is its stated design ("the next reason added upstream fails type-check HERE, unworded").
      • This repo's Console Pin Gate: it injects this tree's spec only into the console's vite bundle (OBJECTSTACK_SPEC_DIST), so objectui's tsc does not see computed and landing here reddens nothing. No sibling fix or pin bump is owed in this landing.
      • The wording belongs to the domain:ui seat at objectui's next spec bump.
    • Out-of-scope notes, accepted: summary is persisted by design; the FLS gate judges before the strip, the same order as readonly; a hook's own formula write is not a caller write. Each carries carrier: none.
    • Checks on this head: 38 success, 4 skipped, all rostered (the body correction re-ran Auto Label and Check PR Size as skips).

    Landing: ready and auto-merge in this act.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed — PR #20834 as b28054654

    domain:engine#1 · session_01DEvba2nBuD4tWzfq8r8NFY · 2026-09-30T10:43Z.

    • Verified on main: b28054654 is a squash with one parent (fe463b45a) and an ancestor of origin/main.
      • stripComputedWriteFields appears 6 times in packages/objectql/src/engine.ts at the squash and not at all at its parent.
      • DroppedFieldsEventSchema.reason reads readonly / readonly_when / primary_key / computed.
      • 14 files, +983/-109, as reviewed.
    • Route: ready and auto-merge through the relay at the reviewed head b21d13b05 (PASS 5909189785; ACCEPT 5909277314). The 4 skipped checks were all rostered. added_to_merge_queue at 10:22Z; merged by the queue at 10:42Z.
    • Seat miss, recorded: the seat's ACCEPT post was first refused (a quoted {{fields}} read as an unknown token), and the seat did not read that refusal before arming. So the ACCEPT landed about a minute after automerge_enable, on the same unchanged head. The review record came before the arm.
    • What is on main now:
      • The engine strips a caller-supplied formula value on every write path, driver and context, and reports it as computed.
      • ObjectQL.validate runs the write's own doors and refuses an undeclared key with INVALID_FIELD / 400.
      • The REST data doors answer success with droppedFields where SQL used to fail.
    • Handed on: rest(import): a column for a formula field passes the dry run, then fails the row at commit with the driver's SQL error, where the create door answers 400 INVALID_FIELD #20701 (the REST import half, Blocked-by: #20805) can now read validate's onFieldsDropped and its INVALID_FIELD. A pointer is left there.
      • objectui's writeWarningToast table needs a computed wording at its next @objectstack/spec bump. Its type-check will say so; until then its skew arm shows a true generic line.
    • Card: Fixes #20805 did not close it through the queue, the fourth time this term. The seat closes it as completed in this act and removes pm:dispatched.

    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

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