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
Conversation
…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>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…pty-string-typed-null
…stub param 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>
📓 Docs Drift Check3 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
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
…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>
Contract reviewServed-tier: ① Derived judgmentsInputs 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
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…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>
Fixes #20308
Clause-②: no (narrowing)
A cleared number, boolean, date, datetime or time field now stores
nullon memory, SQLite and PostgreSQL, through every engine and REST write door. A text, lookup or select''is unchanged.progressnow has a numeric type check: that is the narrowing, and the changeset is BREAKING (minor).summaryis 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: newnormalizeBlankTypedValues(objectSchema, data). For every declared field whose type is in the spec'sNON_TEXT_STORED_VALUE_TYPES(the numeric types includingprogressandsummary,boolean,toggle,date,datetime,time), a blank string (''or whitespace only, which is exactly what the validator'sisMissingreads as missing) becomesnull. 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_TYPESminusCOMPUTED_VALUE_TYPES, both read from the spec, instead of a hand-list of five types. The type check gains one type:progressgets the finite-number check only.min,maxandscalekeep the five types they always read, so nothing is newly bounded.summaryis subtracted. It is inCOMPUTED_VALUE_TYPES(「Server-computed types: never client-written; shape is producer-owned」), so the roll-up producer decides its shape. A blank on asummaryis stillnullat the door.packages/objectql/src/engine.ts: three call sites, each one line.insert(): just beforeopCtxis built.update(): just before the dispatch is resolved andopCtxis built.validate()(the dry run): onrawRows, before the defaults.Every REST, batch and import door reaches the engine through
insert()/update(), so there is no REST or driver copy. Theaggregate/havingregion is untouched..changeset/20308-blank-typed-value-null.md:@objectstack/objectqlminor, withClause-②: no (narrowing), a**BREAKING**banner and the ADR-0087 dispositionnot-required (no-migration-prescription).Measured, base → head
Instrument: a scratch vitest file (not committed) booting the real
ObjectQLandRestServer, one run per driver:InMemoryDriver;SqlDriveron better-sqlite3 in memory;SqlDriveron 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.findOnereturns. Base isorigin/mainde091b50e6. Head is076b82c19a, and every cell was re-measured there. Compared with the previous heade0c193f4bd, exactly four rows moved, all onsummary/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.insert''→ ok,null''(boolean/toggle readfalse) → ok,null22P02, no row → ok,nullinsert([...])22P02→ ok,nullinsertMany22P02→ ok,nullupdateby id''→ ok,null''(readfalse) → ok,null22P02, row unchanged → ok,nullupdateby predicate (multi)''→ ok,null22P02→ ok,nullPOST /data/:object''→ 201,null''→ 201,null500 DATABASE_ERROR→ 201,nullPATCH /data/:object/:id''→ 200,null500 DATABASE_ERROR→ 200,nullPOST /batchcreate / update''→ 200,nullfailed: INTERNAL_ERROR→ 200, row succeeded,nullcreateMany''→ 201,null500 DATABASE_ERROR→ 201,nullupdateMany''→ 200,nullINTERNAL_ERROR→ 200,nullinsert, whitespace' '' '→null' '(boolean/toggle readtrue) →null22P02→nullControls. Each is byte-identical base → head on all three drivers: the door's answer, the physical value and the read value.
''stays'', through engine insert, engine update and REST create.' 'stays' '.0andfalseare in the engine pin).nullstaysnull.VALIDATION_FAILED/required; update: "is required and cannot be cleared").'abc'on anumberfield is unchanged.'abc'onsummaryis unchanged: memory and SQLite store it, and PostgreSQL refuses it with22P02.summarydoingmaxover a childdatefield, recomputed on a child insert, is unchanged. Memory and SQLite accept the child insert, and the recompute stores the date string. PostgreSQL refuses withERR_SUMMARY_RECOMPUTE, as at base.The verdicts that moved, all measured:
'abc'onprogress(the narrowing)'abc'→VALIDATION_FAILED/invalid_number(REST 400)22P02(REST 500) →VALIDATION_FAILED/invalid_number(REST 400)''on a required number that has adefaultValuerequired→ accepted, stores the default''on an optional number / date that has adefaultValue''→ stores the default22007→ stores the defaultWhere the rule runs, and why there (H2)
The validator returns on
isMissingat two sites:validateOneandvalueShapeViolation. Neither is the right place for the rewrite, and neither is the engine'snormalizeMultiValueFieldsstep. Onupdate()all of those run BEFORE thereadonlyWhenstrip, and that strip decides "is this the caller's value" withObject.is(payload[k], suppliedValues[k]). If the rewrite ran there, a caller's''on a locked number would becomenullin 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 thereadonlyWhenpin red.requiredstill refuses. A cleared required number, date or boolean is refused the same way as base, with the same codes and the same words.defaultValueon insert, exactly asnulldoes (applyFieldDefaultsfillsnull/undefined, [objectql] 字段 defaultValue 语义:显式 null 不回填、解析晚于 hook、表单不预填 current_user #2706). At base the same blank was refused asrequired(required field) or stored as''(optional field). A cleared number box already sendsnulland gets the default; a cleared date box sends'', and now gets the same answer.update()never defaults, so there a blank storesnull.validate()normalises at the same point, or a required-with-default blank would previewrequiredwhile the write takes the default.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)'abc'was stored verbatim on memory and SQLite, and failed at the driver on PostgreSQL (22P02, REST 500).progress:invalid_numberon every driver and every door, as onnumber.summary: exempt, so every cell is at its base answer, per seat ruling 5860986842.summaryis writable. Callers can write it:7and'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 throughupdate()under a system context. The disagreement withCOMPUTED_VALUE_TYPES("never client-written") is reported as a finding, not fixed here.No collateral (H4)
str_emptyincluded. It is driver-level, and no driver file is in this diff.summaryand roll-up controls are byte-identical, as listed above.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:
Proved on a scratch SQLite table holding legacy rows written past the engine (
'',' 'and a tab in all twelve typed columns):'',' 'and a tab untouched;findOnereadsnullwhere it readfalse(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
migratestep 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.'abc'on aprogressfield was stored on memory and SQLite, and is now refused with400 VALIDATION_FAILED/invalid_number. PostgreSQL already refused it, as a 500.''that PostgreSQL refused is now accepted asnull, and a required-with-default blank now takes its default.not-required (no-migration-prescription).packages/specis untouched and no metadata key moves, soobjectstack migrate metahas 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 overNUMERIC_VALUE_TYPESminusCOMPUTED_VALUE_TYPES. That includes a control that the two populations are exactly the six judged types andsummary, and a pin thatsummaryis 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; therequired, defaults, dry-run andreadonlyWheninteractions.packages/rest/src/rest-data-blank-typed-value.test.ts, on SQLite: the physical column through POST, PATCH, batch,createManyandupdateMany; the required andprogressinvalid_numberrefusals; and a roll-upmaxover a childdatefield whose child writes succeed and whose recompute lands, as at base.Suites run:
076b82c19a:@objectstack/objectqlvitest run, both projects: 323 files, 5862 tests passed.pnpm --filter @objectstack/objectql --filter @objectstack/rest run typecheck: exit 0.4525303324(after mergingorigin/maina78f731add),@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.e0c193f4bd(neither package is in the diff):@objectstack/driver-memory: 57 files, 1374 passed.@objectstack/driver-sqlwith live PostgreSQL (TZ=America/New_York, serverAsia/Shanghai): 199 files passed and 3 skipped; 3888 tests passed and 88 skipped (the MySQL cells).str_emptylisted: 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 itsdist/), passednode scripts/ablation-dist-preflight.mjs @objectstack/objectql MARKER, and ran the three files. It then restored withgit checkout HEAD --, provedgit hash-objectequal to the HEAD blob andgit status --porcelainempty, rebuilt, and passed the--absentpreflight.B. The numeric door, redone at
076b82c19aagainst the new set, with the direction of each leg predicted before it ran.progress refuses a non-numeric string with invalid_number), REST 1 of 7 red (progress refuses … invalid_number).COMPUTED_VALUE_TYPESsubtraction dropped (plainNUMERIC_VALUE_TYPES). Predicted: 2 objectql reds and 1 REST red. Measured: objectql 2 of 34 red (summary is exempt …, andprogress takes the type check only, and summary none …, becausemaxthen bites), REST 1 of 7 red (the roll-up recompute).git status --porcelainwas empty, and the--absentpreflight 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. Therequired, valid-value andinvalid_numbercases stayed green. Measured at7bae61b0d2; 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 wherenormalizeMultiValueFieldsruns). Exactly 1 red: thereadonlyWhenpin. The lock was bypassed, which is the argument for the door placement. Measured at7bae61b0d2, same reason.Gates
Head
076b82c19a, merge basea78f731add.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackover 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.--ranreconciliation:64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED.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-aliasandcheck:type-check-coverage.check:dual-build-cjs-loadsandcheck: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 thepull_requestpayload): exit 0. The gate printedLEVEL 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 printed1 declared-breaking changeset(s), with signalsBREAKING+bang+clause-②-narrowingand dispositionnot-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
@objectstack/driver-memorytest consumer is refused bypnpm check:driver-memory-censuswithout a ruling (scripts/driver-memory-census.ledger.json).packages/objectqlorpackages/rest, so a live leg there would be a permanent named skip.summarywithmin/maxover a temporal field. The spec acceptssummaryOperationswithfunction: '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 (everysummaryOperationsinexamples/issumorcount). Reported to the seat as a finding.summaryaccepts a caller's value, althoughCOMPUTED_VALUE_TYPESsays "never client-written". This is unchanged from base, and reported as a finding.min/maxdeclared on aprogressfield are not enforced at the write door, before or after.FieldSchema.minsays "Checked on the WRITTEN value only" without a type limit. This is not changed here, and it is reported.''member for typed columns once this lands. The back-link belongs to the seat. objectui#10813 is not addressed here.Generated by Claude Code