Skip to content

A Date in the cache key collapses two wall clocks onto one entry, and the default output reports the coercion that put it there #698

Description

@rejifald

Scenario: naive-timestamps
Proofs: docs/scenarios/proofs/naive-timestamps/ (8 scripts, 386 checks, real node:http + TZ-fixed child processes)

No disclosure content — correctness and observability findings. Nothing here crosses a principal:
the cache key still folds the principal, so the collision below is between two calls of the same
caller. Every date coercion measured is Zod's z.coerce.date() (3.25.76), which is
new Date(input); the library contributes the outbound pipeline.

First, what works

  • The library has no date code, deliberately, and it shows. On origin/main: new Date( 0,
    getTimezoneOffset 0, Intl. 0, timezone/timeZone 0. There is nothing to
    misconfigure.
  • .report().raw is the one observer that holds the truth — the exact wire string, identical on
    every host zone, where the parsed value is not.
  • z.string().datetime({ offset: true }) refuses every naive form in one method call, on every
    host alike. It is the cheapest correct guard measured in the whole scenario.
  • output: z.string() already gives you the exact wire bytes, one seam earlier than
    wire.response: 'text' — so the scenario's escape hatch costs less than the analogous one for
    numeric precision.

1. cache.ts:45 folds the host's timezone into the key — both directions are wrong

cache.ts:45 is if (value instanceof Date) return JSON.stringify(value.toISOString());. A Date
derived from a naive wall clock therefore carries the host's zone into the key.

Measured directly (keys for { method: 'GET', url, body: { since } }):

literal wall clock UTC America/New_York Asia/Tokyo Europe/Berlin
2026-03-08T02:30:00 56ed935d… 8c34a197… 31408c8f… c9a4fe1f…
2026-03-08T03:30:00 eebe22b0… 8c34a197… (same) 474eb546… 56ed935d… (= UTC's 02:30)

Two consequences, both measured end to end over a store all three processes share:

  • Collision. On America/New_York the distinct readings 02:30 and 03:30 derive the
    identical key, because 02:30 does not exist and resolves to the same instant as 03:30. The
    second call was answered from the first's entry — a question nobody asked, answered with
    another question's body, and the store did not grow.
  • Zero sharing. Three hosts asking the identical logical question produced 3 keys, 3 vendor
    requests, 3 entries, 0 hits
    . With the same body as an uncoerced string: 1 entry, 1 request,
    3 hosts.

Ask: canonicalise a Date in the cache key to something that cannot silently alias — or at
minimum note in the caching guide that a Date in the key is host-zone-dependent. The fix and the
bug differ only by whether a schema said z.coerce.date().

2. The default output reports nothing when validation changes the data

validateOutput (engine.ts:460-474) computes findings only on the drift branch:

if ((out as { __kind?: unknown }).__kind === 'drift')
    return { value: validated, findings: classifyDiff(raw, validated, ) };
return { value: validated, findings: [] };

So output: Event where Event contains a z.coerce.date() returns the coerced value with 0
findings
— a silent transformation of the data by the contract that was supposed to describe it.
You only hear about it if you wrote drift().

Ask: this is defensible (drift is opt-in), and it is worth a line in the validation guide:
a plain output schema may change the value, silently, and only drift() reports it.

3. drift()'s coerced detail is kinds-only, and here that is the whole information

The finding is { level: 'warn', path: 'created_at', change: 'coerced', detail: 'string -> object' }
(drift.ts:61, :67, :78-79) — byte-identical on UTC and America/New_York while the underlying
data differs by five hours
, and the same grade as a "4200" → 4200 re-typing in the same run.

verdict.flag/verdict.accept cannot see it at all. Across nine schema arms × two host zones the
entire vocabulary is: string -> object, Expected date, received string, Invalid datetime,
string -> number. Zero occurrences of zone, offset, UTC, local, DST or TZ.

This is the second scenario to file the same shape (a coerced detail that cannot grade its own
coercion); it is recorded here because a date is the case where the kinds are least informative —
string -> object is true of every possible answer.

Ask: include the old and new values in a coerced finding, or their rendered forms. The
diff already has both (diff.ts:91 carries oldValue and value).

4. util.ts:549 sends a value the vendor never mentioned

out.push([key, value.toISOString()]); — always UTC, always Z. Read off the server's own request
line, the vendor that sent 2026-03-08T02:30:00 was asked back about:

host request the vendor received
UTC GET /search?since=2026-03-08T02%3A30%3A00.000Z
America/New_York GET /search?since=2026-03-08T07%3A30%3A00.000Z
Asia/Tokyo GET /search?since=2026-03-07T17%3A30%3A00.000Z

The Tokyo row is the wrong day. The JSON body path reaches the same place through
Date.prototype.toJSON.

Not a bug — toISOString() is the only correct serialisation of a Date — but the pairing of §1's
silent coercion with this line is what turns "we read a wall clock" into "we queried a different
instant", with no diagnostic anywhere between them.

5. A declared input does not stop it

Of six outbound spellings, four reached the vendor carrying a host-dependent UTC instant —
including a fully declared input: { query: z.object({ since: z.date() }) }, which validates a
Date and then hands it to util.ts:549. The only enforcement that worked was a Zod string
constraint that refuses the Date type outright (invalid query: Expected string, received date).

Ask: a note in the URL-shaping guide that a Date in query is serialised as UTC, since the
type system actively encourages putting one there.

Reproduction

npx tsx docs/scenarios/proofs/naive-timestamps/c6-cache-key.ts

Eight scripts run offline. Host timezone is varied by spawning fresh node children with TZ set
before boot — each child's zone is proven by getTimezoneOffset() at two fixed instants plus an
Intl-resolved name before any claim is scored. Every timestamp is a literal; nothing depends on
the wall clock at run time.

Source references (verified against origin/main)

  • cache.ts:45if (value instanceof Date) return JSON.stringify(value.toISOString());
  • util.ts:549out.push([key, value.toISOString()]);
  • engine.ts:460-474validateOutput; :468 if ((out as { __kind?: unknown }).__kind === 'drift')
  • drift.ts:61change: 'coerced', · :67coerced: 'warn', · :78-79return `${kindOf(d.oldValue)} -> ${kindOf(d.value)}`;
  • diff.ts:91out.push({ op: 'change', path, oldValue: before, value: after });
  • types.ts:1540-1549export interface Clock with now, sleep, setTimer, clearTimer
  • resilience.ts:72const when = Date.parse(raw); // HTTP-date (the only Date.parse in core)
  • Zero-counts across packages/core/src/*.ts: new Date( 0, getTimezoneOffset 0, Intl. 0, timezone/timeZone 0

Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on origin/main.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions