Skip to content

feat(objectql,spec)!: enforce a field's declared precision (total digits) at the write seam — max_precision (#19992) - #20423

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19992-field-precision-write-seam
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19992-field-precision-write-seam

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19992

Clause-②: yes

The remainder of #19992 (the family site folded in at 5854612946). The field-level FieldSchema.precision ("Total digits") was declared and read by nothing. It is now enforced at the objectql write seam, per triage 5864304093: ENFORCE, by the maintainer's #18900 ④ criterion 「主流平台有没有这个能力 —— 有 ⇒ 补消费端(一次做对)」. A numeric write whose digit count exceeds the declared precision is refused with 400 VALIDATION_FAILED and the field code max_precision. It is never rounded. ⛔ There is no storage or DDL change: every numeric column stays the fixed NUMERIC_COLUMN_REPRESENTATION exact decimal. #20379 (the major-18 D3 entry text) is its own card and is not addressed here.

What changes

  • Consumer end, packages/objectql/src/validation/record-validator.ts. A precision arm runs in the numeric branch, after min / max and max_scale, on number, currency, percent, rating and slider. progress returns before every bound, as it does today. The count is digitCountAt, which sits beside decimalPlacesOf and reads the same canonical-string normalisation.
  • Code registration, packages/spec/src/api/errors.zod.ts. FieldErrorCode, the ADR-0114 D2 closed field-level catalog, gains max_precision beside max_scale. packages/spec/src/system/validation-message.ts adds its two sentences, max_precision and max_precision_scaled, in en / zh-CN / ja-JP / es-ES.
    • The dispatcher vocabulary gate needs the stamp site classified, so packages/runtime/src/dispatcher-error-vocabulary.ts gains a foreign-vocabulary row. It is a clone of the max_scale row's shape. The table is imported only by a runtime test, so it ships in no package.
    • content/docs/api/error-catalog.mdx lists the new code in its bounded-ranges row.
  • Spec end.
    • The FieldSchema.precision .describe() now states the counting rule and the types it binds on. The field reference pages are regenerated from it.
    • The props.precision row of packages/spec/liveness/field.json is re-evidenced at the write seam. It had cited objectui reads at @11c1e71e, all of them retired at the pin f8a9d0fb.
  • One stale doc line. content/docs/protocol/objectql/types.mdx listed, under currency, "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)

  • A1: nothing enforced it. Held. At base df3ba164, git grep -n precision over record-validator.ts gives 0 hits, against scale with 29. Over packages/*/src outside spec (non-test), the only precision readers are the fixed numeric.precision of the column representation (cli generate, driver-sql DDL) and datetime precision. builtin-column-collision.ts:77 says 「this driver does not read it yet」.
  • A2: the reading is SQL DECIMAL(p, s). Digits are counted from the value's first non-zero digit down to the decimal places the scale rule applies. The integer part may therefore carry precision − scale digits, and precision: 5, scale: 2 holds up to 999.99 but refuses 1234.5 (1234.50, 6 digits). This matches the spec's own prose (field.zod.ts: 「precision: 18 on a fixed-USD field is DECIMAL(18,2)」). Each case below has a pin:
    • Undeclared scale. The value's own decimal places count. Leading zeros never count, and trailing zeros of the integer part always do. Under precision: 4, 0.001 and 12.34 fit, while 12345, 1.2345 and 10000 do not.
    • currency. Its scale is refused, so an amount counts at its own decimals. The decimals themselves stay unconstrained (ruling 乙), and only the total is bounded.
    • Fraction-stored percent (the scale + 2 rule). The count is taken at the stored allowance scale + 2. With no scale declared it is taken at 2: the same two-place shift, which makes the count that of the percentage-point value as displayed. So precision: 4, scale: 2 holds 99.99% (stored 0.9999) and refuses 100% (stored 1, counted 1.0000). Under precision: 3 with no scale, 1000% (stored 10) is 4 digits and is refused. A whole-percent field (max above 1) counts like a number.
    • precision below scale keeps the DECIMAL range meaning (the magnitude stays below 10 to the power p − s): precision: 1, scale: 2 holds 0.05 and refuses 0.1. Zero fits every declaration.
  • A3: where the code lives. Not ERROR_CODE_LEDGER / StandardErrorCode: the top-level code is the already-registered VALIDATION_FAILED. The field code joins FieldErrorCode, 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 why Clause-②: yes holds.
  • A4: no shipped metadata breaks. The census on df3ba164, with \bprecision\s*:\s*[0-9]+ excluding currencyConfig, datetime and generated references, found no example app, template, platform object, seed or JSON fixture that declares a field-level precision. The positive control, the same form for scale, hits examples/app-todo. Two test fixtures declare precision: 5, scale: 0 on a 1–12 hours field (record-validator.test.ts, rest/import-integration.test.ts), and every value they write fits. Spec tests only parse.
  • A5: the named producer's value means total digits. No mismatch. At pin f8a9d0fb, ObjectFieldInspector.tsx:914-921 writes a top-level precision labelled designer.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」.
  • A6: write paths. validateRecord has four call sites in engine.ts: validate() (the dry run), insert() for one row and for an array (where one bad row refuses the whole batch), and update() by id and by predicate (multi: true). insertMany routes through insert with per-row outcomes. REST create, createMany, batch and the import route reach these. The import route's create leg is createManyData, which calls engine.insert(rows[]). update.options.upsert is 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):
    • the three triage pins;
    • the DECIMAL reading (with and without scale, exponent forms, precision below scale, zero);
    • currency and percent (fraction with and without scale, whole);
    • the type set (progress excluded), ordering after min / max / max_scale, update mode, string-carried values, the malformed declaration and the zh-CN message;
    • every engine write door, with a stub driver that shows a refused write reaches nothing.
  • packages/rest/src/import-integration.test.ts: one new MEMBER field, hourly_rate (precision: 5, scale: 2), and two tests.
    • The create route answers 400 + VALIDATION_FAILED + fields[0].code: 'max_precision' + constraint: { precision: 5, scale: 2, actual: 6 } and writes nothing, while 123.45 writes.
    • The import route refuses the over-precision row, writes its sibling, and the dry run predicts it.
  • packages/spec/src/system/validation-message.test.ts: both new templates interpolate their bound and count in every locale.

Verification

Head bb33240d unless stated otherwise. Heavy runs went through scripts/pm/os-verify-lock.sh.

  • Build. pnpm turbo run build --filter='@objectstack/rest^...' (the closure of objectql and rest): 24/24, VERDICT command-exit 0 (at 897593af). After the merge of main, spec was rebuilt: exit 0.
  • objectql.
    • Validator suites (precision, record-validator, number-value): 3 files, 247/247, re-run at bb33240d (first at 563e11d8).
    • Full --project local: 326 files, 6058/6058 (at d6056d91).
    • typecheck (tsc + scripts + check:test-typecheck): exit 0.
  • rest. import-integration.test.ts 44/44 (42 on main + 2 new), re-run at bb33240d in the same VERDICT command-exit 0 (first at 563e11d8). zod-union-fields.test.ts (reads FieldErrorCode.options): 18/18. typecheck: exit 0.
  • spec.
    • check:generated: all 15 artifacts up to date. check:liveness: exit 0.
    • typecheck: exit 0.
    • --project local over src/api, src/system, src/data and scripts/liveness: 202 files, 6840 passed, 1 todo. This is a declared narrowing: the rest of the spec suite is declared to CI.
  • Reverse verification (ablation), at bb33240d, with the fix committed first.
    • The anchor (the if that compares actual with def.precision) was replaced through scripts/ablation-replace.mjs: anchor 1 → 0, blob 84ef8a2e → c928a426. Result: 18 failed / 4 passed in the pin file. The 4 that stay green are the controls (undeclared precision, the ordering pin, the malformed declaration, the fitting-value doors).
    • Restored: blob 84ef8a2e equals HEAD, git diff HEAD is empty, and the marker count is 0. Direction observed: red. The subject resolves through a relative import to src/, not through a package exports, so no rebuild was involved.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at bb33240d derived 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-loads and check:type-check-debt need a whole-repo build;
    • spec check:skill-examples needs the client-react closure built. This diff touches no skill and no client.
    • --ran reconciliation: 114 accounted, 111 run, 3 NOT-MEASURED (derived from exit 3), 0 UNRUN, exit 0.
    • First-pass readings at d6056d91: check:error-code-casing exit 1 on four unaddressed code: assertions (now field-addressed); check:dispatcher-error-vocabulary exit 1 on the unclassified stamp site (now classified); check-engine-split-ratio exit 2 on the shallow clone (deepened with --shallow-since, as it prescribes).
  • Not run locally, declared to CI: the repo-wide pnpm lint; runtime typecheck and 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/main was merged with scripts/pm/os-regen-merge.sh (merge 1c829350). The os-regen driver kept one side of the generated content/docs/references/data/object.mdx, so it was regenerated on the committed merge (bb33240d) and now carries main's action.aria tombstone row and this branch's describe. Sibling entries are present at main's counts: action-aria-removed 20/20, filter-cross-field-comparison-class 2/2.

Changeset

@objectstack/objectql minor, @objectstack/spec minor, BREAKING (a write accept-set narrowing; check-changeset-no-major refuses major). The ADR-0087 disposition is not-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

  • Reading, open for the seat: currency's applied scale. On currency the 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 be yes (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.
  • precision on non-numeric types parses (FieldSchema has 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: 0 parses and refuses every non-zero write. That is loud rather than silent, and a producer-side min(1) would be a separate narrowing. Noted.
  • content/blog/*.mdx (protocol-first-development.mdx:652,680-681, metadata-driven-architecture.mdx:263) teach precision: 1 / precision: 2 as 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 (item records-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 for scale. It belongs to the next checklist-author sweep.

Generated by Claude Code

…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>
…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>
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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/field.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 50e273fd7e933433182d6d89e3968d1af4be7b94.

⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/field.json) — pages documenting those are invisible to this run
  • 5 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 — 141 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 50e273fd7e933433182d6d89e3968d1af4be7b94 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4e4e5563eff92aa0109b129ec6b537d79ea73353 — the merge of head bb33240df524220882ef7064e9a9702461ebb3ac into base 50e273fd7e933433182d6d89e3968d1af4be7b94, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 50e273fd7e933433182d6d89e3968d1af4be7b94 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bb33240df524220882ef7064e9a9702461ebb3ac
Local-runs: none

① Derived judgments

Inputs: card #19992 (body and all 13 comments, the triage answer 5864304093 and the maintainer's #18900 criterion 5727134555 included), PR #20423 (body, the 16-file list, the net diff against main at the head), the check-runs on the head, and origin/main for the files the diff builds on. The seat's ACCEPT 5868199641 was read as a claim to test, not a conclusion to adopt.

  1. The write refusal — right. record-validator.ts gains a precision arm in the numeric branch, after min / max and the scale branch, on number, currency, percent, rating and slider; progress returns before every bound as it did before. It refuses with VALIDATION_FAILED and field code max_precision, never rounds, and judges new writes only (an update judges just the fields the payload carries; pinned). Only a well-formed declaration (integer, 0 or more) is enforced, the same guard scale uses. This is the shape triage 5864304093 ordered: the seam, not storage. The diff touches no driver and no column representation, so the fixed exact-decimal column stands.

  2. The digit-count reading (digitCountAt) — right. Verified by hand against the canonical-string arithmetic: 1234.5 at 2 places is 6, 123.45 is 5, 5 at 2 places is 3, 100 at 0 places is 3 (a trailing zero of the integer part counts), 0.05 is 1 (a leading zero never counts), 1e18 is 19, 1.23e21 is 22, 1.5e-7 is 2, zero is 0, and under precision: 1, scale: 2 the value 0.05 fits while 0.1 does not. The count is taken at the allowance the scale branch APPLIED (scaleAllowance), else at the value's own places, so it is one reading of the field's scale rather than a second derivation. It is equivalent to the DECIMAL(p, s) range: over the bound exactly when the magnitude reaches 10 to the power (p minus places). The three triage pins are the first three tests of the new pin file, and the control (no precision) is the third.

  3. The percent two-place floor with no scale — right (the seat's flag 1). A fraction-stored percent is counted at scale + 2, or at 2 when no scale is declared. Without the floor, 100% (stored 1) would count as one digit and fit precision: 1 while 12.34% would count four: the key would mean displayed digits when scale is declared and stored-fraction digits when it is not. With it, precision on a percent is the digit count of the percentage-point value as displayed, whether or not scale is declared, which is the basis the max_scale rule already encodes (ruling batch Add examples scaffolding for 9 protocol categories #161 item 3 letter B). It is read from percentScaleOf, never re-decided from max; a whole-percent field counts like a number. Pinned twice and stated in the describe.

  4. FieldErrorCode.max_precision and its templates — right. ADR-0114 D2 says a new constraint kind is added to the closed catalog, and the member sits beside max_scale in the max_* family, lowercase snake_case, named for the property it reports on (the invariants errors.test.ts pins). max_precision and max_precision_scaled are added in all four locales, so the every-locale-every-key invariant of the message catalog holds, and the bounded-codes test now requires {{precision}} and {{actual}} (plus {{scale}} on the scaled sentence). The _scaled key is a message key, not a wire code, the same pattern as the value_domain_* variants; the wire code is one. The stamp site is the existing fail(code: FieldErrorCode, ...) helper, so the code cannot leave the catalog.

  5. The liveness row — right. props.precision stays live, verifiedAt 2026-09-28, with the evidence moved from four retired objectui reads at @11c1e71e to the in-repo seam (validateOne, digitCountAt, the pin file, the REST pin); evidenceScope: cross-repo is correctly dropped; the note records the retirement, the WRITTEN-VALUE-ONLY class and the no-DDL caveat. The valueDomain row hunk is a byte-only change (an escaped em dash now written literally), no semantic change. Spec property liveness is green on the head.

  6. The doc corrections — right. The types.mdx currency line taught "precision (0–10, default 2) for decimal places": false on main since feat(spec)!: retire currencyConfig.precision — a currency's decimal places are its currency's (ADR-0049) #20251 retired the currency decimal-places key, and with this PR an author following it would have every amount of 100 or more refused, so the correction has to ride this diff. The number line on the same page now names the refusal. error-catalog.mdx's bounded-ranges row lists the new member. The four reference pages (errors.mdx, field.mdx, object.mdx, migration.mdx) are regenerated from the describe and the enum (the member lists and the +21 more count); Build Docs, Check Documentation Links and the generated-artefact check are green.

  7. The dispatcher vocabulary row — right, and not a published change. packages/runtime/src/dispatcher-error-vocabulary.ts is not reachable from runtime's src/index.ts, which is the tsup entry and the package's only export, so no runtime changeset is owed. The row is the max_scale row with the code changed, which is what check:dispatcher-error-vocabulary requires for the new stamp site.

  8. The REST pin — right. hourly_rate (precision: 5, scale: 2) joins the MEMBER fixture; the create route answers 400 with VALIDATION_FAILED, fields[0].code max_precision and constraint { precision: 5, scale: 2, actual: 6 } and writes nothing, while 123.45 writes; the import route refuses the row, writes its sibling, and the dry run predicts it. The existing work_hours fixture (precision: 5, scale: 0, values 1 to 12) still fits.

  9. Public TypeScript surface. The FieldDef interface addition is internal (not exported). Beyond the enum member and the two message keys, nothing on the diff changes a published type.

  10. Accept-set census. The dev's A4 (no example app, template, platform object, seed or fixture declares a field-level precision) is a measured premise this read-only review did not re-run; it is consistent with Dogfood Regression Gate (all three shards) and Test Core green on this head, which boot the example apps and the platform objects against the new refusal.

② Semver level

  • Changeset 19992-field-precision-write-seam.md: @objectstack/objectql minor, @objectstack/spec minor, a **BREAKING** banner, Clause-②: yes, one ADR-0087 marker not-required (no-migration-prescription). Check Changeset is green.
  • Matches what the diff publishes. spec publishes a new member of the closed FieldErrorCode enum (a widening of a published value set, so at least minor), two message keys and a describe; objectql publishes a narrowing of the write accept set, which is breaking. minor is the launch-window level for a breaking change (check-changeset-no-major refuses major), and the banner plus the marker carry the narrowing. The marker's reasoning holds: nothing authorable is removed or renamed, precision keeps its key, its type and its legality, so no FROM → TO mapping and no conversion is owed, and the changeset states what an author with an oversize value sees and the three fixes.
  • No changeset for @objectstack/rest (test-only) or @objectstack/runtime (an unpublished table): right.
  • Clause-②: line: yes, copied verbatim from claim 5865023687 into the PR body and the changeset. The declaration is right: a member joins a published code set, so yes and at least minor are required. The fuller spelling for this diff is yes (narrowing), since the (narrowing) arm is what the Build and Test rule reads as BREAKING; the arm is optional, and its absence is not a defect here because the BREAKING banner and the ADR-0087 marker state the same narrowing and the registration gate reads them green. Noted, not a FAIL input.

③ Boundary flags

  1. Percent two-place floor with no scale (dev deviation, seat flag 1): answered, right; see ① item 3.

  2. open_questions[0], currency counted at its own decimals, A or B (seat flag 2): answered, A, keep. (a) Ruling 乙 took the currency's decimal places out of the write seam, and enforcing a currency width on writes was offered and not taken, so the validator resolves no currency; B would reintroduce a currency read at the seam, a capability the claim did not give. (b) B is incomplete by construction: under currencyMode: dynamic the currency is per row and the validator does not see it, so one key would count two ways by mode. (c) A is one rule an author can predict, and the describe states it. One correction to the dev's reasoning: the gap is not confined to magnitudes above 2 to the power 53. At precision: 5 on a fixed-USD field, A admits 12345 and 1234.5 where a DECIMAL(5, 2) reading refuses both. That is looser than the spec's DECIMAL(18, 2) prose implies for currency, in the safe direction for a new refusal (A admits everything DECIMAL would admit and never refuses it), and both the describe and types.mdx say the written decimals count in the total. If the maintainer wants the DECIMAL(p, minor unit) reading on fixed-currency fields, that is a one-word ruling on a follow-up card, not a defect of this diff. Not escalated.

  3. types.mdx correction (seat flag 3): answered, in scope; see ① item 6.

  4. Out-of-surface files, each answered. The REST pin: the only pin that proves the code survives the HTTP envelope, test-only. validation-message.ts and its test: the code's registration, since a catalog member without templates degrades to a bare key. dispatcher-error-vocabulary.ts: the gate-required classification of the new stamp site, unpublished. error-catalog.mdx: the catalog page must list every member. types.mdx: above. None widens the claim's intent; each follows from the ordered change.

  5. Dev out-of-scope findings, each answered. Blog posts teaching precision: 1 or 2 as decimal places: the cited lines were checked and use ObjectProtocol.define, default: and enable: shapes refused at parse, so no live producer copies them; noted, no carrier, a docs-sweep item. precision accepted on non-numeric types and inert there: the describe now says it is not read on any other type, and a parse-time applicability refinement would be a separate narrowing; noted. precision: 0 refusing every non-zero write: loud, pinned, and a producer-side minimum of 1 would be a separate narrowing; noted. QA checklist prose on records-forms.field-type-constraints: internal, already stale for scale, the next checklist-author sweep's.

  6. Envelope reading. constraint.scale in a max_precision error names the decimal places the count was taken at (the value's own on currency and on an undeclared scale), not the field's declared scale, which is max_scale's applied-allowance precedent and is stated in the code; accepted, an envelope reader takes it as "counted at".

  7. Serial constraints. record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309 will edit the number arm later and is not in flight, so the later lander resolves; [finding] Two major-18 D3 entries print CurrencyConfigSchema.precision … unchanged through os migrate meta, which PR #20251 makes false in the same major #20379 (the major-18 D3 entry text) is disjoint and, as the PR body says, not addressed here. No other open PR may claim the same single-writer path and No other open PR may claim the same issue are green.

  8. Governance and gates. No governed surface on the diff (Governed Surface Queue Guard green; 654 changed lines). Check-runs on the head at the time of this record: 39 registered, 34 success, 5 skipped (Auto Label, Check PR Size, Packed-tarball smoke twice, Console Pin Gate, all path-filtered), 0 failed, 0 in progress; every gate family the diff derives is answered green, Test Core all six shards included.

Implemented-by: claude/issue-19992-field-precision-write-seam
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: PASS


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36432953259 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  src/data/filter-number-comparand-declared-type.test.ts > [#20336] the judged positions > partition FieldOperatorsSchema's keys with the text operators and the tw
      ↳ 失败原因: @objectstack/spec:test: AssertionError: expected [ '$between', '$contains', …(16) ] to deeply equal [ '$between', '$contains', …(17) ]
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 2 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36434109438 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  src/data/filter-number-comparand-declared-type.test.ts > [#20336] the judged positions > partition FieldOperatorsSchema's keys with the text operators and the tw
      ↳ 失败原因: @objectstack/spec:test: AssertionError: expected [ '$between', '$contains', …(16) ] to deeply equal [ '$between', '$contains', …(17) ]
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit b98fbc2 Sep 28, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19992-field-precision-write-seam branch September 28, 2026 14:58
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants