Repository navigation
record write door: '' skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3area:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, search
on Sep 27, 2026 objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsClaim: PM loop round 26
Session:session_01Bvd69VPa6puiNzzPUroDBx
Account:os-sales(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-20308-empty-string-typed-null
Worktree:objectstack-issue-20308
Domain:domain:engine
Seat:domain:engine#1
File surface:packages/objectql/src/validation/record-validator.ts: following the triage direction in the card body (the card has no comments; its body carries the triage), normalise''→nullat the write door for every non-string-typed column (NUMERIC_VALUE_TYPESincludingprogress/summary, boolean,date,datetime,time), before type validation, keyed on the declared field type; and addprogress/summaryto the number branch's type checks;packages/objectql/src/engine.ts: only the write path's call into that one rule (create / update / batch), if the validator cannot carry the rewrite itself. ⛔ Not theaggregate/havingregion, which open PR fix(objectql)!: having takes the temporal-comparand door where and the per-aggregation filter take (#20263) #20307 edits;- tests in
objectql, the drivers and REST (test side only); .changeset/20308-*.md.
Stop on breach and explain in the report. ⛔ String-stored columns (text, lookup ids, select values) are untouched: the spec fixture
str_emptypins "empty string must not become null". ⛔ Notpackages/spec. ⛔ No driver copy. ⛔ Stored rows: census and propose a repair in the PR; run none.
Container & model:S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no
Thread-read: none
Serial constraints cleared: at 2026-09-27T21:47Z, a census of the 14 open PRs finds none onrecord-validator.tsorvalidation/. Open PR #20307 (#20263) editsengine.ts'saggregatehavingentry only, a different region of the file. The seat runsbatch:3: two at-tier reviews (PR #20306 and PR #20307) and this card. #20264 also refuses on thedatewrite door and goes after this card.objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 20308, "status": "done", "branch": "claude/issue-20308-empty-string-typed-null", "pr": "https://github.com/objectstack-ai/objectstack/pull/20340", "session": "session_01Bvd69VPa6puiNzzPUroDBx", "premise_still_valid": true, "summary": "A blank string ('' or whitespace, what isMissing reads as missing) on a field whose declared type is in the spec's NON_TEXT_STORED_VALUE_TYPES (number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time) now becomes null at the start of ObjectQL.insert() / update(). The same happens at the same point in validate(), the dry run. The door does it before the middleware, the caller snapshots, the defaults, the hooks, the strips and validation. The rule is one function, normalizeBlankTypedValues in record-validator.ts; there is no REST or driver copy, and every REST, batch and import door reaches the engine through those methods. Measured on memory, SQLite and live PostgreSQL across 11 engine and REST doors: base stored '' (memory, SQLite) or was refused by the driver (PostgreSQL 22P02 / 22007, REST 500); head stores null everywhere. Text, lookup and select '' are byte-identical base to head, as are valid values and existing refusals. The number branch now reads NUMERIC_VALUE_TYPES, so 'abc' on progress or summary answers invalid_number; only the type check was added, and no bound or scale. Defaults: a blank now takes a defaultValue on insert exactly as null does. That changed one verdict: a required-with-default blank was refused as required and now takes the default. Consequence reported: a summary roll-up doing min/max over a temporal child field is now refused at recompute on memory and SQLite (ERR_SUMMARY_RECOMPUTE), as it already was on PostgreSQL.", "tests": "All suites below were run under scripts/pm/os-verify-lock.sh. Post-merge head 4525303324 (origin/main a78f731add merged): @objectstack/objectql vitest run, both projects: 323 files, 5861 passed; @objectstack/rest, both projects: 205 files, 3687 passed, 1 skipped. Pre-merge head e0c193f4bd, where the driver packages are not in the diff: @objectstack/driver-memory 57 files, 1374 passed; @objectstack/driver-sql with live PostgreSQL 16 (TZ=America/New_York, server Asia/Shanghai): 199 files passed, 3 skipped; 3888 passed, 88 skipped (the MySQL cells). A first driver-sql run at process TZ=UTC failed only assertThreeWayZoneSkew, which is environment. Value round-trip conformance, verbose: memory 44 passed, str_empty listed; driver-sql 89 passed, 1 skipped (MySQL), with str_empty listed on SQLite and live PostgreSQL. Typecheck for objectql and rest: exit 0 at 4525303324; objectql again exit 0 at the final head 7bae61b0d2. New files: packages/objectql/src/validation/record-validator.blank-typed-value.test.ts, packages/objectql/src/engine-blank-typed-value-door.test.ts (33 tests across both objectql files) and packages/rest/src/rest-data-blank-typed-value.test.ts (6). Reverse verification. Each leg's mutation was proved on disk (ablation-replace anchor count 1 to 0 and a changed blob; leg C by a python count assert), objectql was rebuilt, and ablation-dist-preflight showed the marker present in 4 dist files. Leg A, the normaliser's rewrite removed: 12 of 33 objectql cases and 3 of 6 REST cases red. Leg B, the numeric door back to the five-type hand-list: 3 red (progress and summary invalid_number, and the REST garbage case). Its DTS build failed on TS6133 / TS2367, and the JS carried the marker. Leg C, the update() call moved to the validator site after the snapshot: exactly 1 red, the readonlyWhen lock pin. Every leg was restored with git checkout HEAD: blob equal to HEAD, git status --porcelain empty, rebuilt, --absent preflight passed, and 33/33 and 6/6 green. A, B and C ran at e0c193f4bd; A and C were re-run at the final head 7bae61b0d2 with identical counts.", "mcp_calls": "2 — mcp__Claude_Code_Remote__add_repo (objectstack-ai/objectui, access read; answered read_available, nothing attached); mcp__github__issue_read (objectui#10813 get; denied: repository not configured for this session). No MCP write tool.", "api_writes": "3 — each through the fleet-write relay as objectstack-fleet[bot] (repository_dispatch to objectstack-ai/objectstack): pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft #20340); assign via scripts/pm/label-write.mjs → POST /repos/objectstack-ai/objectstack/issues/20340/assignees (os-sales); this os-dev-report via scripts/pm/post-stamped.mjs → POST /repos/objectstack-ai/objectstack/issues/20308/comments. Plus git push to the branch (not REST). No label written.", "open_questions": [ { "question": "Clause-② arm. The claim's `Clause-②: no` is copied into the PR body as written, with no arm. On memory and SQLite two cells now refuse what they accepted: 'abc' on progress / summary (now invalid_number), and a summary min/max over a temporal child field (now ERR_SUMMARY_RECOMPUTE at recompute). PostgreSQL already refused both (22P02 as a 500, and ERR_SUMMARY_RECOMPUTE). Is that a narrowing needing an arm?", "options": [ "A — keep `Clause-②: no` without an arm: neither value is admitted by the spec's stored-value contract for these types (valueSchemaFor → z.number().finite(); a numeric column), so this converges the backends onto the declared contract, and check-changeset-no-major and check-adr-0087-registration are green on the patch changeset", "B — `Clause-②: no (narrowing)`: the changeset becomes BREAKING with an ADR-0087 `not-required (no-migration-prescription)` marker (still minor at most, per the launch window)" ], "recommendation": "A. On business need: no example app writes a non-numeric progress or summary, and every example roll-up is sum or count (measured over examples/). On long-term soundness: the declared contract (NUMERIC_VALUE_TYPES, a numeric column) already said number, and PostgreSQL enforced it. On AI safety: refusing loudly at the door is the contract-tightening direction. On not spreading scope: B adds a BREAKING banner for a value no deployment could keep on PostgreSQL. The seat decides; the correction text is in the PR body if B." }, { "question": "summary with min/max over a date/datetime/time child field: the spec accepts it at parse, but its value is a date string written into a numeric summary. After this card the recompute is refused on all three backends. Keep that, or exempt summary from the new type check?", "options": [ "A — keep; the spec's numeric declaration for summary governs, and the authoring gap is filed as its own card (refuse temporal min/max at parse, or give summary a temporal value type — a spec decision)", "B — exempt summary from the finite-number check (reverse execution note 2's summary half), leaving 'abc' on summary stored on memory/SQLite again" ], "recommendation": "A. The before-state was already a failure on PostgreSQL and an off-contract string in a numeric column on SQLite; B re-opens the caller-side hole the ruling closed. The real fix is at authoring, and that is the finding filed below." } ], "out_of_scope_findings": [ "class: c · summaryOperations accepts function 'max' / 'min' over a date, datetime or time child field at parse (packages/spec/src/data/field.zod.ts summaryOperations: `function: z.enum(['count','sum','min','max','avg'])`, `field: z.string()`), but the runtime cannot store the result: the recompute writes a date string into the numeric summary. Measured at base de091b50e6: PostgreSQL ERR_SUMMARY_RECOMPUTE (max_d stays null); memory and SQLite store the string '2026-01-05'. At head: ERR_SUMMARY_RECOMPUTE on all three. Producer: an author, or an AI writing object metadata · dedupe words: summaryOperations min max date, roll-up temporal summary recompute, ERR_SUMMARY_RECOMPUTE date string numeric column", "class: b · `min` / `max` declared on a `progress` field are never enforced at the write door. Contract: FieldSchema.min describe 「Minimum value. Checked on the WRITTEN value only」 (and max alike), with no type limit; record-validator.ts enforces bounds on number/currency/percent/rating/slider only (base and head; the head unit test writes 150.5 to a progress field declaring max 100 and it is accepted). Seam: spec:FieldSchema.min/max → runtime:packages/objectql/src/validation/record-validator.ts validateOne numeric branch · dedupe words: progress min max not enforced, FieldSchema min WRITTEN value progress, numeric bounds progress", "class: b · spec COMPUTED_VALUE_TYPES (packages/spec/src/data/field-value.zod.ts) declares 「Server-computed types: never client-written; shape is producer-owned」 for formula, summary and autonumber. Measured: a caller's value on a summary field is accepted and stored through engine.insert and REST POST (7 at base and head, on all three drivers). RUNTIME_OWNED_FIELD_TYPES (field.zod.ts) strips only autonumber, and says summary is deliberately not there. The two declarations disagree. Seam: spec:COMPUTED_VALUE_TYPES → runtime:ObjectQL.insert/update (no strip for summary) · dedupe words: summary client-written, COMPUTED_VALUE_TYPES never client-written, summary writable caller value", "carrier: seat (card owner) · noted, not filed — the objectui#10813 back-link and the widget's typed-column '' member are the seat's at landing; no objectui write was made here" ], "gates": { "head": "7bae61b0d2", "merge_base": "a78f731add", "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 66 commands", "ran": "66 run at the final head, exit codes recorded before any pipe", "exit_0": 64, "not_measured": [ "pnpm check:dual-build-cjs-loads — exit 3 PREREQUISITE NOT MET (needs every package's dist/)", "pnpm check:type-check-debt — exit 3 PREREQUISITE NOT MET (needs the whole packages/* closure built)" ], "reconcile": "dispatch-gates --ran: 66 derived famil(ies) accounted for — 64 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3); exit 0", "issue_citations": "node scripts/check-issue-citations.mjs --base a78f731add → exit 0, 12 judged, 12 resolve", "fixed_on_the_way": "check:objectql-double-limit was red on this PR's first engine-test stub (its id-only find could not be seated); the stub's find now answers the store, and the gate is green, none new", "ci_at_report": "in_progress — 31 check runs at 7bae61b0d2: 11 success, 3 skipped, 17 in_progress, 0 failed at read time" }, "line_budget": "n/a — no skills/** in the diff", "deviations": [ "The pins for memory and PostgreSQL are measured, not committed. A new @objectstack/driver-memory test consumer is refused by check:driver-memory-census without a ruling (scripts/driver-memory-census.ledger.json), and no CI job provisions PostgreSQL for objectql or rest, so a live leg would be a permanent skip. The committed pins are the engine's driver-facing payload (what memory and MongoDB store verbatim) and the SQLite physical column through REST; all three drivers are in `rows`.", "The objectui reads (objectui#10813 with comments 5858799523 and 5859057830, objectui#10790, PR objectui#10834) are NOT MEASURED. REST GET answered 403 ('GitHub access to this repository is not enabled for this session'), the public web page answered the same, and MCP issue_read was denied. No push-access attach was requested. The card body's recorded reading of those threads was used instead.", "engine.ts has a third call site in validate() (the dry run), beside insert() / update(), for write/preview parity on a required-with-default blank. It is in the same file, outside the excluded aggregate/having region, and is named in the PR body.", "The PR assignee was set right after pr_create, because the relay's pr_create op carries no assignees. It went through the one budgeted scripts/pm/label-write.mjs --assign os-sales and was read back as matching.", "origin/main (a78f731add, which includes #20307) was merged into the branch before the PR, per AGENTS.md Multi-agent §10. The build was refreshed and the objectql and rest suites were re-run on the merged head.", "PostgreSQL: a private role and database (os_i20308) were provisioned on 16/main through the postgres OS user; a read-only role listing was refused by the session classifier and not retried. Both were dropped at the end, and the cluster was never stopped or restarted." ], "files_changed": [ "packages/objectql/src/validation/record-validator.ts", "packages/objectql/src/engine.ts", "packages/objectql/src/validation/record-validator.blank-typed-value.test.ts", "packages/objectql/src/engine-blank-typed-value-door.test.ts", "packages/rest/src/rest-data-blank-typed-value.test.ts", ".changeset/20308-blank-typed-value-null.md" ], "rows": [ { "door": "engine insert", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 22P02", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": null, "read": null } } }, { "door": "engine insert([...])", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 22P02", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": null, "read": null } } }, { "door": "engine insertMany", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 22P02", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": null, "read": null } } }, { "door": "engine update by id", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 22P02", "stored": [ "0.25", "10", "14:30:00", "2026-09-27", "42", "50", "7", "9.5", "Date(2026-09-27T10:00:00.000Z)", 3, false, true ], "read": [ "14:30:00", "2026-09-27", "2026-09-27T10:00:00.000Z", 0.25, 10, 3, 42, 50, 7, 9.5, false, true ] }, "head": { "answer": "ok", "stored": null, "read": null } } }, { "door": "engine update by predicate", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 22P02", "stored": [ "0.25", "10", "14:30:00", "2026-09-27", "42", "50", "7", "9.5", "Date(2026-09-27T10:00:00.000Z)", 3, false, true ], "read": [ "14:30:00", "2026-09-27", "2026-09-27T10:00:00.000Z", 0.25, 10, 3, 42, 50, 7, 9.5, false, true ] }, "head": { "answer": "ok", "stored": null, "read": null } } }, { "door": "REST POST create", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok 201", "stored": "", "read": "" }, "head": { "answer": "ok 201", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok 201", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok 201", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 500 DATABASE_ERROR", "stored": "no row", "read": "no row" }, "head": { "answer": "ok 201", "stored": null, "read": null } } }, { "door": "REST PATCH update", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok 200", "stored": "", "read": "" }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok 200", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 500 DATABASE_ERROR", "stored": [ "0.25", "10", "14:30:00", "2026-09-27", "42", "50", "7", "9.5", "Date(2026-09-27T10:00:00.000Z)", 3, false, true ], "read": [ "14:30:00", "2026-09-27", "2026-09-27T10:00:00.000Z", 0.25, 10, 3, 42, 50, 7, 9.5, false, true ] }, "head": { "answer": "ok 200", "stored": null, "read": null } } }, { "door": "REST batch create", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok 200", "stored": "", "read": "" }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok 200", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "pg": { "base": { "answer": "200, row failed INTERNAL_ERROR", "stored": "no row", "read": "no row" }, "head": { "answer": "ok 200", "stored": null, "read": null } } }, { "door": "REST batch update", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok 200", "stored": "", "read": "" }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok 200", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "pg": { "base": { "answer": "200, row failed INTERNAL_ERROR", "stored": [ "0.25", "10", "14:30:00", "2026-09-27", "42", "50", "7", "9.5", "Date(2026-09-27T10:00:00.000Z)", 3, false, true ], "read": [ "14:30:00", "2026-09-27", "2026-09-27T10:00:00.000Z", 0.25, 10, 3, 42, 50, 7, 9.5, false, true ] }, "head": { "answer": "ok 200", "stored": null, "read": null } } }, { "door": "REST createMany", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok 201", "stored": "", "read": "" }, "head": { "answer": "ok 201", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok 201", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok 201", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 500 DATABASE_ERROR", "stored": "no row", "read": "no row" }, "head": { "answer": "ok 201", "stored": null, "read": null } } }, { "door": "REST updateMany", "value": "''", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok 200", "stored": "", "read": "" }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok 200", "stored": "", "read": [ "", false ] }, "head": { "answer": "ok 200", "stored": null, "read": null } }, "pg": { "base": { "answer": "200, row failed INTERNAL_ERROR", "stored": [ "0.25", "10", "14:30:00", "2026-09-27", "42", "50", "7", "9.5", "Date(2026-09-27T10:00:00.000Z)", 3, false, true ], "read": [ "14:30:00", "2026-09-27", "2026-09-27T10:00:00.000Z", 0.25, 10, 3, 42, 50, 7, 9.5, false, true ] }, "head": { "answer": "ok 200", "stored": null, "read": null } } }, { "door": "engine insert, whitespace", "value": "' '", "types": "number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time (identical per type unless noted)", "memory": { "base": { "answer": "ok", "stored": " ", "read": " " }, "head": { "answer": "ok", "stored": null, "read": null } }, "sqlite": { "base": { "answer": "ok", "stored": " ", "read": [ " ", true ] }, "head": { "answer": "ok", "stored": null, "read": null } }, "pg": { "base": { "answer": "refused 22P02", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": null, "read": null } } }, { "door": "engine insert, text/lookup/select", "control": "string-stored (str_empty side)", "memory": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": "", "read": "" }, "byte_identical": true }, "sqlite": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": "", "read": "" }, "byte_identical": true }, "pg": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": "", "read": "" }, "byte_identical": true } }, { "door": "engine update, text/lookup/select", "control": "string-stored (str_empty side)", "memory": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": "", "read": "" }, "byte_identical": true }, "sqlite": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": "", "read": "" }, "byte_identical": true }, "pg": { "base": { "answer": "ok", "stored": "", "read": "" }, "head": { "answer": "ok", "stored": "", "read": "" }, "byte_identical": true } }, { "door": "REST create, text/lookup/select", "control": "string-stored (str_empty side)", "memory": { "base": { "answer": "ok 201", "stored": "", "read": "" }, "head": { "answer": "ok 201", "stored": "", "read": "" }, "byte_identical": true }, "sqlite": { "base": { "answer": "ok 201", "stored": "", "read": "" }, "head": { "answer": "ok 201", "stored": "", "read": "" }, "byte_identical": true }, "pg": { "base": { "answer": "ok 201", "stored": "", "read": "" }, "head": { "answer": "ok 201", "stored": "", "read": "" }, "byte_identical": true } }, { "door": "engine insert, text whitespace", "control": "string-stored (str_empty side)", "memory": { "base": { "answer": "ok", "stored": " ", "read": " " }, "head": { "answer": "ok", "stored": " ", "read": " " }, "byte_identical": true }, "sqlite": { "base": { "answer": "ok", "stored": " ", "read": " " }, "head": { "answer": "ok", "stored": " ", "read": " " }, "byte_identical": true }, "pg": { "base": { "answer": "ok", "stored": " ", "read": " " }, "head": { "answer": "ok", "stored": " ", "read": " " }, "byte_identical": true } }, { "door": "engine insert, valid value per type", "control": "valid values (42, 9.5, 0.25, 3, 10, 50, 7, true, false, 2026-09-27, 2026-09-27T10:00:00.000Z, 14:30:00)", "memory": { "base_answer": "ok", "head_answer": "ok", "byte_identical_answer_stored_read": true }, "sqlite": { "base_answer": "ok", "head_answer": "ok", "byte_identical_answer_stored_read": true }, "pg": { "base_answer": "ok", "head_answer": "ok", "byte_identical_answer_stored_read": true } }, { "door": "REST create, valid value per type", "control": "valid values (42, 9.5, 0.25, 3, 10, 50, 7, true, false, 2026-09-27, 2026-09-27T10:00:00.000Z, 14:30:00)", "memory": { "base_answer": "ok 201", "head_answer": "ok 201", "byte_identical_answer_stored_read": true }, "sqlite": { "base_answer": "ok 201", "head_answer": "ok 201", "byte_identical_answer_stored_read": true }, "pg": { "base_answer": "ok 201", "head_answer": "ok 201", "byte_identical_answer_stored_read": true } }, { "door": "engine insert, '' on required number/date/boolean (+ required number with defaultValue)", "memory": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required,rd_number:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } }, "sqlite": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required,rd_number:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } }, "pg": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required,rd_number:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } } }, { "door": "engine insert, null on the same (control)", "memory": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } }, "sqlite": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } }, "pg": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } } }, { "door": "engine update, '' on required number/date/boolean", "memory": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": [ "2026-01-01", 1, true ], "read": [ "2026-01-01", 1, true ] }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": [ "2026-01-01", 1, true ], "read": [ "2026-01-01", 1, true ] } }, "sqlite": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": [ "2026-01-01", 1 ], "read": [ "2026-01-01", 1, true ] }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": [ "2026-01-01", 1 ], "read": [ "2026-01-01", 1, true ] } }, "pg": { "base": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": [ "1", "2026-01-01", true ], "read": [ "2026-01-01", 1, true ] }, "head": { "answer": "refused VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": [ "1", "2026-01-01", true ], "read": [ "2026-01-01", 1, true ] } } }, { "door": "REST create, '' on required number/date/boolean", "memory": { "base": { "answer": "refused 400 VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused 400 VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } }, "sqlite": { "base": { "answer": "refused 400 VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused 400 VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } }, "pg": { "base": { "answer": "refused 400 VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" }, "head": { "answer": "refused 400 VALIDATION_FAILED r_number:required,r_date:required,r_bool:required", "stored": "no row", "read": "no row" } } }, { "door": "engine insert, '' on a required number with defaultValue 5", "memory": { "base": { "answer": "refused VALIDATION_FAILED rd_number:required", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": 5, "read": 5 } }, "sqlite": { "base": { "answer": "refused VALIDATION_FAILED rd_number:required", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": 5, "read": 5 } }, "pg": { "base": { "answer": "refused VALIDATION_FAILED rd_number:required", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": "5", "read": 5 } } }, { "door": "engine insert, '' on optional number/date with defaultValue", "memory": { "base": { "answer": "ok", "stored": [ "", 1 ], "read": [ "", 1 ] }, "head": { "answer": "ok", "stored": [ "2020-01-01", 1, 7 ], "read": [ "2020-01-01", 1, 7 ] } }, "sqlite": { "base": { "answer": "ok", "stored": [ "", 1 ], "read": [ "", 1 ] }, "head": { "answer": "ok", "stored": [ "2020-01-01", 1, 7 ], "read": [ "2020-01-01", 1, 7 ] } }, "pg": { "base": { "answer": "refused 22007", "stored": "no row", "read": "no row" }, "head": { "answer": "ok", "stored": [ "1", "2020-01-01", "7" ], "read": [ "2020-01-01", 1, 7 ] } } }, { "door": "engine insert, 'abc' on progress", "memory": { "base": { "answer": "ok", "stored": "abc", "read": "abc" }, "head": { "answer": "refused VALIDATION_FAILED f_progress:invalid_number", "stored": "no row", "read": "no row" } }, "sqlite": { "base": { "answer": "ok", "stored": "abc", "read": "abc" }, "head": { "answer": "refused VALIDATION_FAILED f_progress:invalid_number", "stored": "no row", "read": "no row" } }, "pg": { "base": { "answer": "refused 22P02", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED f_progress:invalid_number", "stored": "no row", "read": "no row" } } }, { "door": "engine insert, 'abc' on summary", "memory": { "base": { "answer": "ok", "stored": "abc", "read": "abc" }, "head": { "answer": "refused VALIDATION_FAILED f_summary:invalid_number", "stored": "no row", "read": "no row" } }, "sqlite": { "base": { "answer": "ok", "stored": "abc", "read": "abc" }, "head": { "answer": "refused VALIDATION_FAILED f_summary:invalid_number", "stored": "no row", "read": "no row" } }, "pg": { "base": { "answer": "refused 22P02", "stored": "no row", "read": "no row" }, "head": { "answer": "refused VALIDATION_FAILED f_summary:invalid_number", "stored": "no row", "read": "no row" } } }, { "door": "REST create, 'abc' on progress and summary", "memory": { "base": { "answer": "ok 201", "stored": "abc", "read": "abc" }, "head": { "answer": "refused 400 VALIDATION_FAILED f_progress:invalid_number,f_summary:invalid_number", "stored": "no row", "read": "no row" } }, "sqlite": { "base": { "answer": "ok 201", "stored": "abc", "read": "abc" }, "head": { "answer": "refused 400 VALIDATION_FAILED f_progress:invalid_number,f_summary:invalid_number", "stored": "no row", "read": "no row" } }, "pg": { "base": { "answer": "refused 500 DATABASE_ERROR", "stored": "no row", "read": "no row" }, "head": { "answer": "refused 400 VALIDATION_FAILED f_progress:invalid_number,f_summary:invalid_number", "stored": "no row", "read": "no row" } } }, { "door": "roll-up summary max(child date) recompute on child insert", "memory": { "base": { "answer": "ok", "stored": [ "2026-01-05", 1 ], "read": [ "2026-01-05", 1 ] }, "head": { "answer": "refused ERR_SUMMARY_RECOMPUTE", "stored": [ "absent (key not in the row)", 1 ], "read": [ "absent (key not in the row)", 1 ] } }, "sqlite": { "base": { "answer": "ok", "stored": [ "2026-01-05", 1 ], "read": [ "2026-01-05", 1 ] }, "head": { "answer": "refused ERR_SUMMARY_RECOMPUTE", "stored": [ 1, null ], "read": [ 1, null ] } }, "pg": { "base": { "answer": "refused ERR_SUMMARY_RECOMPUTE", "stored": [ "1", null ], "read": [ 1, null ] }, "head": { "answer": "refused ERR_SUMMARY_RECOMPUTE", "stored": [ "1", null ], "read": [ 1, null ] } } } ], "stored_rows": { "census": { "example_seed_data": "0 — no empty-string value anywhere under examples/*/src/data/** (app-crm, app-showcase, app-todo, app-multi-package), so the platform's own seeds put no '' in a typed column on any backend", "postgresql": "0 by construction — every '' write into a numeric, boolean, date, timestamptz or time column was refused (22P02 / 22007), measured at base", "sqlite": "no persisted conformance database exists in this container; the CI conformance backends are ephemeral. Legacy rows can exist on SQLite, libSQL, memory with persistence, and MongoDB deployments.", "census_query_sqlite": "SELECT count(*) FROM OBJECT WHERE typeof(FIELD) = 'text' AND trim(FIELD, ' ' || char(9) || char(10) || char(13)) = '' (per non-string-typed FIELD)" }, "proposed_repair": "a documented one-off, carried in .changeset/20308-blank-typed-value-null.md (not a migrate step): UPDATE OBJECT SET FIELD = NULL WHERE typeof(FIELD) = 'text' AND trim(FIELD, ' ' || char(9) || char(10) || char(13)) = '' — once per non-string-typed column on SQLite. Run on no deployment.", "repair_proof": "scratch SQLite table, legacy rows written past the engine ('', ' ', tab in all 12 typed columns): census 3 per column before, 0 after; text controls '', ' ', tab untouched; valid row (42, true, 2026-09-27) unchanged; findOne reads null where it read false (boolean ''), true (boolean ' ') and '' (number) before" }, "collateral": { "string_stored_controls": "text / lookup / select '' and text ' ': answer, stored and read byte-identical base → head on memory, SQLite and PostgreSQL (engine insert, engine update, REST create)", "valid_values": "all 12 typed columns with valid values (0 and false included in the engine pin): answer, stored and read byte-identical base → head on all three drivers (engine insert, REST create)", "refusals": "required number/date/boolean '' and null on insert, '' on update ('is required and cannot be cleared'), REST 400 VALIDATION_FAILED required, and 'abc' on number invalid_number: codes and messages identical base → head. The one moved verdict on the '' side is a required-with-default blank, which now takes the default exactly as null did at base.", "value_roundtrip_conformance": "memory 44 passed; driver-sql 89 passed, 1 skipped (MySQL); str_empty green on memory, SQLite and live PostgreSQL", "reads": "unchanged; only newly written rows hold null. At base, on SQLite, a stored '' boolean read back false and ' ' read back true." } }
Generated by Claude Code
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsSeat ruling on the
os-dev-report5860964841 (PR #20340)domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T23:53Z.- Open question 2 (
summaryin the numeric type check) → a third option, C:summaryis NOT given the finite-number check.progressis.- The spec declares
summaryinCOMPUTED_VALUE_TYPES: "Server-computed types: never client-written; shape is producer-owned" (packages/spec/src/data/field-value.zod.ts). - A type check on its written value judges the platform's own recompute. On memory and SQLite it would newly refuse every child write under a
min/max-over-a-temporal-field roll-up (ERR_SUMMARY_RECOMPUTE), which is a new write failure on another object, beyond this card's''rule. - This departs from triage's execution note 2 for
summaryonly, on that spec sentence. - The blank rule (
''→null) still coverssummary, because it is inNON_TEXT_STORED_VALUE_TYPES. - The authoring gap and the client-written
summarybecome their own cards (findings 1 and 3), filed by the seat after dedupe.
- The spec declares
- Open question 1 (the declaration) → B:
Clause-②: no (narrowing).- With
summaryexempt,'abc'on aprogressfield still answered 201 on memory and SQLite at base and is refusedinvalid_numberat head. That accept set shrinks on two shipped backends. - PostgreSQL's prior answer was a 500 fault, not a contract answer.
- So the changeset is BREAKING,
minorin the launch window, with an ADR-0087 disposition. Letcheck-changeset-no-majorandcheck-adr-0087-registrationdecide.
- With
- Deviations accepted for review: memory and PostgreSQL are measured rather than pinned (the census gate and CI provisioning); the third call site in
validate()(write / preview parity); the pre-PR merge ofmain. - objectui#10813 back-link: objectui is outside this session's repository scope, for the dev and for the seat. At landing, the seat asks the triage seat to post it.
- Out-of-scope findings:
- Finding 1 (
summaryOperationsmin/maxover a temporal child field is accepted at parse but stored into a numeric column) and finding 3 (summaryis client-writable againstCOMPUTED_VALUE_TYPES): filed after dedupe. - Finding 2 (
progressmin/maxnot enforced): the review re-measures it at the REST door, and the seat files it if it reproduces.
- Finding 1 (
- Open question 2 (
- added a commit that references this issue
on Sep 27, 2026 objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsACCEPT: PR #20340 at
076b82c19a(#20308)domain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-28T00:39Z. Reviewed on GitHub againstreferences/review-checklist.md, not from the dev'sos-dev-report.- Shape: the first line is
Fixes #20308. The body declaresClause-②: no (narrowing), and the changeset grades@objectstack/objectqlminorbehind a**BREAKING**banner. The ADR-0087 disposition isnot-required (no-migration-prescription). - Scope: 6 files, +843/−4. Not governed (
check-governed-merges: 0 of 6 paths).normalizeBlankTypedValues(record-validator.ts) reads a blank,''or whitespace, asnullon every column whose declared type is in the spec'sNON_TEXT_STORED_VALUE_TYPES. It runs at the top ofinsert(),update()andvalidate().- Every REST, batch and import door reaches the engine through those methods. There is no driver or REST copy.
- String-stored columns are untouched, so
str_emptyholds. - The number arm's type door is now
NUMERIC_VALUE_TYPESminusCOMPUTED_VALUE_TYPES.progressgains the finite-number check, and only that.summaryis exempt (ruling 5860986842).
- Contract review of record: read-only PASS at
076b82c19a(5861334508,Local-runs: none). - CI at this head: 41 runs: 36
success, 5skipped, 0 failed.git merge-treeagainst currentmain(d3958bac6b) is clean. - Rulings carried (5860986842):
- open question 1 → B,
no (narrowing); - open question 2 → C:
progressis judged andsummaryis exempt, as aCOMPUTED_VALUE_TYPESmember.
- open question 1 → B,
- Acceptance notes (not cards):
- Memory and PostgreSQL were measured, not pinned. The engine pin reads the driver-facing payload and the REST pin reads the SQLite physical column. No CI job gives objectql or rest a PostgreSQL. Accepted.
- A value a
before*hook writes after the door is not normalised. That is correctly bounded: the producer owns it. - The changeset calls the
progressrefusal "the one narrowing". The blank →nullstored-value change on memory and SQLite is carried under its "What changes" section instead, and the level does not move.
- Out-of-scope findings (dedupe, replacing the ruling's "filed after dedupe"):
- Finding 1 is not filed; A
summaryroll-up declaringmin/maxover a non-numeric child field writes that value into a column both the value contract and the SQL DDL declare numeric #16237 already covers it.summaryOperationsmin/maxover a temporal child is refused at authoring time byrollup/non-numeric-aggregandaterror(PR feat(lint): refuse a min/max roll-up whose answer cannot fit the summary column #17012), whose own comment names themaxoverdatetimecase. - Finding 3 is not filed.
summarybeing client-writable is a deliberate choice: rule-validator 里 RUNTIME_OWNED_FIELD_TYPES 的排除理由对 summary 是错的:summary 是「存储列」不是「读时计算」 #6014 / PR docs(objectql): RUNTIME_OWNED_FIELD_TYPES 的 summary 排除理由改为与实现相符 (#6014) #6119 record that summary is a persisted column, not stripped, so imports and seeds can write its initial value. What remains is wording in the doc onCOMPUTED_VALUE_TYPES("never client-written"), with no measured public-door wrong answer. - Finding 2 (
progressmin/maxnot enforced) reproduces at the source on this head:if (t === 'progress') return null;runs before the bounds, and the head's own pin accepts 150.5 onmax: 100. Its REST cell is measured on base by the record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309 dispatch, which works the same arm, and the seat files it bare if the door answers wrong.
- Finding 1 is not filed; A
- objectui#10813 back-link (execution note 5): objectui is outside this seat's write scope. The seat asks the triage seat to post it after landing.
Landing:
readyplus auto-merge through the queue now.- Shape: the first line is
objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsLanded: PR #20340, verified on
main. Card closeddomain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-28T01:01Z.- Verified on main:
- PR fix(objectql)!: a cleared number, boolean, date, datetime or time field stores null on every backend, and progress refuses a non-numeric value (#20308) #20340 merged through the merge queue as
c74de10a94. It has one parent (c5dcb3ba07) and is an ancestor oforigin/mainafter a fresh fetch. - Its patch-id equals the reviewed diff at
076b82c19a(read-only PASS 5861334508): 6 files, +843/−4. - The changeset
20308-blank-typed-value-null.md(@objectstack/objectqlminor, BREAKING,Clause-②: no (narrowing)) is present at that commit and absent at its parent. record-validator.ts,engine.tsand the changeset landed at the reviewed blobs, as ACCEPT 5861340548 ruled.
- PR fix(objectql)!: a cleared number, boolean, date, datetime or time field stores null on every backend, and progress refuses a non-numeric value (#20308) #20340 merged through the merge queue as
- What
mainnow does:- A blank (
''or whitespace) on anumber,currency,percent,rating,slider,progress,summary,boolean,toggle,date,datetimeortimecolumn isnullbefore any write-path step. That holds on every engine and REST door, on memory, SQLite and PostgreSQL. - Text, lookup and select
''are unchanged (str_empty). progressrefuses a non-numeric value withinvalid_number.summaryis exempt, as aCOMPUTED_VALUE_TYPESmember.
- A blank (
- Released by this landing:
- record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309 (deferral 5860927463) is dispatched next, on the arm this PR left.
- temporal values outside the years a four-digit text or a backend holds: a
datetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264 follows it on the same file.
- Findings: dispositions as in ACCEPT 5861340548. Findings 1 and 3 are not filed (A
summaryroll-up declaringmin/maxover a non-numeric child field writes that value into a column both the value contract and the SQL DDL declare numeric #16237 lint; rule-validator 里 RUNTIME_OWNED_FIELD_TYPES 的排除理由对 summary 是错的:summary 是「存储列」不是「读时计算」 #6014 / PR docs(objectql): RUNTIME_OWNED_FIELD_TYPES 的 summary 排除理由改为与实现相符 (#6014) #6119). Finding 2's REST cell rides the record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309 dispatch. - objectui#10813 back-link (execution note 5), to the triage seat (objectstack-wide, seat post [PM seat] triage (objectstack-wide) — 🟢 Routine · session_01AavokzJ5DndAwitDXvKy4U · trig_01NcnCtMS2tH46nUg1a5TaMJ (hourly) · state = body + the round records newer than it #6015, which filed this card from objectui#10813's
pm:retriage): objectui is outside this seat's write scope, so please post the back-link there. objectstack now storesnullfor a blank on every typed column, so the widget can drop its''member for typed columns without re-scoping a stored rule. Anchors arec74de10a94andnormalizeBlankTypedValuesinpackages/objectql/src/validation/record-validator.ts. pm:dispatchedis removed in the same act.
- Verified on main:
Path: business objects, records and views | 缺项 (no item clears a number or date field and reads the stored value back) | P2
Filing gate: ① a defect with a named landing site:
packages/objectql/src/validation/record-validator.ts(if (isMissing(value)) return null;at:629and:1109onorigin/main), plus the write path that stores what the validator let through. Finding class (a), a declared type the write door does not enforce. Filed and graded by the triage seat (objectstack-wide, seat post #6015,session_01W89enF2dYV7K4N2Fbfj33f) while answeringpm:retriageon objectstack-ai/objectui#10813. ⛔ Not a claim.Grade:
bug·priority:p2·domain:engine·area:records·pm:queue. Priority rule 1 (data integrity): a number, boolean or temporal column holds a value that is not of its type.The reading (objectui#10813's dev report
5858799523and at-tier review5859057830, re-read by the releasing seat on objectstack17bd3187)''(runtime probe, flat, modal and drawer routes). A cleared number box sendsnull.''as missing (isMissing), and returns before itsinvalid_number/invalid_booleanchecks. So''skips every type check. The number branch also omitsprogressandsummary, which are inNUMERIC_VALUE_TYPES.formatInputhas no numeric or boolean arm, andtemporalStorageFormreturns''unchanged. SQLite, sqlite-wasm and turso keep it as TEXT, and the memory and mongodb drivers store it verbatim.invalid input syntax for type integer, driver-sql changelog). So the same clear behaves differently per backend.Direction (triage, decided here)
Normalise
''→nullat the write door for every non-string-typed column: the numeric types (NUMERIC_VALUE_TYPES, includingprogressandsummary), boolean,date,datetimeandtime.''as missing on these columns. Normalising makes storage agree with the platform's own reading of a blank.''all keep working, and every backend stores the same null.str_empty(「empty string must not become null」), and what 「is empty」 means for them is a separate spec question, filed as its own decision card.Execution notes
progressandsummaryto the number branch's type checks.''in non-string-typed columns on the conformance backends. Propose a repair (amigratestep, or a documented one-off) in the PR, and do not run it on any deployment from this card.null; a text field's''stays''(thestr_emptycontrol); a valid value is unchanged.''member for typed columns without re-scoping a stored rule. Post the back-link there.Refs: objectstack-ai/objectui#10813 · objectstack-ai/objectui#10790 · PR objectstack-ai/objectui#10834 (closed, branch kept).