feat(objectql,spec)!: enforce a field's declared precision (total digits) at the write seam — max_precision (#19992) - #20423
Conversation
…ator write seam Adds the `max_precision` FieldErrorCode member, its four-locale message templates, the precision describe, and the digit-count refusal arm beside `max_scale`. Tests follow. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
Validator, engine-door and REST-envelope pins for max_precision; the liveness row re-evidenced at the write seam; the message placeholder pin; the error-catalog row and the stale currency precision line in types.mdx. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…refusal Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…ddress the pins check:dispatcher-error-vocabulary needs a foreign-vocabulary row for the ADR-0114 field code, as max_scale has; check:error-code-casing reads the field-level pins as field-addressed once they name their field. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…_precision gen:docs output only (check:generated named check:docs as the one stale artifact). Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…eld-precision-write-seam
The os-regen driver kept one side of this generated file at the merge; gen:schema + gen:docs on the committed merge carry both main's action.aria tombstone row and this branch's precision describe. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 141 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 4e4e5563eff92aa0109b129ec6b537d79ea73353 && git checkout 4e4e5563eff92aa0109b129ec6b537d79ea73353
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 50e273fd7e933433182d6d89e3968d1af4be7b94 bb33240df524220882ef7064e9a9702461ebb3ac && git checkout -B drift-repro 50e273fd7e933433182d6d89e3968d1af4be7b94 && git merge --no-ff bb33240df524220882ef7064e9a9702461ebb3ac
node scripts/docs-audit/affected-docs.mjs --json 50e273fd7e933433182d6d89e3968d1af4be7b94
|
Contract reviewServed-tier: ① Derived judgmentsInputs: card #19992 (body and all 13 comments, the triage answer
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36432953259 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36434109438 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…write seam (objectstack-ai#20386) (objectstack-ai#20482) Fixes objectstack-ai#20386 Clause-②: no (narrowing) A `progress` field's declared `min` / `max` are now **enforced at the objectql write seam**, per triage `5865053231` (ENFORCE, no decision card). A `progress` write outside a declared bound is refused with `400 VALIDATION_FAILED` and the `number` field's own field codes, `max_value` / `min_value`. `scale` and `precision` stay unread on `progress`. Measured head: **`af9e5101a`**. ## What changes - **`packages/objectql/src/validation/record-validator.ts`, the number arm.** The `if (t === 'progress') return null;` early return moves from above the `min` / `max` checks to directly below them, and above `scale` / `precision`. The objectstack-ai#20308 docblock that deferred this now says why the bounds bind and why the return stays above `scale` / `precision`: each of those keys' own `.describe()` names a type set `progress` is not in. - **The file header's `min` / `max` line** now lists `progress`. It named the five types that were enforced, so leaving it would have made it false. This is one line outside "the number arm and the objectstack-ai#20308 docblock" (declared below as a deviation). It is line 31, far from the date line PR objectstack-ai#20469 edits (line 55 on `main`). - **Tests.** `record-validator.blank-typed-value.test.ts` pinned the old boundary (`progress` `max: 100` accepting `150.5`). It now pins what stays true: `summary` reads no bound or `scale`, and `progress` reads no `scale`. One test name in `record-validator.precision.test.ts` said `progress`'s "bounds the numeric branch never reads", and it is reworded. Its assertion is unchanged. - **`.changeset/20386-progress-min-max-enforced.md`**: `@objectstack/objectql` `minor`, **BREAKING** banner, the `Clause-②` line, a before → after line and the ADR-0087 disposition (below). ## Measured premises (dispatch zone 2) - **H1: `progress` bounds are skipped on `origin/main`. Held.** Reproduced on `dc0ab6a2e` through the real `RestServer` `POST /api/v1/data/:object` handler, over a real `ObjectQL` engine on the SQLite `SqlDriver` and on the memory driver. This was a scratch harness, not committed, because `check:driver-memory-census` refuses a new driver-memory consumer. | field | value | SQLite before | memory before | both after (`5f5bc7580`) | |:--|:--|:--|:--|:--| | `progress`, `max: 100` | `150` | 201, stored `150` (real) | 201, stored `150` | 400 `VALIDATION_FAILED` / `max_value` `{ max: 100 }`, no row | | `progress`, `min: 0` | `-5` | 201, stored `-5` (real) | 201, stored `-5` | 400 `VALIDATION_FAILED` / `min_value` `{ min: 0 }`, no row | | `number`, `max: 100` (control) | `150` | 400 / `max_value`, no row | the same | unchanged | | `number`, `min: 0` (control) | `-5` | 400 / `min_value`, no row | the same | unchanged | | `progress` in bounds / on each bound | `50`, `0`, `100` | 201 | 201 | 201, stored unchanged | - **H2: `scale` / `precision` after the move. Held, with a measured boundary.** With the return placed below the bounds, `scale` and `precision` are still not enforced on `progress`: `33.5` under `scale: 0` gets 201, and `99.5` under `precision: 2` gets 201, on SQLite and memory. Deleting the return outright would start both. Measured by ablation (below): four pins turn red with `max_scale` / `max_precision`. So the placement is load-bearing, and it is pinned from both sides. - **H3: producers. Zero writes outside a declared bound.** - objectui `SliderField`, the `progress` editor (`FieldEditWidget.tsx:87` `progress: SliderField`), is byte-identical at the pinned `.objectui-sha` `dd3f7e1be3` and at objectui HEAD `b8e0941`. It passes `min = field.min ?? 0` and `max = field.max ?? 100` to `@radix-ui/react-slider` (`^1.4.7`). That component's `updateValues` does `clamp(snapToStep, [min, max])` before every `onValueChange` (read in the 1.4.7 tarball). So it cannot emit a value outside a declared bound. - Example apps, on `dc0ab6a2e`: 2 `progress` fields declare bounds, `showcase_task.progress` and the field zoo's `f_progress`, both `min: 0, max: 100`. Their writers are 12 seed rows, the `showcase_mark_done` action (`progress: 100`) and the dogfood field-zoo matrix (`60`). All of them are in bounds. ## Pins - `packages/objectql/src/validation/record-validator.progress-bounds.test.ts` (new, 14 tests): - the triage pins (`150` gets `max_value` `{ max: 100 }`, `-5` gets `min_value` `{ min: 0 }`, and in-bounds values plus both inclusive bounds are accepted); - envelope equality with the `number` refusal; - one bound declared alone, and no invented 0..100 bound when none is declared; - update mode, string-carried values, and an omitted field that is never re-read; - ⛔ `scale` / `precision` unread on `progress`, while the same declaration on `slider` refuses; - every engine write door through a stub driver: insert of one row and of an array, `insertMany` partial success, update by id and by predicate, the `validate` dry run, and a control showing that in-bounds values arrive as the same number. - `packages/rest/src/rest-data-progress-bounds.test.ts` (new, 6 tests), on the real `RestServer` routes over SQLite, reading the physical column past every read coercion: - POST, batch create, PATCH, batch update and updateMany refuse `150` / `-5` with the `number` field's envelope and write or change nothing; - controls: in-bounds values and both bounds are stored unchanged, and `33.5` writes under `scale: 0, precision: 2`. ## Verification Heavy runs went through `scripts/pm/os-verify-lock.sh`. All readings are at `af9e5101a` unless stated otherwise. - **Build.** `pnpm turbo run build --filter='@objectstack/rest...' --concurrency=2`: 25/25, VERDICT command-exit 0. It was re-run after each merge of `main` (last at `5c4148234`). The objectql source has not changed since. - **objectql.** - Validator and door suites (`progress-bounds`, `blank-typed-value`, `precision`, `number-value`, `record-validator`, `engine-number-value-door`, `engine-blank-typed-value-door`): 7 files, 333/333. - Full `--project local`: 328 files, 6081/6081 (at `6a029a923`, before the second merge, which brought only spec and driver-sql commits). - `typecheck` (tsc, scripts, and `check:test-typecheck`, whose `tsconfig.test.json` includes `src/**/*`): exit 0. - **rest.** `rest-data-progress-bounds`, `rest-data-number-value`, `rest-data-blank-typed-value` and `import-integration`: 4 files, 100/100. `typecheck` including `check:test-typecheck`: exit 0. - **Reverse verification.** - **REST pin, with a build.** Against the `dist/` built from base `dc0ab6a2e`, the REST pin read **5 failed / 1 passed**. Each failure was `expected 201 to be 400` or a batch row reporting success. The one green is the in-bounds control. The `dist/index.js` number arm was read directly before and after: the return sat above the bounds, then below `max`. After `pnpm --filter @objectstack/objectql build` at `5f5bc7580` the pin reads 6/6. - **Ablation 1, fix committed first (`3b7b55406`).** `scripts/ablation-replace.mjs` re-planted `if (t === 'progress') return null;` above the bounds: anchor 1 → 0, blob `9ede5b5b` → `d396c53a`. Result: the new objectql file reads **10 failed / 4 passed**. The 4 greens are the controls: in-bounds values, `scale` unread, `precision` unread, and the engine in-bounds control. Restored: blob `9ede5b5b` equals HEAD, and `git diff HEAD` is empty. The subject resolves through a relative import to `src/`, so no rebuild was involved. Direction: red. - **Ablation 2, the H2 reading.** The same tool deleted the return: blob `9ede5b5b` → `d683b83e`. **4 failed**: the two `progress-bounds` pins for `scale` / `precision`, the `blank-typed-value` `scale` pin, and the `precision` test that excludes `progress`. Restored the same way. Direction: red. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `af9e5101a` derived 64 commands. **62 exit 0**. 2 are **NOT MEASURED** (exit 3, PREREQUISITE NOT MET): `check:dual-build-cjs-loads` and `check:type-check-debt` both need a whole-repo build. `--ran` reconciliation: 64 derived, 62 run, 2 NOT-MEASURED (derived from exit 3), 0 UNRUN, exit 0. - First pass at `5c4148234`: `check:error-code-casing` exit 1 on four bare `{ code: 'max_value' }` style assertions. They are now field-addressed (`af9e5101a`), and the gate reads 0. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` on the 5 changed `.ts` files: 5 files, 0 errors, 0 warnings. - Population: `--print-config` applies the config's rules to each file (6 rules on the validator, 5 on the REST test). - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. - The repo-wide `pnpm lint` is declared to CI. - **Not run locally, declared to CI:** the rest of the rest suite, runtime and dogfood, and the 6 path-matched families that take a value from the workflow. ## ADR-0087 disposition: which precedent, and why `not-required (no-migration-prescription)`, following **PR objectstack-ai#20423**, not objectstack-ai#7501: - objectstack-ai#20423 is the closer precedent: the same arm, the same kind of change (a declared numeric bound starting to bind at the write seam, a narrowing of the write accept set with no authored key moving), and a gate-era marker. - objectstack-ai#7501's changeset (`number-scale-enforced-by-rejection.md`, `951476719`) declared no BREAKING banner, so `check:adr-0087-registration` never asked it for a marker. It carries none, and there is nothing to copy. One conflict with the dispatch order's wording is worth stating. The seat's dispatch order asks for "a FROM → TO line" (claim 5873443045 itself names only `.changeset/20386-*.md`; seat edit after review 5875022310). Measured with the gate's own exported `findMigrationPrescription`: a line opening with the literal `FROM → TO` label is read as a **migration prescription** (branch `from-to-label`), and that refuses `no-migration-prescription`. The only category left would then be `registered`, which would need a new ledger entry in `packages/spec`. That is out of scope for this card and wrong on the facts, since nothing authorable moves. So the changeset carries the mapping as **"What a caller sees, before → after"** (`201`, stored as sent → `400 VALIDATION_FAILED` + `max_value` / `min_value`, nothing stored), plus the one-line fix. The detector reads that as no prescription, and `check:adr-0087-registration` passes. ## Acceptance notes - **`scale` / `precision` declared on a `progress` field parse, and nothing reads them at the write seam.** - Their `.describe()` texts name the types they bind on, and `precision`'s says "Not read on any other field type". - The metadata designer offers neither on `progress`: `ObjectFieldInspector` `isNumeric` covers only `number`, `currency` and `percent`. - No example declares them. Noted, not filed. - **objectui `SliderField`'s undeclared-bound defaults** (`min ?? 0`, `max ?? 100`) are narrower than the server, which enforces nothing undeclared. - There is one degenerate shape, not measured in a browser: a `progress` field declaring `min` above 100 and no `max`. The slider then clamps into `[min, 100]`, so it would emit `100`, which is now refused. - No field declares that shape. Noted, not filed. Holder: none. - **`docs/qa/platform-checklist/areas/records-forms.json`**, item `records-forms.field-type-constraints`: its steps do not reach `progress` bounds (triage said so). This belongs to the next checklist-author sweep. Holder: none. - **PR objectstack-ai#20469** (objectstack-ai#20264) edits the header's date line and the date / datetime arm of the same file. The hunks are far apart and there is no textual overlap. The later lander merges `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…9, refused at the comparand door and the write door (objectstack-ai#20264) (objectstack-ai#20469) Fixes objectstack-ai#20264 Clause-②: yes (narrowing) A `date` or `datetime` value now names a year from 0001 to 9999, or it is refused: `INVALID_FILTER` / 400 as a comparand on `where`, a per-aggregation `filter` and `having`, and `VALIDATION_FAILED` / 400 (`invalid_date`) as a written value. This is triage's ruling on the card (5858474998): "The supported year range is **0001..9999** for both `date` and `datetime`." Year 0000 joins the refused range, and the `date` arm's padding covers 0001..0999. One range function in `@objectstack/core` answers both doors. No driver source is edited. **Stop valve (claim 5872518067): it fired.** On a local MySQL 8.0.46, a `datetime` in years 0001..0099 is still stored right and read back a century late through mysql2's instant parser. That cell is returned as `needs_decision` (see the section below). Everything else lands here. **Patch round 1** (at-tier review 5874841530, amended claim 5874849531) touches `.changeset/20264-temporal-year-range.md` only; no code or test changed. - `Clause-②` is now `yes (narrowing)`, because `@objectstack/core` gains the root export `isOutsideTemporalYearRange`. The levels, the BREAKING banner, the ADR-0087 marker and the FROM → TO line are unchanged. - The note now says a `datetime` `where` bound in year 10000 answered 7/0/0 for `$gt` / `$lt` / `$eq`. The right answer is 0/7/0. - The note's Unchanged clause now makes one exception to "refused in its existing words". A `date`-column string whose instant names a year outside 0001..9999 (`+010000-01-01T00:00:00.000Z`, `-000001-…`, an out-of-range epoch-millisecond string) is refused with the same code and status on `where`, the per-aggregation `filter` and `having`, but now in the year-class words. - The new head `b559a5d0e` is that commit (`f16ff84ac`) plus a merge of `origin/main` `b810ddb6f`. Every other file of this PR is blob-identical to `311ce0640`. ## What changed - `packages/core/src/utils/temporal-storage-form.ts` - New export `isOutsideTemporalYearRange(value, kind)`, the one range. The year is the one the kind's rule reads: - `datetime`: the UTC year of the instant `canonicalUtcDatetime` reads. That function now shares one private `instantMs` reader with the range, so the two cannot drift. - `date`: a string's leading `YYYY-MM-DD` year. Otherwise, the UTC year of the instant the value names. - `time`: never judged. - The `date` arm pads years 0001..0999 only. Year 0 keeps its unpadded spelling (`0-06-15`), like every other year outside the range. The rule stays total, and the `datetime` spelling of any instant is unchanged. - `packages/core/src/utils/temporal-comparand.ts`: `isUninterpretableTemporalComparand` asks the range for a `date` or `datetime` number, `Date` or readable string. `objectstack-ai#20240`'s private `isOutsideCalendarDayYears` (0..9999, `date` only) is removed. `time` is untouched. - `packages/objectql/src/temporal-comparand-door.ts`: the year-class refusal now covers both kinds, in words that name 0001 to 9999. `where`, the per-aggregation `filter` and `having` inherit the range through the one predicate. The door asks core's `isOutsideTemporalYearRange` which class a hit is, and never re-derives the range. - `packages/objectql/src/validation/record-validator.ts`, the `date` / `datetime` arm only: a readable value outside the range fails `invalid_date`, with the same code, constraint and message key as any other invalid date. This covers insert, update, a multi-row update and `engine.validate`. The number arm (PR objectstack-ai#20423) is not touched. ## Measured: base `b28550818` vs head `f2d96c96c` Scratch harness, not committed. Drivers: InMemoryDriver, and SqlDriver on SQLite, on a local PostgreSQL 16.13 (server `Asia/Shanghai`) and on a local MySQL 8.0.46 (`+08:00`), with `TZ=America/New_York`. Doors: the engine and REST (`POST /data/:object/query`, `POST /data/:object`). Data: seven 2026 rows. The harness compares **236 cells: 160 identical, 76 moved**. Every moved cell went from a misorder, a 500 or a stored non-day to a 400. No in-range cell and no control moved. | position | value | base: memory · SQLite · PG · MySQL | head, all four | |:--|:--|:--|:--| | `where` `datetime`, `$gt`/`$lt`/`$eq` | year 10000 or −1: number, `Date`, ISO | 7/0/0 · 7/0/0 · 500 · 500 | 400 `INVALID_FILTER` | | per-agg `filter` `$gt` / `having` `$gt` on `min(datetime)` | the same | 7 / 4 groups on all four | 400 `INVALID_FILTER` | | `where` `datetime` | year 0 | 7/0/0 · 7/0/0 · 500 · 7/0/0 | 400 `INVALID_FILTER` | | `where` `date` | year 0: number, `Date`, ISO, bare `0000-06-15` | 7/0/0 · 7/0/0 · 500 · 7/0/0 | 400 `INVALID_FILTER` | | REST create `date` | `+010000-01-01T00:00:00.000Z` / `-000001-…` | 201 verbatim · 201 verbatim · 500 · 500 | 400 `VALIDATION_FAILED` | | REST create `date` / `datetime` | year 0 | 201 · 201 · 500 · 201 | 400 `VALIDATION_FAILED` | | edges `0001-01-01`, `9999-12-31T23:59:59.999Z`, 2026 control | every spelling, every position | read | identical | H1 held: the card's table reproduces on `origin/main` in every cell. PR objectstack-ai#20261 (objectstack-ai#20240) and objectstack-ai#20263 had already moved only the `date` 10000 / −1 cells, and those are unchanged. ## The PM's hypotheses - **H2.** The one place is core's `isOutsideTemporalYearRange`. It is called by the predicate (and through it by the three comparand positions, `judgeFilter` and service-analytics' decline) and by the record validator. Each caller of the storage rule: - The engine's write coercion (`resolveNowDefault` / `normalizeExpressionDefault`) runs before `validateRecord` on insert, so a defaulted year outside the range is refused. - `SqlDriver.formatInput` and `memory-temporal.ts` read `temporalStorageForm` and still see an out-of-range year, but only on a direct driver call that bypasses the engine. Both doors sit in front of them. Their source is not edited. - `mongodb-temporal.ts` **keeps its own copy** (`storageDatetimeValue` / `storageDateValue`). This card needs no edit there, because both doors are engine-level. The copy's drift is pre-existing (no four-digit padding for a `Date` year 1..999, no number arm on `date`), and is noted below, not changed. - **H3.** The write door is `validateRecord`'s `date` / `datetime` arm, reached from the engine and REST create, PATCH and the multi-row update. It is refused there through the same range. - **H4.** These pins asserted year 0000 as accepted. Each is flipped as the ruling says: - core `temporal-comparand.test.ts` IN_RANGE `the first millisecond of year 0`; - core `temporal-storage-form.test.ts` padding cases `0000-06-15` and `0000-01-01`, now `0-06-15` and `0-01-01`; - objectql `engine-date-year-range-door.test.ts` IN_RANGE `0000-01-01`. These pins asserted a `datetime` number, `Date` or extended-year string as read, and are flipped too: - core `leaves the datetime and time rules alone`; - objectql `leaves the datetime and time fields alone`; - objectql `having` UNCHANGED `an extended-year ISO on min(datetime)`; - REST `data-query-date-year-range.test.ts`'s datetime control. It now reads a 2026 instant. ## Stop valve: MySQL `datetime` in 0001..0099 (`needs_decision`) The cell was measured live on MySQL 8.0.46, through REST create then query, at base and at head (identical): - `0001-01-01T00:00Z` reads back as `2001-01-01T00:00Z`, `0001-03-04T10:00Z` as `2004-01-03`, `0050-…` as `1950-…`, `0069-…` as `1969-…`, `0070-…` as `1970-…`, and `0099-…` as `1999-…`. - `0100`, `0101`, `0500`, `0999` and `1000` read back as written. - The stored text (`CAST(… AS CHAR)`) is right in every case. The mysql2 read parser is not touched here (ADR-0053 D-F2). The two options are in the `os-dev-report` on objectstack-ai#20264. objectstack-ai#20280 remains open for its `datetime` half, per ruling 5859414357. ## DELIBERATE CORRECTION: three pending release notes `Check Changeset` will be red on these three names by design. Each file gets one clause, correcting a sentence this change makes false in the same release. Do NOT restore them from base. - `.changeset/20240-date-year-four-digits.md`: the `Unchanged` clause "every `datetime` and `time` cell, the same numbers included" gains the 0001..9999 narrowing. - `.changeset/20203-epoch-ms-date-comparand.md`: the parenthetical "refuses one whose year falls outside 0..9999" gains "objectstack-ai#20264 … narrows that to 0001..9999". - `.changeset/20263-having-temporal-comparand-door.md`: the `Unchanged` clause "an extended-year instant on a `datetime` column, which that rule reads" gains "until objectstack-ai#20264 … refuses a `datetime` year outside 0001..9999". The claim's file surface names `.changeset/20264-*.md` only. These three are an in-place addition, declared here and in the report. ## Tests and gates, measured at `311ce0640` `311ce0640` is the merge of `origin/main` `dc0ab6a2e` into this branch, and it carries PR objectstack-ai#20423's record-validator number arm. These readings are its own. - **Full suites:** - core: 56 files / 1490 passed, plus `test:repo` 3 / 48. - objectql: 328 files / 6077 passed, plus `test:repo` 1 / 5. - rest: 217 files / 3916 passed / 34 skipped, plus `test:repo` 1 / 8. - driver-memory: 58 files / 1378 passed. - service-analytics: 132 files / 3093 passed. - driver-sql, the whole suite: 207 files / 4714 passed / 1 skipped, with "all 3 dialects were exercised". It ran with `TZ=America/New_York`, `OS_EXPECT_LIVE_DIALECT_MATRIX=1`, live PostgreSQL 16.13 (`Asia/Shanghai`) and live MySQL 8.0.46 (`+08:00`). - The new REST file with its live cells: 12 passed on SQLite, PG and MySQL. - **Typecheck:** core, objectql, rest, driver-memory and driver-sql exit 0. Test-layer debt is unchanged (core 4 / 4, objectql 40 files / 234 errors, rest 0). Every new or edited test file is in its package's tsc program, counted with `--listFiles`. - **Ablation A: the range reverted to the base semantics** (`date` only, 0..9999). The mutation went in through `scripts/ablation-replace.mjs`: anchor 1 to 0, blob `7801894e` to `8a7dc4b4`. Core was rebuilt, and the dist preflight found the marker present in 2 built files. - Mutated: core 7 failed / 90 passed; objectql 11 failed / 56 passed; rest 4 failed / 19 passed (8 live cells skipped). Every red is a objectstack-ai#20264 cell: the `datetime` year class, year 0, or the write door. Every `date` 10000 / −1 cell and every control stayed green. - Restored: blob equals `HEAD`, `git status --porcelain` is empty, and after a rebuild the preflight finds the marker absent from 14 files. core 97, objectql 67 and rest 23 passed. - **Ablation B: the write door's range call removed** from `record-validator.ts`. Anchor 1 to 0, blob `a7fd6b04` to `cfbeeebc`. objectql was rebuilt, and the preflight found the marker present in 4 files. - Mutated: objectql 2 failed / 118 passed (exactly the write-door cells); rest 1 failed / 3 passed (the write cell). - Restored: the tree is clean, objectql got a full rebuild with DTS, the marker is absent from 14 files, and 120 and 4 passed. - **`dispatch-gates --commands`** at `311ce0640` derived 67 commands. All 67 ran, each exit code captured before any pipe. `--ran` reconciles them: 67 derived, 67 run, 0 NOT-MEASURED, with a derived zero. - 66 exit 0. - `check-empty-changeset.mjs --base origin/main` exits 1, on exactly the three DELIBERATE CORRECTION names above. - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3). After a full `turbo run build`, it exits 0. - **ESLint, narrowed:** 13 changed `.ts` files, 0 errors and 0 warnings, counted from `--format json`. The population is `eslint.config.mjs`'s `**/*.{ts,…}` block (line 971). The config enables no type-aware linting (its own note, lines 327-328), so no untouched file's verdict can move. - **`check:driver-conformance`:** base `b28550818` reads 50 covered / 0 DEBT / 0 exempt, and `311ce0640` reads 50 / 0 / 0. ## Acceptance notes - `driver-mongodb` keeps its own copy of the storage rule (`mongodb-temporal.ts`). Its `date` arm pads no year and has no number arm, which objectstack-ai#20203 and objectstack-ai#20240 name as known. Both doors of this card sit in the engine in front of it. It was not measured here (no MongoDB in this container). Carrier: none. - `time` columns judge no year. This is measured at REST on memory and SQLite, at head. `where t $gt "+010000-01-01T10:00:00Z"` answers 200 with 3 of 3 rows, and `$lt` answers 0, so the string is compared verbatim as text. The same instant in 2026 (`10:00:00`) answers 2 / 1. The predicate reads the string as an instant, while the `time` rule hands it back unchanged. This is outside the ruling's `date` / `datetime` scope, so it is reported for the seat to file. - The write door's `date` arm admits a `Date.parse`-readable string with no leading `YYYY-MM-DD` inside the range. This is measured at REST on memory and SQLite, at head. `POST /data/:object` with `d: "2026/07/15"` answers 201, and the row reads back `"2026/07/15"`, a stored non-day. The class differs from the year range, and the ruling scoped this card's write-door refusal to the range, so it is reported for the seat to file. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19992
Clause-②: yes
The remainder of #19992 (the family site folded in at
5854612946). The field-levelFieldSchema.precision("Total digits") was declared and read by nothing. It is now enforced at the objectql write seam, per triage5864304093: ENFORCE, by the maintainer's #18900 ④ criterion 「主流平台有没有这个能力 —— 有 ⇒ 补消费端(一次做对)」. A numeric write whose digit count exceeds the declaredprecisionis refused with400 VALIDATION_FAILEDand the field codemax_precision. It is never rounded. ⛔ There is no storage or DDL change: every numeric column stays the fixedNUMERIC_COLUMN_REPRESENTATIONexact decimal. #20379 (the major-18 D3 entry text) is its own card and is not addressed here.What changes
packages/objectql/src/validation/record-validator.ts. Aprecisionarm runs in the numeric branch, aftermin/maxandmax_scale, onnumber,currency,percent,ratingandslider.progressreturns before every bound, as it does today. The count isdigitCountAt, which sits besidedecimalPlacesOfand reads the same canonical-string normalisation.scalebranch now keeps the allowance it applied (scaleAllowance), so the digit count is taken at exactly those decimal places. This is one reading of the field's scale, not a second derivation.scaleuses.FieldSchemaalready refuses a malformed one at parse (spec:Field.scaleaccepts meaningless declarations (2.5,-1) — now that scale is enforced, malformed declarations should be refused at authoring time #8321).packages/spec/src/api/errors.zod.ts.FieldErrorCode, the ADR-0114 D2 closed field-level catalog, gainsmax_precisionbesidemax_scale.packages/spec/src/system/validation-message.tsadds its two sentences,max_precisionandmax_precision_scaled, inen/zh-CN/ja-JP/es-ES.packages/runtime/src/dispatcher-error-vocabulary.tsgains aforeign-vocabularyrow. It is a clone of themax_scalerow's shape. The table is imported only by a runtime test, so it ships in no package.content/docs/api/error-catalog.mdxlists the new code in its bounded-ranges row.FieldSchema.precision.describe()now states the counting rule and the types it binds on. The field reference pages are regenerated from it.props.precisionrow ofpackages/spec/liveness/field.jsonis re-evidenced at the write seam. It had cited objectui reads at@11c1e71e, all of them retired at the pinf8a9d0fb.content/docs/protocol/objectql/types.mdxlisted, undercurrency, "precision(0–10, default 2) for decimal places". With enforcement, that advice turns into refused writes for every amount of 100 or more. It now states the total-digit reading.Measured premises (dispatch zone 2)
df3ba164,git grep -n precisionoverrecord-validator.tsgives 0 hits, againstscalewith 29. Overpackages/*/srcoutside spec (non-test), the onlyprecisionreaders are the fixednumeric.precisionof the column representation (cligenerate,driver-sqlDDL) anddatetimeprecision.builtin-column-collision.ts:77says 「this driver does not read it yet」.DECIMAL(p, s). Digits are counted from the value's first non-zero digit down to the decimal places thescalerule applies. The integer part may therefore carryprecision − scaledigits, andprecision: 5, scale: 2holds up to999.99but refuses1234.5(1234.50, 6 digits). This matches the spec's own prose (field.zod.ts: 「precision: 18on a fixed-USD field is DECIMAL(18,2)」). Each case below has a pin:scale. The value's own decimal places count. Leading zeros never count, and trailing zeros of the integer part always do. Underprecision: 4,0.001and12.34fit, while12345,1.2345and10000do not.currency. Itsscaleis refused, so an amount counts at its own decimals. The decimals themselves stay unconstrained (ruling 乙), and only the total is bounded.percent(thescale + 2rule). The count is taken at the stored allowancescale + 2. With noscaledeclared it is taken at 2: the same two-place shift, which makes the count that of the percentage-point value as displayed. Soprecision: 4, scale: 2holds 99.99% (stored0.9999) and refuses 100% (stored1, counted1.0000). Underprecision: 3with noscale, 1000% (stored10) is 4 digits and is refused. A whole-percent field (maxabove 1) counts like anumber.precisionbelowscalekeeps the DECIMAL range meaning (the magnitude stays below 10 to the power p − s):precision: 1, scale: 2holds0.05and refuses0.1. Zero fits every declaration.ERROR_CODE_LEDGER/StandardErrorCode: the top-level code is the already-registeredVALIDATION_FAILED. The field code joinsFieldErrorCode, the closed ADR-0114 D2 catalog, which that ADR says a new constraint kind is added to. It is a new member of a published enum, which is whyClause-②: yesholds.df3ba164, with\bprecision\s*:\s*[0-9]+excludingcurrencyConfig,datetimeand generated references, found no example app, template, platform object, seed or JSON fixture that declares a field-levelprecision. The positive control, the same form forscale, hitsexamples/app-todo. Two test fixtures declareprecision: 5, scale: 0on a 1–12 hours field (record-validator.test.ts,rest/import-integration.test.ts), and every value they write fits. Spec tests only parse.f8a9d0fb,ObjectFieldInspector.tsx:914-921writes a top-levelprecisionlabelleddesigner.field.precision("Precision" / 「精度」) beside "Scale" / 「小数位」. Its own docblock (offersScale) says 「it is the field-level TOTAL digit count of the stored decimal, not a decimal-places knob」.validateRecordhas four call sites inengine.ts:validate()(the dry run),insert()for one row and for an array (where one bad row refuses the whole batch), andupdate()by id and by predicate (multi: true).insertManyroutes throughinsertwith per-row outcomes. REST create, createMany, batch and the import route reach these. The import route's create leg iscreateManyData, which callsengine.insert(rows[]).update.options.upsertis a retired tombstone, so there is no separate upsert door. Pinned: insert of one row, insert of an array,insertMany, update by id, update by predicate,validate(), the REST create route, and the REST import route (dry run and commit).Pins
packages/objectql/src/validation/record-validator.precision.test.ts(new):scale, exponent forms,precisionbelowscale, zero);scale, whole);progressexcluded), ordering aftermin/max/max_scale, update mode, string-carried values, the malformed declaration and the zh-CN message;packages/rest/src/import-integration.test.ts: one newMEMBERfield,hourly_rate(precision: 5, scale: 2), and two tests.400+VALIDATION_FAILED+fields[0].code: 'max_precision'+constraint: { precision: 5, scale: 2, actual: 6 }and writes nothing, while123.45writes.packages/spec/src/system/validation-message.test.ts: both new templates interpolate their bound and count in every locale.Verification
Head
bb33240dunless stated otherwise. Heavy runs went throughscripts/pm/os-verify-lock.sh.pnpm turbo run build --filter='@objectstack/rest^...'(the closure of objectql and rest): 24/24, VERDICT command-exit 0 (at897593af). After the merge ofmain, spec was rebuilt: exit 0.precision,record-validator,number-value): 3 files, 247/247, re-run atbb33240d(first at563e11d8).--project local: 326 files, 6058/6058 (atd6056d91).typecheck(tsc + scripts +check:test-typecheck): exit 0.import-integration.test.ts44/44 (42 onmain+ 2 new), re-run atbb33240din the same VERDICT command-exit 0 (first at563e11d8).zod-union-fields.test.ts(readsFieldErrorCode.options): 18/18.typecheck: exit 0.check:generated: all 15 artifacts up to date.check:liveness: exit 0.typecheck: exit 0.--project localoversrc/api,src/system,src/dataandscripts/liveness: 202 files, 6840 passed, 1 todo. This is a declared narrowing: the rest of the spec suite is declared to CI.bb33240d, with the fix committed first.ifthat comparesactualwithdef.precision) was replaced throughscripts/ablation-replace.mjs: anchor 1 → 0, blob84ef8a2e→c928a426. Result: 18 failed / 4 passed in the pin file. The 4 that stay green are the controls (undeclaredprecision, the ordering pin, the malformed declaration, the fitting-value doors).84ef8a2eequals HEAD,git diff HEADis empty, and the marker count is 0. Direction observed: red. The subject resolves through a relative import tosrc/, not through a packageexports, so no rebuild was involved.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatbb33240dderived 114 commands, the dispatch list plus 37 more. 111 exit 0 and 3 are NOT MEASURED (exit 3, an unmet prerequisite):check:dual-build-cjs-loadsandcheck:type-check-debtneed a whole-repo build;spec check:skill-examplesneeds theclient-reactclosure built. This diff touches no skill and no client.--ranreconciliation: 114 accounted, 111 run, 3 NOT-MEASURED (derived from exit 3), 0 UNRUN, exit 0.d6056d91:check:error-code-casingexit 1 on four unaddressedcode:assertions (now field-addressed);check:dispatcher-error-vocabularyexit 1 on the unclassified stamp site (now classified);check-engine-split-ratioexit 2 on the shallow clone (deepened with--shallow-since, as it prescribes).pnpm lint; runtimetypecheckand tests (its closure is unbuilt here; the one edit is a table row the green vocabulary gate reads); the rest of the spec, rest and objectql suites beyond the files above.Merge
origin/mainwas merged withscripts/pm/os-regen-merge.sh(merge1c829350). The os-regen driver kept one side of the generatedcontent/docs/references/data/object.mdx, so it was regenerated on the committed merge (bb33240d) and now carries main'saction.ariatombstone row and this branch's describe. Sibling entries are present at main's counts:action-aria-removed20/20,filter-cross-field-comparison-class2/2.Changeset
@objectstack/objectqlminor,@objectstack/specminor, BREAKING (a write accept-set narrowing;check-changeset-no-majorrefusesmajor). The ADR-0087 disposition isnot-required (no-migration-prescription): nothing authorable moves, since the key keeps its spelling, type and legality. The changeset states what an author with an oversize value sees and the three fixes.Acceptance notes
currencythe seam counts an amount at its own decimals, because it resolves no currency. On a fixed-USD field the spec's DECIMAL(18,2) reading would bound the integer part at 16 digits, where this seam allows 18 for an integer amount. Deriving the minor unit would need the fixed currency or the tenant's currency at the validator, which is a decision this card was not given. The practical gap sits above 2^53 anyway (about 16 digits), where a double holds no exact amount.Clause-②arm. The line is copied from the claim verbatim. The fuller spelling for this diff would beyes (narrowing): one surface widens (a catalog member) and another narrows (the write accept set). The changeset's BREAKING banner and its ADR-0087 marker carry the narrowing.precisionon non-numeric types parses (FieldSchemahas no applicability refinement for it) and is read nowhere. The describe now says so. No producer writes it there: both metadata forms and the designer offer it only on numeric types. Noted, not filed.precision: 0parses and refuses every non-zero write. That is loud rather than silent, and a producer-sidemin(1)would be a separate narrowing. Noted.content/blog/*.mdx(protocol-first-development.mdx:652,680-681,metadata-driven-architecture.mdx:263) teachprecision: 1/precision: 2as decimal places. Enforcement makes following them refuse ordinary values. The posts use shapes that are already refused at parse (ObjectProtocol.define,default:,enable), so no live producer copies them. Noted as a boundary; no carrier.docs/qa/platform-checklist/areas/records-forms.json(itemrecords-forms.field-type-constraints) still says precision/scale are 「DECLARED but NOT enforced on the write path」. That is internal QA prose, and it was already stale forscale. It belongs to the next checklist-author sweep.Generated by Claude Code