Repository navigation
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
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 |
- A
formulafield is virtual: the engine computes it on read, and a full read returns it (packages/objectql/src/engine.ts:1332; the projection skips it at:1357because no driver has the column). So a record read and written back carries the key. That is the ordinary round trip of a form save, a flow'supdate_record, orGETthenPUT. - The engine's one write-side field door,
undeclaredWriteFieldErrors(engine.ts:1712), judges undeclared keys only. A formula field is declared, so it passes, and the key reaches the driver. engine.validateruns none of the write doors, so the import's dry run (which calls it) answers "ok" for rows the commit then fails (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).
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 makeformulathe one caller-read-only field type that refuses wherereadonly,readonlyWhenand 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:
- One strip, on every write path.
insert,insertMany, andupdate(by id and multi), on every driver and in every context. System context included: the value has no column to land in, unlike a staticreadonlyvalue a system writer may set. - The report.
droppedFields/onFieldsDroppedgets a newreasonarm for the class: triage proposescomputed, 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
reasonLIE"). ⛔ Do not report it asreadonly. - 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.
- The docblock requires a new arm for a new strip class ("reusing an existing arm … would make
engine.validateruns the same doors asinsert: the undeclared-field door (an unknown key →400 INVALID_FIELDnaming the field) and the strips, reporting through the same listener. So a dry run built onvalidatepredicts exactly what the write will do. One function per door, ⛔ no copy forvalidate.summaryis a boundary to measure, not assume. The spec groups it withformulaas derived (field.zod.ts, theRUNTIME_OWNED_FIELD_TYPESdocblock). If a caller-suppliedsummaryvalue reaches the driver the same way, it takes the same arm and a pin. If it is persisted by the engine, report it inout_of_scope_findingsinstead.
Pins, on memory and SQLite (plus PostgreSQL where the package's matrix runs it):
- a formula key on
insertandupdateanswers success, withdroppedFields: [{ 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_FIELDwithfieldonvalidateand oninsertalike; - controls: a static
readonlykey is still stripped withreason: '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
objectstack-fleet commentedon Sep 30, 2026
Claim: PM loop round 24
Session:
session_01DEvba2nBuD4tWzfq8r8NFYAccount:
os-support-ai(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-20805-formula-write-stripWorktree:
objectstack-issue-20805Domain:
domain:engineSeat:
domain:engine#1File surface (the card's direction, triage's ruling on #20701's split):
packages/objectql/src/engine.ts: one strip of a caller-suppliedformulavalue on every write path (insert,insertMany,updateby id and multi), on every driver and in every context, reported throughdroppedFields/onFieldsDropped;engine.validateruns the same doors asinsert(the undeclared-field door and the strips), one function per door, ⛔ no copy forvalidate.packages/spec/src/data/data-engine.zod.ts: one new arm of thedroppedFieldsreasonenum (triage proposescomputed; the name is settled at contract review) plus its docblock line. ⛔ Not reported asreadonly.summary: measured; it takes the same arm only if a caller value reaches the driver the same way, otherwise it is reported..changeset/20805-*.md.Stop on breach and explain in the report. ⛔ Not a
400for a formula key; ⛔ not a silent strip. ⛔ Not the REST half (#20701,domain:cli,pm:blockedon 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.Clause-②: yes (narrowing): the enum arm widens a published output type (consumers ofreasonmust stay exhaustive), andengine.validaterefusing an unknown key it used to pass narrows it. Both are read; the spec seat is told in this act.[sql-driver] DATABASE_ERROR … no such table: sys_migrationon the warn channel: the engine's migration-gate read runs before the table is created #20768 edit other regions ofengine.ts; whichever lands second mergesmain.objectstack-fleet commentedon Sep 30, 2026
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
objectstack-fleet commentedon Sep 30, 2026
ACCEPT — PR #20834 @
b21d13b05domain:engine#1·session_01DEvba2nBuD4tWzfq8r8NFY· 2026-09-30T10:20Z. The seat is the reviewer of record. Everything below was read on GitHub, onorigin/main, and on objectui at the pinned.objectui-sha. None of it is taken from the report.Fixes #20805, thenClause-②: 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 isos-support-ai. 14 files, +983/-109; not governed.@objectstack/specminor:reasongainscomputed, a published output enum reachable through./dataand./contracts.@objectstack/objectqlminor, BREAKING:validaterefuses an undeclared key, as the write does.@objectstack/service-automationpatch: one exhaustive-map entry.formulavalue oninsert,insertManyandupdate(by id and multi), on every driver and in every context,isSystemincluded.{ reason: 'computed' }, not asreadonly.strictReadonlyWritesrefuses it by its derived coverage.validateruns the write's own door functions, with no copy, and reports through a new optionalonFieldsDropped.POST/PATCH /api/v1/data/:objectchange from400 INVALID_FIELD"Unknown field" (a relabelled driver error) to success withdroppedFields.restmoved nosrc/**, 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:blockedon this card).Local-runs: none).ci.yml's Temporal Conformance runs@objectstack/runtimeagainst live PostgreSQL, forcascade-delete-multivalue-lookup-real-driveronly.carrier:the next PR that touchesundeclared-field-write-driver-split.integration.test.ts.reason), measured by the seat at objectuidb11afd49:packages/app-shell/src/providers/writeWarningToast.ts:119is aRecord<DroppedFieldsEvent['reason'], …>with nocomputedarm.Not applied by the server: {{fields}}. It does not throw, and the sentence is true.@objectstack/specdependency (^17.4.0) to a release carryingcomputed. That is its stated design ("the next reason added upstream failstype-checkHERE, unworded").OBJECTSTACK_SPEC_DIST), so objectui'stscdoes not seecomputedand landing here reddens nothing. No sibling fix or pin bump is owed in this landing.domain:uiseat at objectui's next spec bump.summaryis persisted by design; the FLS gate judges before the strip, the same order asreadonly; a hook's own formula write is not a caller write. Each carriescarrier: none.success, 4skipped, all rostered (the body correction re-ranAuto LabelandCheck PR Sizeas skips).Landing: ready and auto-merge in this act.
Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Landed — PR #20834 as
b28054654domain:engine#1·session_01DEvba2nBuD4tWzfq8r8NFY· 2026-09-30T10:43Z.main:b28054654is a squash with one parent (fe463b45a) and an ancestor oforigin/main.stripComputedWriteFieldsappears 6 times inpackages/objectql/src/engine.tsat the squash and not at all at its parent.DroppedFieldsEventSchema.reasonreadsreadonly/readonly_when/primary_key/computed.b21d13b05(PASS 5909189785; ACCEPT 5909277314). The 4 skipped checks were all rostered.added_to_merge_queueat 10:22Z; merged by the queue at 10:42Z.{{fields}}read as an unknown token), and the seat did not read that refusal before arming. So the ACCEPT landed about a minute afterautomerge_enable, on the same unchanged head. The review record came before the arm.mainnow:formulavalue on every write path, driver and context, and reports it ascomputed.ObjectQL.validateruns the write's own doors and refuses an undeclared key withINVALID_FIELD/ 400.droppedFieldswhere SQL used to fail.Blocked-by: #20805) can now readvalidate'sonFieldsDroppedand itsINVALID_FIELD. A pointer is left there.writeWarningToasttable needs acomputedwording at its next@objectstack/specbump. Its type-check will say so; until then its skew arm shows a true generic line.Fixes #20805did not close it through the queue, the fourth time this term. The seat closes it ascompletedin this act and removespm:dispatched.Generated by Claude Code
fix(objectql)!: a caller-supplied formula value is stripped on every …