Skip to content

fix(core): readsAsWallClock answers from the spec's ClockTimeValueSchema - #20885

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20771-wall-clock-one-rule
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20771-wall-clock-one-rule

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20771
Clause-②: no

readsAsWallClock, the private wall-clock half of core's time rule in packages/core/src/utils/temporal-comparand.ts, now answers ClockTimeValueSchema.safeParse(s).success. That schema is the spec's time stored form from @objectstack/spec/data. The private regex is gone, and no second regex was written. Core already imported @objectstack/spec/data in this file, so no dependency edge was added.

Shape taken: delegation, not the fallback. packages/spec is untouched, and no spec export was added, so Clause-②: no holds. The parse path was measured before choosing (below): it costs about 30 ns more per accepted string and about 1.2 µs more per refused one. At realistic counts that is microseconds per request, so the fallback (a spec-exported regex) was not needed.

Measurements (scratch scripts, not committed)

  • Same boolean for every string. 8,655,360 generated strings: hours in 1-, 2- and 3-digit spellings plus non-ASCII digits, : or . separators, minutes in 1- and 2-digit spellings, 12 seconds/fraction variants, 14 suffixes (Z, z, +00:00, +0000, -00:00, +08:00, -0530, +2359, +24:00, a space, a newline, T, a letter, none), with and without a leading space. The old private regex (at base 6c96b37be8) and ClockTimeValueSchema.safeParse agreed on all of them: 8,640 accepted by both, 8,646,720 refused by both, 0 by one side only. The schema is a bare z.string().regex(...), with no transform, refinement or brand.
  • Cost. 2,000,000 calls per leg, three rounds. Accepted strings: old 134 to 154 ns, new 169 to 173 ns. Refused strings: old 41 to 43 ns, new 1,215 to 1,303 ns (zod builds the issue list). On a mixed sample (half accepted, half refused): old 94 to 124 ns, new 734 to 796 ns. For scale, 20 time comparands in one request cost about 15 µs in total at the mixed rate.
  • Where it runs. isUninterpretableTemporalComparand('time', ...) is asked per comparand by the engine's comparand door and the analytics check, and also per written value by the record validator's time arm and per cell by the import's time coercion. So it is not only a per-request cost. At the mixed rate, 10,000 rows with 5 time fields cost about 33 ms more (39 ms in total). At the accepted rate, which is what a valid import mostly hits, they cost about 1.5 ms more.
  • Lean entry. At 5d3ff119cf, check:lean-entry-closure is green: @objectstack/objectql/core keeps its 15 admitted packages (import 201 modules, require 199). check:dts-closure is green: 71/71 declaration files. The import names a specifier core already loads.

Pins

