Found while implementing #13994 (the import-job DTO's four timestamps). Not addressed there, and this card does not close it.
The shape
The repo now has one canonical spelling for "a driver handed me a timestamp column, the contract declares a string" — three landed copies, all with the same Date arm:
All three reach value.toISOString() for any value instanceof Date. For an Invalid Date — a Date object whose time value is NaN — that call raises RangeError: Invalid time value.
The spelling each of them replaced was String(value), which for the same input produces the string "Invalid Date".
So for that one input shape the repair trades a wrong-looking-but-served string for an uncaught exception at a serialisation seam — a 500 on a read path, rather than a visibly-wrong field the caller can see and report.
What is NOT claimed
Reachability is unmeasured. The measured input domain these normalisers were written against — enumerated in canonicalVersionInstant's docblock in protocol.ts — is Date (Postgres/MySQL/Mongo), canonical ISO text (SQLite and friends), epoch-ms number, and nullish. An Invalid Date is in none of them, which is exactly why no copy guards it and why #13994 deliberately did not invent a fourth variant to add a guard unilaterally.
The reason to look rather than close this on the enumeration alone: a driver constructs these Dates from stored bytes, and a legacy or zero-valued datetime is the classic way a driver produces new Date(NaN) rather than throwing at the driver. Whether any dialect and driver version in use actually does that here is the question — it wants a measurement against a real MySQL/Postgres with a legacy row, not a reading of the docblock.
Why it is one card and not three
The three copies are deliberately one spelling; a guard added to one and not the others re-opens the drift the single spelling closed. So the decision is about the shared spelling:
- A — leave it. The input shape is outside the measured domain; a throw at the seam is arguably better than serving
"Invalid Date" to a client, because it is loud. Cost: the loudness lands as a 500 on a read endpoint, on a row the operator cannot see from the error. - B — give the shared spelling a total
Date arm, e.g. falling back to the previous rendering (or to '') when Number.isNaN(value.getTime()). There is in-repo precedent for exactly that guard on the same call: packages/rest/src/export-format.ts:291 and packages/rest/src/import-prepare.ts:115 both test Number.isNaN(value.getTime()) before using a Date. Cost: it re-admits a silent wrong value on a path that would otherwise fail loudly.
Either way it is a change to a spelling three packages now share, so it is a maintainer call rather than an implementer's — which is why this is filed rather than folded into #13994.
A useful first step either way is the measurement, not the fix: drive a legacy/zero datetime row through a real MySQL and a real Postgres and record what the driver materialises. If no driver produces an Invalid Date, A is settled on evidence instead of on the docblock.
Backlinks: #13994 (where this was found), #13997, #13973 (the census this family came from). None are addressed here.
Generated by Claude Code
Found while implementing #13994 (the import-job DTO's four timestamps). Not addressed there, and this card does not close it.
The shape
The repo now has one canonical spelling for "a driver handed me a timestamp column, the contract declares a string" — three landed copies, all with the same
Datearm:packages/metadata-protocol/src/protocol.ts—auditMetaItem'soccurredAt(the model form)packages/metadata-protocol/src/sys-metadata-repository.ts—canonicalIsoInstant(MetadataItem.authoredAtis declaredz.string()but receives a JSDateon Postgres/MySQL — a silent declared-contract violation, because the schema is parsed only in its own test #13997 / Canonicalise driver-materialised timestamps at the metadata adapter boundaries #14040)packages/rest/src/rest-server.ts—canonicalIsoStamp(rest-server: the import-job DTO serves"Sun Aug 30 2026 … GMT+0800 (China Standard Time)"for four timestamp fields on Postgres/MySQL —String()over aDate#13994)All three reach
value.toISOString()for anyvalue instanceof Date. For an Invalid Date — aDateobject whose time value isNaN— that call raisesRangeError: Invalid time value.The spelling each of them replaced was
String(value), which for the same input produces the string"Invalid Date".So for that one input shape the repair trades a wrong-looking-but-served string for an uncaught exception at a serialisation seam — a 500 on a read path, rather than a visibly-wrong field the caller can see and report.
What is NOT claimed
Reachability is unmeasured. The measured input domain these normalisers were written against — enumerated in
canonicalVersionInstant's docblock inprotocol.ts— isDate(Postgres/MySQL/Mongo), canonical ISO text (SQLite and friends), epoch-msnumber, and nullish. An InvalidDateis in none of them, which is exactly why no copy guards it and why #13994 deliberately did not invent a fourth variant to add a guard unilaterally.The reason to look rather than close this on the enumeration alone: a driver constructs these
Dates from stored bytes, and a legacy or zero-valued datetime is the classic way a driver producesnew Date(NaN)rather than throwing at the driver. Whether any dialect and driver version in use actually does that here is the question — it wants a measurement against a real MySQL/Postgres with a legacy row, not a reading of the docblock.Why it is one card and not three
The three copies are deliberately one spelling; a guard added to one and not the others re-opens the drift the single spelling closed. So the decision is about the shared spelling:
"Invalid Date"to a client, because it is loud. Cost: the loudness lands as a 500 on a read endpoint, on a row the operator cannot see from the error.Datearm, e.g. falling back to the previous rendering (or to'') whenNumber.isNaN(value.getTime()). There is in-repo precedent for exactly that guard on the same call:packages/rest/src/export-format.ts:291andpackages/rest/src/import-prepare.ts:115both testNumber.isNaN(value.getTime())before using aDate. Cost: it re-admits a silent wrong value on a path that would otherwise fail loudly.Either way it is a change to a spelling three packages now share, so it is a maintainer call rather than an implementer's — which is why this is filed rather than folded into #13994.
A useful first step either way is the measurement, not the fix: drive a legacy/zero datetime row through a real MySQL and a real Postgres and record what the driver materialises. If no driver produces an Invalid
Date, A is settled on evidence instead of on the docblock.Backlinks: #13994 (where this was found), #13997, #13973 (the census this family came from). None are addressed here.
Generated by Claude Code