fix(objectql)!: a time field is a zone-less wall clock — a zone-suffixed time of day and an extended-year instant are refused with VALIDATION_FAILED / invalid_time (#20671) - #20721
Conversation
… is refused in its own sentence Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…t this card's surface This reverts commit 691bfab. The write arm stands without it; the spec narrowing is reported as a finding for the spec lane instead. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…the narrowing Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 138 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 d8f2818bba7102d2cfd9156465a1cc3b87e3b533 && git checkout d8f2818bba7102d2cfd9156465a1cc3b87e3b533
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cbaf04c1fd7a595236701bea02280c0f8b34e16e 9b426f8abf8b3618231e6a9e1dd7d27a9b723c81 && git checkout -B drift-repro cbaf04c1fd7a595236701bea02280c0f8b34e16e && git merge --no-ff 9b426f8abf8b3618231e6a9e1dd7d27a9b723c81
node scripts/docs-audit/affected-docs.mjs --json cbaf04c1fd7a595236701bea02280c0f8b34e16e
|
Contract reviewServed-tier: PR #20721 on card #20671. The head was confirmed unmoved at the sha above (branch ① Derived judgmentsEach accept-set and public-surface change the diff implies, judged against triage 5895825766, ADR-0053 D-C1 and core's
The seat answer 5899587971, judged. ADR-0104 D1 on PR body, sentence by sentence where a fact can be checked, the seat-edited first line and H4 bullet included. Shipped prose, two imprecisions that are not defects. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20671
Clause-②: no (narrowing)
The record validator's
timearm now judges a written value by@objectstack/core's one temporal rule,isUninterpretableTemporalComparand('time', value), the rule thetimecomparand door asks since PR #20668. That is how #20525 moved thedate/datetimearm. Atimefield is a zone-less wall clock (triage 5895825766): a time of day with aZor an offset is refused withVALIDATION_FAILED/ 400, field codeinvalid_time, and a sentence that says what to do. An extended-year instant is refused too. Nothing reaches a driver, so it is never a 500. The unanchoredhasDatetest is gone.Base
fa0a4b661(this branch's merge base). Head9b426f8ab.Reproduced first, then after
POST /api/v1/data/:objectthen a read-back throughPOST /api/v1/data/:object/query. The process ran inTZ=America/New_York. PostgreSQL 16.13 was a private server atAsia/Shanghai. Memory isRestServeroverInMemoryDriver, from a scratch probe that was not committed. The card's table reproduced on every cell.time"+010000-01-01T10:00:00Z"(the card)DATABASE_ERRORinvalid_time"9999-12-31T23:00:00-02:00"(UTC year 10000)invalid_time"10:00Z"(the card)"10:00Z""10:00Z""10:00:00""10:00+08:00","10:00:00+0800""10:00:00""10:00:00.250Z""10:00:00.250""2026-07-15 10:00Z"(a space and a zone)"10:00:00"invalid_time"10:00","10:00:00"(the controls)"10:00:00""10:00:00.250""10:00:00.250""2026-07-15T10:00:00Z","2026-07-15T18:00:00+08:00","2026-07-15 10:00""10:00:00""07/15/2026 10:00","x2026-07-15T10:00:00Z","{now}", the number36000000invalid_time" "(blank)nullThe zone sentence, in English: "Slot is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant". Every other refusal keeps the existing "must be a valid time (HH:MM or HH:MM:SS)".
The change
packages/objectql/src/validation/record-validator.ts, thetimearm:readable && !isUninterpretableTemporalComparand(t, value), thedate/datetimearm's line;readableholds the write door to what the comparand door exempts on purpose. A number stays refused as a writtentime(a comparand may be epoch milliseconds), and a{placeholder}stays refused (it is filter vocabulary, judged byclassifyFilterTokenfrom@objectstack/spec/data). A blank is missing before the arm, as before;timeOfDay/hasDatepatterns are deleted;isZonedTimeOfDaychooses the sentence, never the verdict: a time of day plusZ/z/ an offset whose wall-clock half core's rule reads. So"25:00Z"gets the plain sentence.packages/spec/src/system/validation-message.ts: one message key,invalid_time_zoned, inen/zh-CN/ja-JP/es-ES. It is a rendering variant of the existing wire codeinvalid_time, which does not change. See the scope section for why it is here.content/docs/protocol/objectql/types.mdx: thetimeinput sentence said "with an optional fractional part andZ/offset". It now says no zone, and that an epoch number is refused. The number was already refused at base:36000000answered 400 on all three.PM hypotheses, which held
fa0a4b661core's predicate refuses"10:00Z","10:00+08:00","+010000-01-01T10:00:00Z"and"9999-12-31T23:00:00-02:00", measured on core'sdist. The arm asks it. The one addition is the write door's type gate above. The predicate answersfalsefor a number, a{placeholder}and a blank, which are comparand exemptions, and the old arm refused the first two as written values.Date/ epoch-ms / full-timestamp value folds to its UTC time-of-day"). It is pinned as a control on the engine, on REST over SQLite and PostgreSQL, and on the memory driver. Noneeds_decisionis raised on it.invalid_time.fail(code, constraint, messageKey)goes tobuildFieldError, then torenderValidationMessage(messageKey), and that readsBUILTIN_VALIDATION_MESSAGESinpackages/spec. So the prescription has to live there. Details are in the scope section.timekeeps its value, with novalue-shapesreport, as PRs fix(objectql)!: a date string is written in its YYYY-MM-DD form, or refused with VALIDATION_FAILED / invalid_date (#20481) #20524 / fix(objectql)!: a temporal string is written on a real calendar day, and a datetime string in an ISO 8601 spelling, or refused with VALIDATION_FAILED / invalid_date (#20525) #20547 did fordate/datetime. Measured:valueShapeViolationhas one caller, the scan (scan-value-shapes.ts:155). The write path does not call it. Its siblingisScannableValueShapeFieldIS on the write path:ObjectQL.objectHasCoveredValueFielddecides from it whether an object reads theadr-0104-value-shapesflag and passesvalueShapeStrictto the validator. Addingtimethere changes notimeverdict, because the arm reads no strictness flag. It does make every object whose only covered field is atimeread the flag, and it makes the boot line announce a warn mode that does not governtime.valueSchemaFor(field, 'stored')", and "the covered classes are exactly the validator's own non-media branch —REFERENCE_VALUE_TYPES… andSTRUCTURED_JSON_TYPES". Coveringtimechanges that fact. Every deployment that already holds the flag, including every fresh datastore that attests it at creation, would never re-run the scan, so its rows would not be reported. The findings would also block a gate whose strictness thetimearm never reads. And the spec'svalueSchemaFor(time)itself admits"10:00Z"(measuredtrue), so the scan could not reuse its own predicate for this.os-dev-reporton record validator: atimefield written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671.Scope: two
packages/specedits, one kept and one revertedThe claim's file surface did not name
packages/spec. Both edits are explained here, as the claim asks for a breach.Kept:
packages/spec/src/system/validation-message.ts, theinvalid_time_zonedkey. The card needs it. Triage rules that a suffix "is refused with a prescription: drop the suffix, or use adatetimefield for an instant". A refusal's sentence can only come from that catalog. Measured on spec'sdist:renderValidationMessage({ messageKey: 'invalid_time_zoned_absent_probe', label: 'Slot' })renders"Slot (invalid_time_zoned_absent_probe)", the resolution order's step 4, a coding-error fallback. With the key it renders the sentence above, and in zh-CN it renders "时段是不带时区的时刻:…". The spec test "every locale defines every message key" makes all four locales required, and it passes:en/zh-CN/ja-JP/es-ESeach have 38 keys. The key does not widen a published type or export:BUILTIN_VALIDATION_MESSAGESdoes not change: a record of locale to a record of message key to template;check:api-surfaceanswers "@objectstack/spec public API surface + factory signatures unchanged ✓";FieldErrorCodedoes not change;check-widening-tells --declaration nojudgedvalidation-message.tsagainst its declared surface and found no widening tell.What a deployment gains is one more translation key it may override,
validation.field.invalid_time_zoned.@objectstack/specpublishesdist(files[]), and the key ships in 4distfiles, next toinvalid_datetimeas a positive control. So the changeset lists@objectstack/spec: patch.Reverted:
ClockTimeValueSchemainpackages/spec/src/data/field-value.zod.ts. Commit691bfabd6narrowed it to refuse a zone, andb5d95181dreverts it with a normal revert commit. The arm stands without it. With the spec schema left wider, at9b426f8ab:action-params-enforcement.test.ts: 5 / 5;field-zoo-value-shape.test.ts: 45 / 45.All passed. No parity pin or gate reds on the difference. What the wider schema leaves open is reported to the seat as a finding rather than fixed here:
FieldSchemaacceptsField.timewithdefaultValue: '10:00Z';engine.insertthat falls back to that default is refused, 400invalid_time, on a field the caller never sent (measured ona596fad76);validateActionParams, strict under ADR-0104 D2) still admits'10:00Z'for atimeparam (measured[]).The readers of
ClockTimeValueSchemaare all throughvalueSchemaFor:checkLiteralDefaultValue(theFieldSchema.defaultValuegate and the action-paramdefaultValuegate),validateActionParams(runtimeaction-execution.ts:1376), andimport-mapping-target.ts. The last reads only object-shaped schemas, sotimenever reaches it. The objectql scan'sshapeSchemaFornever seestime. Metadata shipped in this repo authors no zonedtimevalue:examples,packages/platform-objects,packages/create-objectstackandskills;examples;timefield declarations inexamplesand 1 inskills, and none carries adefaultValue.The commit
691bfabd6stays on this branch as a ready reference for the spec seat, with its pins.Tests
packages/objectql/src/engine-time-write-zone-less.test.ts(new, 5 tests, recording driver).engine.validate. Each assertscodeVALIDATION_FAILED,fieldsexactlyslot/invalid_time, and zero driver writes.Dateamong them, and each reaches the driver as written.timeexactly when core refuses it as atimecomparand, except{now}. The number is asserted as the other write-only refusal.packages/objectql/src/validation/record-validator.test.ts, one pin flipped.'14:30:00Z'and'08:15:00+02:00'were pinned as accepted; they are now refused withinvalid_timeand the zone sentence. That keeps a load-bearing assertion of the new rule.packages/rest/src/data-temporal-write-real-day-iso.test.ts, a newiton the SQLite cell and the live PostgreSQL cell."10:00","10:00:00","10:00:00.250"and the two full instants read back identically.packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts(new, 2 tests). UnderAmerica/New_York, each spelling the door admits is stored as its wall clock and found by it.Reverse verification. The fix was committed first.
scripts/ablation-replace.mjsreplaced the arm'sreadableline with one that admits every string andDate. The anchor went 1 → 0 and the blobeb565fe32fe5→2b0d369b76c9. objectql was rebuilt, andablation-dist-preflightfound the marker in 4 built files.[#20671]itwent red on SQLite and on live PostgreSQL, and every otheritstayed green.git diff HEADis empty. After a rebuild, the preflight found the marker absent from all 14 built files and the tree clean. objectql went 117 / 117 and REST 12 / 12, both[#20671]cells included.Verification at
9b426f8abtypecheckexit 0 for spec, objectql, rest and driver-memory.--listFileslists the new objectql test and the REST file. driver-memory'stsconfig.jsonincludessrc/**/*.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 110 commands. 107 exited 0.check:skill-examplesfirst exited 3 because the client packages had nodist. It was re-run, exit 0, after building them.--ranreads "110 derived famil(ies) accounted for — 107 run, 3 NOT-MEASURED".check:api-surfaceanswered "unchanged ✓", andcheck:docs"226 generated files in sync".check:nul-bytesscanned 9333 files and found no raw control bytes.check:driver-conformancereads 50 covered cells, 0 DEBT.check-adr-0087-registrationreads the changeset as "BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)", exit 0.check-changeset-no-majorandcheck-empty-changesetexited 0.pnpm lintis CI's).eslint --no-inline-config --format jsonover the 6 changed.tsfiles: 6 files, 0 errors, 0 warnings.eslint.config.mjs's**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}andpackages/**objects cover all 6.--print-configshows noparserOptions.projectorprojectServiceon any of them. Type-aware linting is off, so this diff cannot move a verdict on an untouched file.NOT MEASURED:
check:dual-build-cjs-loadsandcheck:type-check-debtexited 3, PREREQUISITE NOT MET: no whole-workspacedist. The container restarted twice during this run, so a whole-workspace build was not attempted.@objectstack/objectql.has 178 exports and./core52,@objectstack/spec/system400 and@objectstack/spec/data528.check:query-options-erasure: it was killed with the container (exit 137) after its self-test passed, and it was not re-run. CI'sLint & Repo Gatesruns it.Acceptance notes (not filed)
/import: measured after the change on all three backends.10:00Zand10:00+08:00time cells are per-row refusals, from the import's own reader, before this door. They were refused there before this PR too:parseDateCellruns first and never hands this arm a suffix.9999-12-31T23:00:00-02:00is stored as01:00:00, its UTC clock, while the write door refuses the same string. The import converts before the door, both answers can be defended, and this PR leaves it as it was.Dateis still admitted by all three temporal arms, as before. Only an engine caller can send one (JSON cannot carry one), so no public door reaches it.defaultValueon atimefield.Generated by Claude Code