Files
deepseek-harness/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md
T
Chinesezjc b72e83e915 revert(workspace): keep the platform instant rendering in the record writer
The shared formatter renders byte-identical strings for every epoch a
workspace record can receive, and the two inputs it would refuse (a
fractional epoch, an extended year) are unreachable from Date.now() and
from a SessionHeader epoch. Adopting it therefore added a module, a
dependency, and a writer test for an error branch that cannot occur, so
this reverts that increment and records the site as the fifth deliberate
exclusion in the Agent Note.

The session-format migration and its committed acceptance test remain.
2026-10-19 18:18:00 +08:00

15 KiB

Agent Note: Shared time vocabulary for wall-clock selectors

Status: implemented

English | 中文

Problem

dsh-util-time existed for one job and one caller shape: canonicalClientTimeZone validated a caller-reported zone at a wire boundary and answered undefined, which dsh-api-session-controller and dsh-subagent mapped onto their own domain errors. Two other consumers had grown parts of the same vocabulary privately. Schedule held a strict RFC 3339 instant parser, a local-calendar parser, a four-digit-year bound and canonical formatter, a local wall-clock resolver, and its own IANA zone canonicalizer in packages/schedule/schedule/src/domain.ts, throwing ScheduleInputError instead of answering a result. dsh-time-context held a second copy of the IANA zone profile in packages/context/time-context/src/request-zone.ts, with its own regular expression and its own Intl.DateTimeFormat canonicalization, to enforce the stricter policy that a request may only name a zone another process resolves back to the same identity. The IANA profile and the Intl canonicalization step therefore existed three times in all, so a change to that profile had to be made and verified once per copy, and the next consumer of a wall-clock selector had no shared entry point to reach for.

Decision

dsh-util-time owns the time vocabulary that boundaries accept. packages/util/time/src/instant.ts holds the parsers, src/duration.ts holds the span type, and src/index.ts re-exports both and keeps canonicalClientTimeZone as the string | undefined wrapper the existing wire boundaries consume.

The library answers discriminated results and carries no failure vocabulary. parseOffsetInstant, fourDigitYearEpoch, parseLocalCalendar, and canonicalizeTimeZone answer an accepted arm or a rejected arm naming a stable reason (not_rfc3339, out_of_range, not_a_calendar_date, invalid_numeric_offset, malformed, blank, padded, not_area_location, unsupported, not_canonical_shape). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. formatFourDigitYearInstant returns the canonical instant text or undefined outside the bound, isFourDigitYearInstant answers whether an epoch is representable at all, and resolveLocalInstant answers epoch milliseconds or undefined for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is out_of_range in both, so year 0 is refused by parseOffsetInstant and parseLocalCalendar alike, while resolveLocalInstant accepts any year a real Temporal date carries because its callers derive local dates at the UTC edges. LocalCalendarTime.year records that split.

Schedule consumes it and keeps every domain decision. canonicalizeTimeZone, decodeInstant, parseAtInput, and futureInstant remain Schedule entry points with their existing codes and diagnostics; they now call the shared parser and map its reason. Schedule still owns the durable record shapes, the fold and transition authority, the injection-resistant framing, and all recurrence arithmetic: the cron engine, the daily and weekly date walks, the latest-due search, and the schedule-specific accepted-decision bound.

dsh-time-context consumes it too and keeps its stricter policy. deriveBrowserTimeZoneContext reads each user-rpc zone through canonicalizeTimeZone and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its unsupported rejection carries the platform error the library caught, so that refusal keeps its cause in both consumers.

Duration holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded number, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (Date.now(), SessionEvent.time, the session log, the client), and because a double holds every millisecond of the whole Date range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is fromMilliseconds and a whole-part decompose(), the two entries the migrated consumer calls.

The last copy of the canonical instant profile moves too. packages/session/session-format-v0-to-v1/src/payload-validation.ts validated a stored schedule instant with its own regular expression and round trip; the shared fourDigitYearEpoch owns that same profile, so the migration now asks it and keeps its own error type and message. Because that migration reads released logs, its accepted set is a compatibility promise: packages/session/session-format-v0-to-v1/tests/validation.spec.ts accepts the four-digit-year bounds and refuses each spelling a regular expression alone would admit (0000, 2026-02-30, a four-digit fraction, a numeric offset, surrounding whitespace).

The maintained @js-temporal/polyfill 0.5.1 becomes a runtime dependency of dsh-util-time as well as of dsh-schedule. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. util/time's own description and README drop the zero-dependency phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why packages/README.md and the util/ line in AGENTS.md keep the phrase while packages/util/README.md and this package's own description name what it loads.

Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same ScheduleInputError or ScheduleLogError code and the same message, and the stored scheduledAt profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to packages/schedule/schedule/tests/domain.spec.ts is the only place that pins the three local-calendar message strings. packages/util/time/tests/instant.spec.ts covers the new entries directly, per-file 100% included.

