Skip to content

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
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20308-empty-string-typed-null
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20308-empty-string-typed-null

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20308

Clause-②: no (narrowing)

A cleared number, boolean, date, datetime or time field now stores null on memory, SQLite and PostgreSQL, through every engine and REST write door. A text, lookup or select '' is unchanged. progress now has a numeric type check: that is the narrowing, and the changeset is BREAKING (minor). summary is exempt from that check, per seat ruling 5860986842.

What changes (read from the code at the head below)

  • packages/objectql/src/validation/record-validator.ts: new normalizeBlankTypedValues(objectSchema, data). For every declared field whose type is in the spec's NON_TEXT_STORED_VALUE_TYPES (the numeric types including progress and summary, boolean, toggle, date, datetime, time), a blank string ('' or whitespace only, which is exactly what the validator's isMissing reads as missing) becomes null. It takes one record or an array, never mutates the caller's objects, and returns the same reference when nothing changed.

  • The number branch's door is now NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES, both read from the spec, instead of a hand-list of five types. The type check gains one type:

    • progress gets the finite-number check only. min, max and scale keep the five types they always read, so nothing is newly bounded.
    • summary is subtracted. It is in COMPUTED_VALUE_TYPES (「Server-computed types: never client-written; shape is producer-owned」), so the roll-up producer decides its shape. A blank on a summary is still null at the door.
  • packages/objectql/src/engine.ts: three call sites, each one line.

    • insert(): just before opCtx is built.
    • update(): just before the dispatch is resolved and opCtx is built.
    • validate() (the dry run): on rawRows, before the defaults.

    Every REST, batch and import door reaches the engine through insert() / update(), so there is no REST or driver copy. The aggregate / having region is untouched.

  • .changeset/20308-blank-typed-value-null.md: @objectstack/objectql minor, with Clause-②: no (narrowing), a **BREAKING** banner and the ADR-0087 disposition not-required (no-migration-prescription).

Measured, base → head

Instrument: a scratch vitest file (not committed) booting the real ObjectQL and RestServer, one run per driver:

  • InMemoryDriver;
  • SqlDriver on better-sqlite3 in memory;
  • SqlDriver on PostgreSQL 16 (the system cluster 16/main), using a private role and database that were dropped afterwards.