In packages/core/src/utils/temporal-comparand.test.ts, block [#20771]:

  • 23:59:59.999 is read;
  • 24:00 and 10:00Z are refused.

They are asserted through isUninterpretableTemporalComparand('time', ...), because readsAsWallClock is private. None of the three strings is an ISO instant, so the instant half cannot rescue one, and the verdict is the wall-clock half's alone. Exporting readsAsWallClock would widen core's public barrel (export * from this module) and turn this into Clause-②: yes. The existing temporal-comparand pins are unchanged and green.

Ablation

The implementation was committed first (45ba267683). Every leg went through scripts/ablation-replace.mjs in wrap mode.

  • Core side, source-resolved (the test imports core src relatively).
    • readsAsWallClock widened to a bare HH:MM prefix test: 1 red, the 24:00 / 10:00Z pin.
    • readsAsWallClock made to answer false: 4 red. They are the 23:59:59.999 pin and three existing wall-clock pins ([#20264], [#20549], [#20480]).
    • Each was restored: the blob equals HEAD and git diff HEAD is empty.
  • Spec side, dist-resolved. This leg proves the delegation is live.
    • ClockTimeValueSchema was made to accept a trailing Z again, and spec was rebuilt. scripts/ablation-dist-preflight.mjs found the marker in 28 built files.
    • Core's 10:00Z pin went red (1 failed, 25 passed).
    • Control, in the same mutated-spec window: core's function was put back to the old private regex, and all 26 passed. The old shape could not see an edit to the spec; the new one can.
    • Restore: the source was restored, spec was rebuilt, and the preflight found the marker in none of 230 files. The tree is clean, and all 26 passed.

Verification

At head 5d3ff119cf, which includes a merge of origin/main 2d5fe76f43:

  • pnpm --filter @objectstack/core test: 61 files, 1795 passed.
  • pnpm --filter @objectstack/core typecheck: exit 0.
  • objectql engine-time-write-zone-less and engine-temporal-comparand-door: 19 passed, against core dist rebuilt at this head.
  • dispatch-gates --commands: 61 families. --ran: 59 run with exit 0, 2 NOT MEASURED, 0 unrun.
    • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, because their prerequisite is every workspace package built, which is beyond the foreground cap. CI builds that closure.
  • eslint, narrowed to the 2 changed .ts files with eslint --no-inline-config --format json: 2 files, 0 errors, 0 warnings.
    • The population comes from eslint.config.mjs: its **/*.{ts,...} and packages/** blocks. --print-config resolves both files, so neither is ignored.
    • The config enables no type-aware linting. parserOptions is only ecmaVersion and sourceType, so this diff cannot move a verdict on an untouched file.

Acceptance notes

  • packages/core/src/utils/temporal-storage-form.ts canonicalTimeOfDay carries its own wall-clock pattern with capture groups. It needs the groups to write HH:MM:SS, so safeParse cannot replace it. That is the storage rule readsAsWallClock mirrors. Today it admits the same set. It is outside this card's file surface, so it is recorded here and not touched. Carrier: none.
  • objectql's isZonedTimeOfDay (record validator) spells a zone-suffix pattern, but it judges the wall-clock half through isUninterpretableTemporalComparand('time', ...), so it now reaches the spec rule too.

Generated by Claude Code

The private wall-clock regex in temporal-comparand.ts was a second spelling
of the time-of-day set the spec's ClockTimeValueSchema defines. It now asks
that schema, so one rule answers the comparand door, the record validator's
time arm and the spec's time default gate. Pins: 23:59:59.999 read, 24:00
and 10:00Z refused, through isUninterpretableTemporalComparand.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 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 — 25 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 9905e61ca2fddb43d266c23cdb12ced5019c1a74 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d77bff40756516b55dc8ff5454103bf2e3a607d — the merge of head 5d3ff119cf858471f62903b70fccbb90809d6e52 into base 9905e61ca2fddb43d266c23cdb12ced5019c1a74, 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 5d77bff40756516b55dc8ff5454103bf2e3a607d && git checkout 5d77bff40756516b55dc8ff5454103bf2e3a607d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9905e61ca2fddb43d266c23cdb12ced5019c1a74 5d3ff119cf858471f62903b70fccbb90809d6e52 && git checkout -B drift-repro 9905e61ca2fddb43d266c23cdb12ced5019c1a74 && git merge --no-ff 5d3ff119cf858471f62903b70fccbb90809d6e52

node scripts/docs-audit/affected-docs.mjs --json 9905e61ca2fddb43d266c23cdb12ced5019c1a74

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5d3ff119cf858471f62903b70fccbb90809d6e52
Local-runs: none

PR #20885 on card #20771, reviewed at tier from the card (its body and comments 5905061766, 5913205120 and 5914112124), the PR body and file list, the net diff against main at the merge base 2d5fe76f43, PR #20763 (#20740) as the change that narrowed ClockTimeValueSchema, and the head's check-runs. The head was 5d3ff119cf858471f62903b70fccbb90809d6e52 at the first read and at the last; it did not move. Nothing was built, run or re-run locally; the check-run conclusions recorded below are the gate verdicts.

① Derived judgments

Net diff: 3 files, +41 / -4 — packages/core/src/utils/temporal-comparand.ts (+9 / -4), its test (+18 / -0) and .changeset/20771-wall-clock-one-rule.md (+14 / -0). packages/spec is untouched.

  1. The private regex is gone — right. The three-line body of readsAsWallClock (the two-digit capture regex and its three numeric bounds) is deleted and replaced by the single statement return ClockTimeValueSchema.safeParse(s).success;. No wall-clock spelling remains anywhere in the module: the patterns still in the file are the ISO datetime write form, the leading calendar-day capture, the two instant spellings inside readsAsInstant and the storage-form shape test in keepsTimeOfDay, and none of them spells a time-of-day set. No second regex was written.

  2. The one call is the schema's — right. ClockTimeValueSchema is imported from @objectstack/spec/data, the specifier this file already imported for classifyFilterToken; the data barrel re-exports field-value.zod, spec's package.json publishes ./data, and core already depends on @objectstack/spec (workspace:*). No dependency edge was added, and the import line is the only import change.

  3. The delegation answers exactly the old set — right. Read side by side: old ^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$ with hours at most 23 and minutes and seconds at most 59; new ^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?$. Two ASCII digits bounded to 00..23 is [01]\d|2[0-3]; 00..59 is [0-5]\d; the optional :SS carries the same bound; a .fraction of one or more digits is admitted only after seconds on both sides; both are anchored at both ends and carry no m, u, g or i flag, so \d is ASCII-only and $ refuses a trailing newline on both. Both sides read the same trimmed s. The schema is lazySchema wrapping a bare z.string().regex(RE, message) and nothing else — no .refine, .transform, .trim, .pipe, .brand or .default that could move the boolean; lazySchema is a Proxy that binds every method, safeParse included, to one cached real instance, and its only interception is the JSON Schema hook, which the parse path never touches; zod is pinned at 4.6.1 in the lockfile, where .regex() resets lastIndex and tests the string. The dev's reading — 0 of 8,655,360 generated strings answered differently — is consistent with that side-by-side reading. PR fix(spec)!: a time value carries no zone — ClockTimeValueSchema refuses a Z or an offset, with an ADR-0087 disposition for stored defaults (#20740) #20763's narrowing (the optional Z or offset suffix dropped from the regex) is an ancestor of this head and of the dev's base 6c96b37be8, and the schema's lines are byte-identical at that base and at the head, so the set core now asks is the zone-less one the card describes.

  4. No caller's accept set moves — right. The predicate's own control flow is untouched; only readsAsWallClock's body changed, and its boolean is unchanged for every string. Its time callers, each handing a string to isUninterpretableTemporalComparand('time', …): the engine's comparand door (packages/objectql/src/temporal-comparand-door.ts), the analytics comparand check (packages/services/service-analytics/src/comparand-shape.ts), the record validator's time arm and its isZonedTimeOfDay sentence chooser (packages/objectql/src/validation/record-validator.ts, which hands the wall-clock capture to the predicate and judges nothing itself), and the REST import's readTimeOfDayCell (packages/rest/src/import-coerce.ts). The changeset names exactly those four. An instant, a number or a Date on a time column never reaches readsAsWallClock, so that half is judged as before.

  5. No public surface change — right. readsAsWallClock stays a non-exported function; core's index re-exports this module with export *, so no name was added to the barrel; no spec export was added; the PR's file list is the three files above. Clause-②: no holds, on the PR body's second line and in the changeset.

  6. The pins — right, including the deviation. Block [#20771] asserts 23:59:59.999 read and 24:00 and 10:00Z refused, through isUninterpretableTemporalComparand('time', …). None of the three has a leading YYYY-MM-DD, so readsAsInstant is false for each and the verdict is readsAsWallClock's alone; none is a {token}. Triage's wording was "asserted through readsAsWallClock"; the dev asserted through the one public door because exporting the helper would widen core's barrel and turn the claim's Clause-②: no into yes — that reading is right, and the pins reach the function they were asked to reach. The 10:00Z pin is the one that goes red under the pre-fix(spec)!: a time value carries no zone — ClockTimeValueSchema refuses a Z or an offset, with an ADR-0087 disposition for stored defaults (#20740) #20763 schema, which is what shows the delegation is live rather than a copy. The four existing blocks ([#20240], [#20264], [#20549], [#20480]) are unchanged; the test diff is append-only.

  7. Review faces — right. The changeset title names the public symbol (isUninterpretableTemporalComparand), the schema and the specifier; its body states what was two spellings, what is one now, the four callers and the measured invariance, and says what is unchanged. The PR body states the shape taken and why (delegation, measured; the fallback not needed), the pins, the ablation and the verification. Both carry Clause-②: no. Neither names a model identifier.

② Semver level

@objectstack/core patch, Clause-②: no with no arm — right. The diff publishes a changed implementation inside a released package with an unchanged public surface and an unchanged boolean; a fix in a released package takes patch, and skip-changeset is reserved for a diff that publishes nothing from any released package. The changeset is the one file under .changeset/, and Check Changeset concluded success on this head. The Clause-②: line on the PR body and on the changeset agree with what the diff publishes: no widening, no narrowing, no spec export.

③ Boundary flags

  • Measured cost (+30 ns per accepted string, +1.2 µs per refused one). Answered: accepted. The predicate is asked per comparand by the door and the analytics check, per written value by the validator's time arm and per cell by the import's time coercion, so the dev's H2 was rightly corrected from "per request". The slow leg is the refused one, paid on a value about to be refused (invalid_time, a 400, or a dropped cell), not on the accepted path; a valid import mostly pays the +30 ns leg (about 1.5 ms over 50,000 cells by the dev's own figure), and the dev's worst case (half refused) is about 33 ms over the same. That is not "too costly for the comparand hot path", so triage's fallback (a spec-exported regex, which would be Clause-②: yes (widening) on spec) is not owed. It stays the recorded route if a later measurement on a malformed bulk import says otherwise.
  • check:dual-build-cjs-loads and check:type-check-debt NOT MEASURED locally. check:dual-build-cjs-loads runs in ci.yml's Build Core job; check:type-check-debt runs in lint.yml's Type Check · debt ledger job. At this record's reading, both check-runs on this head had concluded success.
  • One merge of main. Answered: 5d3ff119cf is the single merge in the range, merging 2d5fe76f43 into aebac6676b. The merge tree, excluding the three PR files, is byte-identical to 2d5fe76f43's — no hand edit rode in — and between the dev's base 6c96b37be8 and 2d5fe76f43, main did not touch temporal-comparand.ts, its test, temporal-storage-form.ts or field-value.zod.ts, so the measurements taken at 45ba267683 still describe this head. origin/main has since moved past the merged commit; the queue's rebuilt generation covers that.
  • temporal-storage-form.ts canonicalTimeOfDay (out of scope, carrier none). Answered: a storage-form concern outside this card, and escalated as a residue. The card names exactly two spellings — core's readsAsWallClock and the spec's ClockTimeValueSchema — and the claim's file surface is temporal-comparand.ts plus the conditional spec file; not touching temporal-storage-form.ts was right (never expand scope). But canonicalTimeOfDay is a third spelling of the same set — the old core regex verbatim, with the same bounds — and it is the rule readsAsWallClock's own doc comment says it mirrors: after this PR the door admits by the spec's rule while the drivers write by the storage form's, and an edit to one without the other is exactly the "door lets through what the driver hands back unchanged" class this module exists to close. It cannot take safeParse as-is (it needs the capture groups), so its route is either the regex-export fallback or a safeParse guard in front of a shape-only capture. It admits the same set today, so it is unexercised drift, not a defect, and the dev's acceptance-note disposition is right for this PR. Escalated to the PM: it is the same shape core: readsAsWallClock and the spec's ClockTimeValueSchema are two spellings of one time-of-day set with no pin between them #20771 itself was filed as (a residue, given a carrier), and it warrants a card rather than a note that will not be found again.
  • Other deviations in the report. Pins through the public door: answered under ① item 6. Commit trailers: the three commits carry Claude-Session: and the model-free Co-authored-by: Claude pair, which is the AGENTS.md form. Labels: the PR's documentation, size/s, tests and tooling are Auto Label's writes, not the dev's; nothing to answer.
  • open_questions: empty; none to answer. premise_still_valid: true is confirmed — the private regex is at 6c96b37be8 as the card describes it.

Check-runs on this head, read once immediately before this record was posted at 2026-09-30T15:23Z, never waited on: 32 runs. Success, 22: Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Temporal Conformance (live PG + MySQL), Test Core (4/6), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, filter. Skipped, 3: Build Docs, Console Pin Gate, Packed-tarball smoke. In progress with no conclusion yet, 7: Lint & Repo Gates, Test Core (1/6), (2/6), (3/6), (5/6) and (6/6), Type Check · workspace. Of the required contexts, Build Core, Dogfood Regression Gate, Temporal Conformance and Governed Surface Queue Guard have concluded success; Lint & Repo Gates, Test Core and the type-check lane are still running. No check-run on this head has failed. This verdict is on the contract; landing waits on the required set concluding green, which is the PM's convergence and not this record's.

Implemented-by: claude/issue-20771-wall-clock-one-rule
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 15:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 6b004c0 Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20771-wall-clock-one-rule branch September 30, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: readsAsWallClock and the spec's ClockTimeValueSchema are two spellings of one time-of-day set with no pin between them

2 participants