Alternatives considered

Keep the shared module private to Schedule. A Schedule-local time module beside domain.ts would shrink that file without touching a published package, its lockfile entry, its README, or its dependency set. It was rejected because the duplication this change exists to remove is the duplicate profile, not the duplicate line count: the zone regular expression, the canonical Intl step, and the instant profile would still exist twice, and the next boundary that accepts a wall-clock selector would copy a third.

Give the library its own error type. A TimeInputError carrying the reason would let callers rethrow instead of mapping, shortening each call site. It was rejected because util/time's existing contract is that each boundary owns its refusal vocabulary, and Schedule's messages reach the model through the tool result { code, message } and the client's message card; a shared error class would put one caller's wording into another caller's surface.

Have the library return undefined instead of reasons. That is the shape canonicalClientTimeZone already has, and it was the smaller change. It was rejected because Schedule must distinguish a malformed instant from a non-existent calendar date from an out-of-range clock field to keep its existing diagnostics, and canonicalizeTimeZone must distinguish a bad name from a locally unresolvable one; collapsing those to one falsy value would have forced Schedule to re-derive the distinction with its own regular expressions, restoring the duplication.

Brand the millisecond count instead of building a class. Branded<number> already exists in this repository for opaque identities, and a branded count is smaller than a class. It was rejected because the brand does not survive the operation that matters: TypeScript accepts brandedA + brandedB and answers an unbranded number, so a caller who adds a span to an epoch silently loses the unit, which is the exact mistake the type exists to prevent. A class makes that addition a type error, and it also gives the whole-part split one home.

Hold the span in nanoseconds. Temporal carries nanoseconds internally as a bigint, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a bigint, which no number arithmetic, no Date, and no JSON.stringify accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a number would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name.

Add formatting and DST arithmetic for other consumers too. No current consumer needs model-facing formatting or offset arithmetic from this library: dsh-time-context owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need.

Consequences

The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's domain.ts loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the Intl canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to isFourDigitYearInstant, so the range itself has one owner.

The cost is a wider published surface and a heavier dependency graph: dsh-util-time gains a runtime dependency on @js-temporal/polyfill, so a consumer that imports only canonicalClientTimeZone now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a never default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on not_canonical_shape — a reason the current IANA data never produces, so it is guarded by a v8 ignore rather than a never default — and folds blank, padded, not_area_location, and unsupported into one diagnostic. A reason added to TimeZoneRejection later would therefore compile there and silently take that generic message.

A value type publishes what its consumers call, so the span exposes the constructor and the split that dsh-time-context uses and nothing else. A conversion, an arithmetic operation, or a zero identity lands with the caller that needs it; the seconds-valued fields at dsh-schedule and the DeepSeek file store are the spans that will ask for a second-unit conversion, and the bound and the freeze the class already carries are what make that addition a small one. The package README records the un-migrated boundaries as a known limitation.

The elapsed reading keeps its split in the library and its wording in the consumer: dsh-time-context still decides that a backward clock reading clamps to zero and how the compact line reads, while Duration.decompose() answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (1d 1h 1m 1s, 1m 1s, 4m 2s, and the clamped zero). Two samples do change: NaN rendered NaNs and +Infinity rendered Infinityd NaNs, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A -Infinity sample is unchanged, because the consumer clamps it to zero before the span is built.

Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in packages/context/time-context/src/timestamp.ts, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own offset reading for display, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. Five time sites keep their own code, each for a different reason. packages/experimental/tool-session-query/src/input.ts parses a wider profile than the shared entry — seconds optional, any number of fractional digits, and the digits below the millisecond kept so two instants can be compared exactly — so substituting the shared parser would narrow what it accepts, and moving that profile into the library is a design step of its own. The Date.parse(record.scheduledAt) reads in dsh-schedule parse a value the write path already validated, so a discriminated decode would add a rejection branch that cannot occur in practice and change what a corrupt record reports. The everySeconds, afterSeconds, and refreshMarginSeconds multiplications each sit beside a validation that raises the package's own error, so routing them through a constructor that throws its own type would change the failure a caller sees. The UTC offset a client reports is computed in packages/client/ui-settings-account/src/client/client-metadata.ts instead of shared: the client bundle admits a cross-plugin value import only for the packages it lists as inline-safe, dsh-util-time is not one of them, and it imports the Temporal polyfill at module scope, so inlining it would ship that polyfill to the browser. The Desktop host computes the same offset from the shared library, where no such rule applies. dsh-workspace writes its record timestamps with the platform's toISOString, which is the canonical rendering for every representable epoch: the extended-year and fractional-truncation cases a shared writer would refuse are unreachable from Date.now() and from a SessionHeader epoch, so adopting the shared formatter there would add a module and a dependency without changing a byte.