Each cell records three things: what the door answered, the physical value (the memory driver's own store, or a knex select), and what engine.findOne returns. Base is origin/main de091b50e6. Head is 076b82c19a, and every cell was re-measured there. Compared with the previous head e0c193f4bd, exactly four rows moved, all on summary / progress; they are listed below.

Typed columns: number, currency, percent, rating, slider, progress, summary, boolean, toggle, date, datetime, time. Each row below is all twelve typed columns written '' at once.

door memory base → head SQLite base → head PostgreSQL base → head
engine insert ok, stored '' → ok, null ok, stored '' (boolean/toggle read false) → ok, null 22P02, no row → ok, null
engine insert([...]) same as above same as above 22P02 → ok, null
engine insertMany same same 22P02 → ok, null
engine update by id ok, '' → ok, null ok, '' (read false) → ok, null 22P02, row unchanged → ok, null
engine update by predicate (multi) ok, '' → ok, null same 22P02 → ok, null
REST POST /data/:object 201, '' → 201, null 201, '' → 201, null 500 DATABASE_ERROR → 201, null
REST PATCH /data/:object/:id 200, '' → 200, null same 500 DATABASE_ERROR → 200, null
REST POST /batch create / update 200, '' → 200, null same 200 with the row failed: INTERNAL_ERROR → 200, row succeeded, null
REST createMany 201, '' → 201, null same 500 DATABASE_ERROR → 201, null
REST updateMany 200, '' → 200, null same 200 with the row failed INTERNAL_ERROR → 200, null
engine insert, whitespace ' ' ' ' → null ' ' (boolean/toggle read true) → null 22P02 → null

Controls. Each is byte-identical base → head on all three drivers: the door's answer, the physical value and the read value.

  • A text, lookup and select '' stays '', through engine insert, engine update and REST create.
  • A text ' ' stays ' '.
  • A valid value on every typed column is unchanged, including the falsy ones (0 and false are in the engine pin).
  • null stays null.
  • The refusals of a required number, date and boolean are unchanged on insert, update and REST create, in codes and in words (VALIDATION_FAILED / required; update: "is required and cannot be cleared").
  • The refusal of 'abc' on a number field is unchanged.
  • 'abc' on summary is unchanged: memory and SQLite store it, and PostgreSQL refuses it with 22P02.
  • A roll-up summary doing max over a child date field, recomputed on a child insert, is unchanged. Memory and SQLite accept the child insert, and the recompute stores the date string. PostgreSQL refuses with ERR_SUMMARY_RECOMPUTE, as at base.

The verdicts that moved, all measured:

cell memory / SQLite base → head PostgreSQL base → head
'abc' on progress (the narrowing) stored 'abc' → VALIDATION_FAILED / invalid_number (REST 400) 22P02 (REST 500) → VALIDATION_FAILED / invalid_number (REST 400)
'' on a required number that has a defaultValue required → accepted, stores the default same
'' on an optional number / date that has a defaultValue stored '' → stores the default 22007 → stores the default

Where the rule runs, and why there (H2)

The validator returns on isMissing at two sites: validateOne and valueShapeViolation. Neither is the right place for the rewrite, and neither is the engine's normalizeMultiValueFields step. On update() all of those run BEFORE the readonlyWhen strip, and that strip decides "is this the caller's value" with Object.is(payload[k], suppliedValues[k]). If the rewrite ran there, a caller's '' on a locked number would become null in the payload while the snapshot still held ''. The strip would then read it as a hook's write and let it through the lock.

So the rule runs at the door, before anything reads the payload: the middleware, the suppliedValues / per-row caller snapshots, applyFieldDefaults, the hooks, the strips and validation. Every stage then sees one image. This is measured below: moving the update call to the validator site turns exactly the readonlyWhen pin red.

  • required still refuses. A cleared required number, date or boolean is refused the same way as base, with the same codes and the same words.
  • Defaults: a blank takes the defaultValue on insert, exactly as null does (applyFieldDefaults fills null/undefined, [objectql] 字段 defaultValue 语义:显式 null 不回填、解析晚于 hook、表单不预填 current_user #2706). At base the same blank was refused as required (required field) or stored as '' (optional field). A cleared number box already sends null and gets the default; a cleared date box sends '', and now gets the same answer. update() never defaults, so there a blank stores null.
  • The dry run agrees. validate() normalises at the same point, or a required-with-default blank would preview required while the write takes the default.
  • Boundary. A value that a before* hook writes after the door is the hook's own and is not normalised. A server-side producer that writes '' into a typed column is fixed at that producer.

progress / summary (H3)

  • Base omits both from the number branch. Measured: 'abc' was stored verbatim on memory and SQLite, and failed at the driver on PostgreSQL (22P02, REST 500).
  • Head, progress: invalid_number on every driver and every door, as on number.
  • Head, summary: exempt, so every cell is at its base answer, per seat ruling 5860986842.
  • summary is writable. Callers can write it: 7 and 'abc' are both accepted through engine and REST on memory and SQLite, at base and at head. The platform also writes it: the roll-up recompute stores its aggregate through update() under a system context. The disagreement with COMPUTED_VALUE_TYPES ("never client-written") is reported as a finding, not fixed here.

No collateral (H4)

  • The spec's value round-trip conformance passes on memory, SQLite and live PostgreSQL, str_empty included. It is driver-level, and no driver file is in this diff.
  • The string-stored, valid-value, refusal, summary and roll-up controls are byte-identical, as listed above.
  • Reads are unchanged. The only difference is that newly written rows hold null.

Stored rows (H5): census and proposed repair, run nowhere

  • Census, example apps' seed data: examples/*/src/data/** holds 0 blank values (a grep for an empty-string value finds none), so the platform's own seeds leave no '' in a typed column on any backend.

  • Census, PostgreSQL: 0 by construction. A numeric, boolean, date, timestamptz or time column cannot hold '' (measured: every such write was refused, 22P02 / 22007).

  • Census, SQLite: no persisted conformance database exists in this container. The CI backends are ephemeral.

  • Proposed repair: a documented one-off, carried in the changeset. For SQLite, run the statement below once per non-string-typed column. OBJECT is the object name and FIELD is the field name, each double-quoted as SQL identifiers:

    UPDATE OBJECT SET FIELD = NULL
     WHERE typeof(FIELD) = 'text' AND trim(FIELD, ' ' || char(9) || char(10) || char(13)) = '';

    Proved on a scratch SQLite table holding legacy rows written past the engine ('', ' ' and a tab in all twelve typed columns):

    • census 3 per column before, 0 after;
    • text controls '', ' ' and a tab untouched;
    • a valid row unchanged;
    • findOne reads null where it read false (boolean ''), true (boolean ' ') and '' (number) before.

    Memory, MongoDB and libSQL can hold such rows too. The same predicate applies there, and none is proposed as a migrate step here.

Declaration (H6)

Clause-②: no (narrowing), per seat ruling 5860986842. The line at the top of this body and the one in the changeset match.

  • The narrowing: 'abc' on a progress field was stored on memory and SQLite, and is now refused with 400 VALIDATION_FAILED / invalid_number. PostgreSQL already refused it, as a 500.
  • Widenings: a '' that PostgreSQL refused is now accepted as null, and a required-with-default blank now takes its default.
  • ADR-0087: not-required (no-migration-prescription). packages/spec is untouched and no metadata key moves, so objectstack migrate meta has nothing to rewrite. The gate accepts this: see Gates.

Tests

New files:

  • packages/objectql/src/validation/record-validator.blank-typed-value.test.ts: the normaliser over the spec sets, and the numeric door over NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES. That includes a control that the two populations are exactly the six judged types and summary, and a pin that summary is exempt.
  • packages/objectql/src/engine-blank-typed-value-door.test.ts: what the driver receives on insert, insert([...]), insertMany, update by id and by predicate; the required, defaults, dry-run and readonlyWhen interactions.
  • packages/rest/src/rest-data-blank-typed-value.test.ts, on SQLite: the physical column through POST, PATCH, batch, createMany and updateMany; the required and progress invalid_number refusals; and a roll-up max over a child date field whose child writes succeed and whose recompute lands, as at base.

Suites run:

  • At head 076b82c19a:
    • @objectstack/objectql vitest run, both projects: 323 files, 5862 tests passed.
    • The three pin files: 34/34 objectql and 7/7 REST.
    • pnpm --filter @objectstack/objectql --filter @objectstack/rest run typecheck: exit 0.
  • At the previous head 4525303324 (after merging origin/main a78f731add), @objectstack/rest, both projects: 205 files, 3687 passed and 1 skipped. This round changed only the REST pin file, and that file passes at the new head.
  • At e0c193f4bd (neither package is in the diff):
    • @objectstack/driver-memory: 57 files, 1374 passed.
    • @objectstack/driver-sql with live PostgreSQL (TZ=America/New_York, server Asia/Shanghai): 199 files passed and 3 skipped; 3888 tests passed and 88 skipped (the MySQL cells).
    • The value round-trip conformance, str_empty listed: memory 44 passed; driver-sql 89 passed and 1 skipped (MySQL), SQLite and live PostgreSQL both included.

Reverse verification

Every leg changed the source, rebuilt @objectstack/objectql (the REST test resolves its dist/), passed node scripts/ablation-dist-preflight.mjs @objectstack/objectql MARKER, and ran the three files. It then restored with git checkout HEAD --, proved git hash-object equal to the HEAD blob and git status --porcelain empty, rebuilt, and passed the --absent preflight.

  • B. The numeric door, redone at 076b82c19a against the new set, with the direction of each leg predicted before it ran.

    • B1, the door put back to the five-type hand-list. Predicted: 1 objectql red and 1 REST red. Measured: objectql 1 of 34 red (progress refuses a non-numeric string with invalid_number), REST 1 of 7 red (progress refuses … invalid_number).
    • B2, the COMPUTED_VALUE_TYPES subtraction dropped (plain NUMERIC_VALUE_TYPES). Predicted: 2 objectql reds and 1 REST red. Measured: objectql 2 of 34 red (summary is exempt …, and progress takes the type check only, and summary none …, because max then bites), REST 1 of 7 red (the roll-up recompute).
    • Both builds exited 0, and the marker was present in 4 built files. After the restore, the blob matched HEAD, git status --porcelain was empty, and the --absent preflight passed for B1 and B2. The pins were green again: 34/34 and 7/7.
  • A. The normaliser's rewrite removed. 12 of 33 objectql cases and 3 of 6 REST cases red: every normalisation case, every door case, defaults, the dry run, and the unlocked half of readonlyWhen. The required, valid-value and invalid_number cases stayed green. Measured at 7bae61b0d2; this round changed neither the normaliser nor its call sites.

  • C. The update() call moved to the validator site (after the caller snapshot, which is where normalizeMultiValueFields runs). Exactly 1 red: the readonlyWhen pin. The lock was bypassed, which is the argument for the door placement. Measured at 7bae61b0d2, same reason.

Gates

Head 076b82c19a, merge base a78f731add.

  • This round's changed paths, derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over those four paths: 64 commands, all of them already in the previous round's 66. Every one was run at this head, and its exit code was recorded before any pipe.
  • --ran reconciliation: 64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED.
  • 62 exit 0. This includes check-changeset-no-major --base origin/main, check-adr-0087-registration --base origin/main, check-empty-changeset --base origin/main, check:objectql-double-limit, check:driver-memory-census, check:issue-citations, check:nul-bytes, check:test-source-alias and check:type-check-coverage.
  • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt (exit 3, PREREQUISITE NOT MET). Both need every package built, and CI runs both.
  • check-changeset-no-major --base origin/main --event (this body as the pull_request payload): exit 0. The gate printed LEVEL AXIS: this PR declares clause-② no (narrowing) and, for the direction arm, narrowing — a BREAKING change; during the launch window it ships minor.
  • check-adr-0087-registration --base origin/main: exit 0. The gate printed 1 declared-breaking changeset(s), with signals BREAKING+bang+clause-②-narrowing and disposition not-required (no-migration-prescription).
  • check-empty-changeset --base origin/main: exit 0, with no DELIBERATE CORRECTION: No changeset from the merge base modified or deleted by this diff.

Acceptance notes

  • Memory and PostgreSQL are measured, not committed as pins. The committed pins are the engine's driver-facing payload (backend-agnostic: memory and MongoDB store it verbatim) and the SQLite physical column through REST.
    • A new @objectstack/driver-memory test consumer is refused by pnpm check:driver-memory-census without a ruling (scripts/driver-memory-census.ledger.json).
    • No CI job provisions PostgreSQL for packages/objectql or packages/rest, so a live leg there would be a permanent named skip.
    • Both were measured live on this change: see the table above.
  • Roll-up summary with min / max over a temporal field. The spec accepts summaryOperations with function: 'max' over a date field at parse. Its value is a date string written into a numeric summary. Memory and SQLite store it, and PostgreSQL refuses the recompute; both behaviours are unchanged from base. No example app declares one (every summaryOperations in examples/ is sum or count). Reported to the seat as a finding.
  • summary accepts a caller's value, although COMPUTED_VALUE_TYPES says "never client-written". This is unchanged from base, and reported as a finding.
  • min / max declared on a progress field are not enforced at the write door, before or after. FieldSchema.min says "Checked on the WRITTEN value only" without a type limit. This is not changed here, and it is reported.
  • objectui#10813's widget can drop its '' member for typed columns once this lands. The back-link belongs to the seat. objectui#10813 is not addressed here.

Generated by Claude Code

…he write door

WIP: normalizeBlankTypedValues at insert/update/validate entry; progress and
summary join the numeric type check.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…uery

check:objectql-double-limit could not seat the id-only filter as a
query-honouring double; nothing in the file reads rows back through find.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7b28d40b7a2ab1090d5b0f04b8d24dc0ac56bb91 — the merge of head 076b82c19a01ee925c1e59680fa6818dfee443d1 into base d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7b28d40b7a2ab1090d5b0f04b8d24dc0ac56bb91 && git checkout 7b28d40b7a2ab1090d5b0f04b8d24dc0ac56bb91
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f 076b82c19a01ee925c1e59680fa6818dfee443d1 && git checkout -B drift-repro d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f && git merge --no-ff 076b82c19a01ee925c1e59680fa6818dfee443d1

node scripts/docs-audit/affected-docs.mjs --json d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…keeps it

Seat ruling 5860986842 on #20308: the number branch's door is
NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES, so a roll-up max/min over a
temporal child field recomputes as at base. The changeset declares the
progress narrowing (Clause-② no, narrowing; BREAKING, minor).

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title fix(objectql): a cleared number, boolean, date, datetime or time field stores null on every backend (#20308) fix(objectql)!: a cleared number, boolean, date, datetime or time field stores null on every backend, and progress refuses a non-numeric value (#20308) Sep 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 076b82c19a01ee925c1e59680fa6818dfee443d1
Local-runs: none

① Derived judgments

Inputs read, and nothing else: card #20308 body and all 3 comments (claim 5860122781, os-dev-report 5860964841, seat ruling 5860986842); PR #20340 body, its 6-file list and the net diff against origin/main at the head (merge base a78f731); the 41 check-runs on the head. The PR's one comment is the Docs Drift Check bot.

  1. Normalisation population — right. normalizeBlankTypedValues keys on the spec's NON_TEXT_STORED_VALUE_TYPES, which on origin/main is exactly NUMERIC (number, currency, percent, rating, slider, progress, summary) ∪ BOOLEAN (boolean, toggle) ∪ date ∪ datetime ∪ time: the card's direction list, toggle as the boolean class, summary included as the card asks. String-stored types (text, lookup ids, select values) are outside the set; the validator pin asserts that, and str_empty is not moved (no driver file in the diff, packages/spec untouched).
  2. Blank widened from the card's literal '' to ''-or-whitespace — right. It is exactly isMissing's reading (v.trim() === ''), the platform reading the card says storage should agree with; named in the PR body and pinned.
  3. Placement — right. Three call sites: insert() (before opCtx; the only earlier read of data is the isBatch debug log), update() (after withResolvedWhere, before id extraction and the suppliedValues snapshot), validate() on rawRows. insertMany delegates to insert(), and the engine's only driver.update(...) call at the head is inside update(), so no engine write door bypasses the rule; REST reaches the engine through those methods, so no REST or driver copy (card ⛔ honoured). The readonlyWhen argument (snapshot and judged value must be one image) is sound and pinned. The validate() site is within "the write path's call into that one rule" and buys write/preview parity.
  4. Purity — right: same reference when nothing changes, shallow copy per changed row, own-property lookup on fields; pinned.
  5. Numeric type door rewritten from a five-type hand-list to NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES — right. progress gains the finite-number check only; summary is exempt per ruling 5860986842. The new if (t === 'progress') return null; before min/max/scale skips nothing progress previously received: on origin/main the string progress does not occur in record-validator.ts, so it had no check at all. number, currency, percent, rating, slider keep their exact prior path (bounds plus the [finding] FieldSchema.scale on a currency field is offered to authors by the field designer, ignored by every display face, and still enforced on writes — half-live in the direction that surprises #19629 scale rule). The population pin asserts the six judged types and [summary].
  6. Public surface — right, no widening. normalizeBlankTypedValues is exported from validation/record-validator.ts only; src/index.ts and src/core.ts are untouched and re-export only ValidationError, validateRecord, VALIDATION_FAILED_CODE and types from that module; package.json exports maps only . and ./core, so the emitted .d.ts is unaddressable shipped bytes, not a published accept set. No new error code (invalid_number pre-exists).
  7. Accept-set movements of @objectstack/objectql behaviour (and REST through it): (a) narrowing — 'abc' on progress on memory/SQLite: stored → VALIDATION_FAILED / invalid_number; (b) widenings — a blank PostgreSQL refused (22P02 / 22007, REST 500) now stores null; a blank on a required field with a defaultValue now takes the default as null does; (c) stored-value change — a blank on an optional typed column stores null where memory/SQLite stored '' (SQLite read a boolean '' as false). All three are named in the PR body and the changeset; (a) is the BREAKING claim, (c) is under "What changes". Right.
  8. Docs surface — a git grep over origin/main content/docs finds no page stating how a blank on a number, date or boolean column is stored at the write door (hits are filter $between, export nullValues, CEL isBlank, a CLI migrate note), so no hand-written page is falsified; consistent with the Docs Drift Check comment (nothing to list).
  9. Stored rows (card note 3) — census reported (example seeds 0; PostgreSQL 0 by construction; SQLite none persisted in the container), repair carried as a documented one-off SQL in the changeset, run nowhere — right. The statement is per named typed column with a typeof = 'text' and trim guard, so string columns are untouched by construction.
  10. Check-runs on the head: 41 runs, 36 success, 5 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke opt-in, and the re-run Auto Label and Check PR Size), 0 failure. Check Changeset, Lint & Repo Gates, the four Type Check jobs, Test Core 1–6 and rollup, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate 1–3 and rollup, Governed Surface Queue Guard, Flag docs affected by code changes are all green. Those conclusions are the gate verdicts; nothing was re-run here.

② Semver level

  • Changeset .changeset/20308-blank-typed-value-null.md: "@objectstack/objectql": minor, a **BREAKING** banner, ADR-0087 not-required (no-migration-prescription), Clause-②: no (narrowing). PR body first line: Clause-②: no (narrowing). Card claim: Clause-②: no with no arm; the arm was added by ruling 5860986842 (OQ1 → B). All three carriers agree on no; the arm is on the two the gate reads.
  • Level right: a declared narrowing is BREAKING and inside the launch window ships minor (check-changeset-no-major refuses major); minor is the ceiling, and it matches what the diff publishes: the progress narrowing plus the blank-to-null stored-value change on memory/SQLite. @objectstack/rest publishes nothing (a test file only), so no rest changeset is owed. ADR-0087 disposition right: no metadata key moves, packages/spec is untouched, migrate meta has nothing to rewrite. Check Changeset on the head is success.
  • Prose note, not a defect: the changeset calls the progress refusal "the one narrowing"; the blank-to-null rewrite is also an observable stored-value change on memory/SQLite. The banner and the "What changes" section carry it, and the level is unaffected.
  • Clause-②: no (narrowing)

③ Boundary flags

  • OQ1 (Clause-② arm) — answered by ruling 5860986842 → B no (narrowing); PR and changeset carry it; gate green. Concur: 'abc' on progress answered 201 on two shipped backends at base; PostgreSQL's 500 was a fault, not a contract answer.
  • OQ2 (summary in the type check) — answered by ruling → C: progress judged, summary exempt. Concur: summary is in COMPUTED_VALUE_TYPES, and judging it would refuse the platform's own roll-up recompute, a new write failure on another object beyond the card. The departure from card execution note 2 is ruled and recorded in code, changeset and PR body; summary behaviour is unchanged from base (no regression) and the blank rule still covers it. Findings 1 and 3 are the seat's to file after dedupe — escalated to the seat, not this PR's.
  • Dev deviation: memory and PostgreSQL measured, not pinned (card note 4 asks pins on all three) — accepted. The engine pin captures the driver-facing payload through a stub driver (what memory and MongoDB store verbatim), the REST pin reads the SQLite physical column, the driver-memory census gate ledgers every new @objectstack/driver-memory binding and refuses an unruled one, and no CI job provisions PostgreSQL for objectql or rest. The seat accepted it for review; concur. Named so the seat can decide whether a census ruling for a memory pin is wanted later.
  • Dev deviation: third call site in validate() — accepted (①.3).
  • Dev deviations: pre-PR merge of origin/main; objectui threads not readable (the card body's reading used); assignee write after pr_create; the PostgreSQL scratch role — process, no contract bearing.
  • Dev-stated boundary: a value a before* hook writes after the door is not normalised — accepted and correctly bounded (fixed at the producer).
  • Finding 2 (progress min/max not enforced) — the ruling asked the review to re-measure at the REST door. No local run was needed: it reproduces by reading the head, where if (t === 'progress') return null; precedes the bounds, and the head's own pin (150.5 accepted on a progress declaring max: 100) is green in Test Core. Reproduces; the seat files it.
  • objectui#10813 back-link — the seat's at landing, outside this repo; escalated, not a PR defect.
  • The PR is draft: true — not a contract matter; noted for the seat.

Implemented-by: claude/issue-20308-empty-string-typed-null
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 00:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit c74de10 Sep 28, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20308-empty-string-typed-null branch September 28, 2026 01:01
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ct with invalid_number (objectstack-ai#20309) (objectstack-ai#20370)

Part of objectstack-ai#20309

Clause-②: no (narrowing)

A number, currency, percent, rating, slider or progress field now
refuses an array, a boolean or an object with `400 VALIDATION_FAILED` /
`invalid_number`, on every engine and REST write door. At base `[500]`
answered 201 and SQLite stored the TEXT `'[500]'`. A number is judged
and stored exactly as before. **A string is unchanged**: it is still
judged by `Number()` and written as sent. That is the half this PR
leaves open, which is why the first line says `Part of`: see "The string
half" below.

## What changes (read from the code at the head below)

- **`packages/objectql/src/validation/record-validator.ts`, the number
arm** (`NUMERIC_VALUE_TYPES` minus `COMPUTED_VALUE_TYPES`, as objectstack-ai#20308
left it). One guard before the old finite check: a value whose `typeof`
is neither `number` nor `string` is `invalid_number`. The rest of the
arm is byte-identical: the finite check, `progress`'s early return,
`min`, `max`, `scale` and every message.
- No change in `engine.ts`, no driver change, no REST change. Every
REST, batch and import door reaches this validator through `insert()` /
`update()`.
- `summary` is still not judged (ruling 5860986842 on objectstack-ai#20308). A blank
is still `null` before the arm (objectstack-ai#20308's `normalizeBlankTypedValues`).
- `.changeset/20309-number-arm-non-string-refused.md`:
`@objectstack/objectql` `minor`, a BREAKING banner, `Clause-②: no
(narrowing)`, ADR-0087 `not-required (no-migration-prescription)`.

## Measured, base to head (H1, H2)

Instrument: a scratch script, not committed, booting the real
`ObjectQL`, `ObjectStackProtocolImplementation` and `RestServer` from
the built packages, once on `InMemoryDriver` and once on `SqlDriver`
over better-sqlite3 in memory. Types: number, currency, percent, rating,
slider, progress. Doors: engine `insert`, engine `update`, REST `POST
/data/:object`, REST `PATCH /data/:object/:id`, REST batch create, REST
batch update. Each cell records the door's answer, the physical cell
(the memory driver's own store; on SQLite the column and its
`typeof()`), `engine.findOne` and REST `GET`. Base is `c74de10a94`. Head
is this branch at `bf83ded05c` (the code at the head below is the same).
1016 cells: 14 inputs x 6 types x 6 doors x 2 drivers, plus the 4 H6
cells.

| input | base, memory | base, SQLite | head, both drivers |
|---|---|---|---|
| `[500]` | accepted, stores the array, reads `[500]` | 201, stores TEXT
`'[500]'`, reads `"[500]"` | `invalid_number` (REST 400; batch row
`VALIDATION_FAILED`), nothing written |
| `[]` | accepted, stores `[]` | 201, stores TEXT `'[]'` |
`invalid_number`, nothing written |
| `true` / `false` | accepted, stores the boolean | 201, stores `1` /
`0` (`real`; `integer` on rating) | `invalid_number`, nothing written |
| `[5, 7]`, `{}`, `'Infinity'` | `invalid_number` | `invalid_number` |
unchanged |
| `500`, `12.5` | stored as the number | stored `real` (`integer` for
500 on rating) | unchanged |
| `'0x10'` | stored the string | stored TEXT `'0x10'`, read back as `16`
| unchanged (the string half) |
| `' 12 '`, `'12'`, `'1e3'` | stored the string | stored as a number
(column affinity) | unchanged (the string half) |
| `'12.5'` | stored the string | stored `real` 12.5 | unchanged (the
string half) |

Of 1016 cells, exactly 288 moved: `[500]`, `[]`, `true` and `false`, 72
cells each (6 types x 6 doors x 2 drivers). The other 728 are
byte-identical base to head in all five columns.

**H2.** At base the arm judged `Number(value)` and the driver received
`value`: the table's `[500]` row is that gap, read from the raw column.
At head a non-string never reaches the driver. A number arrives as the
same number: `engine-number-value-door.test.ts` asserts `Object.is` on
the driver-facing payload for insert and update. A string still has the
gap, and that is the open half.

## The string half, and why this PR does not close the card

The seat's update after dispatch set two branches. (a) If no shipped
producer sends a numeric string to a number-typed field, refuse every
string. (b) If one does, refuse only the non-strings, leave strings
exactly as base, open the PR as `Part of`, and sequence the string half
after objectstack-ai#20336's grammar.

The in-repo census (below) finds no producer that sends a numeric
string. **objectui's form widgets, the main row, are NOT MEASURED**:
this session has no read access to `objectstack-ai/objectui` (REST `GET`
answered 403, "GitHub access to this repository is not enabled for this
session"), and the request to attach it was refused by the session's
permission classifier. So branch (a) cannot be established. This PR
takes branch (b): it is the one that refuses no form a shipped producer
might send. The string half is an open question in the report, not a
guess here. ⛔ No numeric-string grammar is authored in
`packages/objectql`.

## Producer census (H3)

Every row is a shipped producer of values for a number-typed field, and
what it sends.

| producer | file | what it sends | measured how |
|---|---|---|---|
| Example seed records | `examples/app-crm/src/data/index.ts`,
`examples/app-showcase/src/data/**` | JS numbers only: 35 values (crm)
and 124 (showcase) on numeric fields; app-todo and app-multi-package
seed none | each example's `objectstack.config.ts` imported with tsx and
every seed record walked against its object's field types |
| Numeric `defaultValue`s | the example objects | JS numbers only: 4
(crm), 11 (showcase), 2 (todo) | same walk |
| Example flow `create_record` / `update_record` nodes |
`examples/app-todo/src/flows/task.flow.ts`, `create_next_task` | one
value on a numeric field: `recurrence_interval:
'{completedTask.recurrence_interval}'`, a single-token template.
`interpolateString`
(`packages/services/service-automation/src/builtin/template.ts`) returns
the resolved raw value for a single token, so this sends the stored
number | same walk; the template rule read at source |
| Flow templates in general | `template.ts` | a single token keeps its
type; an EMBEDDED template (text around a token) is stringified. No
shipped flow puts one on a numeric field | read at source |
| CSV / JSON import | `packages/rest/src/import-coerce.ts`
(`parseNumberCell`), called by `import-runner.ts` before the engine | a
JS number, or the row's own `invalid_number` refusal | read at source |
| REST batch, `createMany`, `updateMany`, import doors | `packages/rest`
| pass the caller's JSON through to the engine; a door, not a producer |
measured above |
| `@objectstack/client` | `packages/client/src/index.ts` | serialises
the caller's record as JSON; no value stringification | read at source |
| Read-modify-write through driver-sql |
`packages/drivers/driver-sql/src/sql-driver.ts` (`numericValueFields`) |
numeric columns are presented as JS numbers on every dialect, so a
record read back and written again carries numbers | read at source |
| objectui form widgets | `objectstack-ai/objectui` | **NOT MEASURED**
(no read access in this session). Indirect only: PR objectstack-ai#20340 measured that
a cleared number box sends `null`, not `''` | none |

Runtime sweep at head: `@objectstack/service-automation` (147 files,
1767 tests), `@objectstack/rest` and `@objectstack/objectql` all pass
with the refusal in place.

## No collateral (H4)

- A blank (`''`, `' '`) is still `null` before the arm: objectstack-ai#20308's pins
pass unchanged (`record-validator.blank-typed-value.test.ts`,
`engine-blank-typed-value-door.test.ts`,
`rest-data-blank-typed-value.test.ts`), and the new files re-assert it.
- `summary` is not judged: the new validator pin writes `[500]`, `true`
and a Date to `summary` and all are accepted, as at base.
- Existing refusals keep their words and code: `[5, 7]`, `{}`, `NaN`,
`Infinity`, `'Infinity'`, `'abc'`, `min_value` / `max_value` on all six
types and `max_scale`. That is 192 validator answers (code, message,
fields) compared between the base file and the head file, en and zh-CN,
insert and update: 192 identical.
- A valid JS number is stored byte-identical: the 1016-cell table, and
the `Object.is` pin.

## Declaration (H5)

`Clause-②: no (narrowing)`, the claim's line. `[500]`, `[]`, `true` and
`false` answered 201 at base on memory and SQLite and are refused at
head. The census names no shipped producer that sends an array, a
boolean or an object to a number-typed field, so no producer's form is
refused. The objectui row is NOT MEASURED, as above.
- `check-changeset-no-major --base origin/main --event` (this body as
the `pull_request` payload): exit 0. The gate printed `LEVEL AXIS: this
PR declares clause-② no (narrowing)` and, for the direction arm,
`narrowing — a BREAKING change; during the launch window it ships
minor`.
- `check-adr-0087-registration --base origin/main`: exit 0, "1
declared-breaking changeset(s)", signals
`BREAKING+bang+clause-②-narrowing`, disposition `not-required
(no-migration-prescription)`.
- `check-empty-changeset --base origin/main`: exit 0. objectstack-ai#20308's pending
changeset (`.changeset/20308-blank-typed-value-null.md`) reads true at
this head, so there is no DELIBERATE CORRECTION.

## H6, for the seat (nothing changed for it)

Base `c74de10a94`, REST `POST /data/:object`, one field each, bounds
declared on the field. The four cells are the same on SQLite and memory,
and the same at head.

| field | value | answer | stored |
|---|---|---|---|
| `progress`, `max: 100` | 150 | 201 | 150 (SQLite `real`) |
| `progress`, `min: 0` | -5 | 201 | -5 (SQLite `real`) |
| `number`, `max: 100` (control) | 150 | 400 `VALIDATION_FAILED` /
`max_value` | no row |
| `number`, `min: 0` (control) | -5 | 400 `VALIDATION_FAILED` /
`min_value` | no row |

It reproduces: `progress` stores a value outside the `min` / `max` it
declares.

## Tests

New files:
-
`packages/objectql/src/validation/record-validator.number-value.test.ts`:
the refusal set on the six judged types, insert and update, as the
envelope (`VALIDATION_FAILED` plus `invalid_number`); parity with the
spec's `valueSchemaFor` over every non-string input; a characterization
that the string half is unchanged, which turns red when it lands; and
the blank and `summary` controls.
- `packages/objectql/src/engine-number-value-door.test.ts`: what the
driver receives on insert, `insert([...])`, `insertMany`, update by id
and update by predicate, plus the dry run; a refused value never reaches
the driver.
- `packages/rest/src/rest-data-number-value.test.ts`, on SQLite: `POST`,
batch create, `PATCH`, batch update and `updateMany`, with the physical
cell and its `typeof()`.

Suites run at `c135a38d31` (this branch after merging `origin/main`
`26daf0b036`):
- `@objectstack/objectql`, whole suite: 325 files, 6013 tests passed.
- `@objectstack/rest`, whole suite: 211 files, 3856 passed and 2
skipped.
- `@objectstack/driver-memory`: 57 files, 1374 passed.
- `@objectstack/driver-sql` (no live PostgreSQL or MySQL; those files
skip): 192 files passed and 11 skipped; 3157 tests passed and 176
skipped.
- `@objectstack/service-automation`: 147 files, 1767 passed.
- `pnpm --filter @objectstack/objectql --filter @objectstack/rest run
typecheck`: exit 0, both test layers included.

## Reverse verification

The guard was removed with `scripts/ablation-replace.mjs` (anchor hit
once, blob changed), `@objectstack/objectql` was rebuilt, and
`ablation-dist-preflight` found the marker in 4 built files. Predicted
before the run: 68 objectql reds (42 per-input validator cases, the
spec-parity case, 24 per-input door cases and the dry run) and 24 REST
reds. Measured: objectql 68 failed of 151, REST 24 failed of 43, and
every red was one of the predicted cases. Measured at `bf83ded05c`; the
merge of `origin/main` that followed changed no file in
`packages/objectql` or `packages/drivers`. The restore proved blob
equals HEAD and `git diff HEAD` is empty; after a rebuild the `--absent`
preflight passed with a clean tree, and the pins were green again
(151/151 and 43/43).

## Gates

Derived at `c135a38d31` with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`: 64 commands, the same 64
as the dispatch list. Each was run at that head with its exit code
recorded before any pipe.
- `--ran` reconciliation: `64 derived famil(ies) accounted for — 62 run,
2 NOT-MEASURED`.
- 62 exit 0, including `check:driver-memory-census`,
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:test-source-alias`, `check:cross-package-test-inputs`,
`check:nul-bytes`, `check:issue-citations` and
`check:type-check-coverage`.
- **NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`** (exit 3, `PREREQUISITE NOT MET`). Both need
every package built, and CI runs both.
- `node scripts/check-issue-citations.mjs --base 26daf0b` (the merge
base): exit 0, 4 citations resolve.

## Acceptance notes

- **Memory is measured, not pinned at the REST door.** The triage asked
for REST pins on driver-sql and driver-memory. A new
`@objectstack/driver-memory` test consumer is refused by
`check:driver-memory-census` without a ruling, which is the same
constraint PR objectstack-ai#20340 met. The engine pin reads the driver-facing
payload, which memory stores verbatim, and the memory REST cells are in
the table above.
- **The import route's header** (`packages/rest/src/import-coerce.ts`)
says the validator "coerces only to *check* a value and then discards
the coerced form". That is still true for strings, and for the boolean
and date arms. It is outside this claim's file surface, so it is not
edited.
- **Stored rows.** A value written before this change is never re-read
by the check. The changeset carries a read-only SQLite query that finds
TEXT cells in a numeric column; nothing is rewritten.
- objectstack-ai#20336 and objectstack-ai#20351 are not addressed here. The census found a shipped
producer of numeric strings: objectui's `plugin-grid` CSV import legacy
fallback writes the raw cell (seat answer 5863923799 on objectstack-ai#20309). So the
string half accepts objectstack-ai#20336's grammar and stores the parsed number, and
it waits for that grammar.
- **Not filed:** the boolean arm has the same judged-vs-written shape
(it accepts `0`, `1`, `'0'`, `'1'`, `'true'`, `'false'` and writes the
value as sent). It was read at source only, not measured at a door, so
it is not a card (filing gate ①).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants