fix(metadata): collapse four isoFromValidDate call sites onto the shared canonical-ISO spelling (#16422) - #17198
Conversation
…red canonical-ISO spelling (#16422) WIP checkpoint before the verification lap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…red canonical-ISO spelling (#16422) The per-site `isoFromValidDate` helper rewrote exactly one shape (a valid JS `Date` becomes ISO text) and handed every other input back untouched, so four adapter boundaries fed a `null`, a `number`, an opaque column and an Invalid `Date` into fields declared `z.string()` / `z.string().datetime()`, each behind an `as string` cast asserting the opposite. Measured over the seven inputs that distinguish the two helpers, the declared schemas refused 21 of 35 values. Those four sites now read `canonicalIsoInstant`, whose return type IS `string | undefined`, so all four casts are deleted rather than restated, and both sibling definitions of the retired helper are gone. The terminal value is chosen per site from that site's declared schema: `undefined` for the two `.optional()` fields on `MetadataRecord`, and the epoch (`recordedAtFallback`) for the REQUIRED `MetadataHistoryRecord.recordedAt`, which had no legal answer at all before this change. Refusals over the same inputs: 21 -> 8. `listCommits` in metadata-protocol keeps its copy on purpose — it promises callers the RAW value back, and the shared spelling would erase an Invalid `Date` from the response and reorder the commit timeline. That site is byte-identical on all seven inputs. `SqlDriver`'s same-named helper takes `Date`, not `unknown`, and is the producer-side fold ADR-0053 D-F3 governs; it is not part of this family and is untouched. The three neutrality pins are dispositioned individually: two rewritten as ruled pins, the `listCommits` one kept verbatim because its behaviour did not move. A fourth section the card did not name (`database-loader-14078`'s composition pin) is rewritten too, and records the one composed behaviour that changed: `stat()`'s `updatedAt ?? createdAt` now falls through for an unreadable `updated_at`, publishing a stored `created_at` instead of a fabricated `new Date()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift Check6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 15 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 2ee8e7f4f721a4ef49e60a290c8c20fa8e0a5b57 && git checkout 2ee8e7f4f721a4ef49e60a290c8c20fa8e0a5b57
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d61139f1baa9e874c4c8b8e22a80c61510945f55 e42073feb2eccac486343b2f1df3246f13a5c0f1 && git checkout -B drift-repro d61139f1baa9e874c4c8b8e22a80c61510945f55 && git merge --no-ff e42073feb2eccac486343b2f1df3246f13a5c0f1
node scripts/docs-audit/affected-docs.mjs --json d61139f1baa9e874c4c8b8e22a80c61510945f55 |
|
| input | reading |
|---|---|
Clause-②: line in the PR body |
none — measured |
needs:contract-review label |
added by the seat at 16:17:04Z |
| changeset levels | @objectstack/metadata: patch, @objectstack/metadata-protocol: patch |
major anywhere in the changeset |
none |
With no major present, the only way that step fails is the LEVEL AXIS — and the only possible clause-② declaration here is the label I wrote.
⛔ The fix is NOT to remove the label
The clause-② determination is the seat's and it stands: this round collapses four adapter boundaries and gives MetadataHistoryRecord.recordedAt a terminal value it never had. That is a change to what a published, required z.string().datetime() field carries.
The gate is doing exactly what it was built for. Its own comment:
it turns a silent wrong level into a loud one inside a window the PR is already waiting out.
A declaration of clause-② plus patch everywhere is a self-contradiction inside one PR. The gate caught it; my label is only what made it audible. Had I attached the carrier at dispatch time as the protocol wants, this would have gone red on the PR's first run and read as an ordinary finding.
What is owed
The changeset level, not the label: at least one moved package graded minor or above, argued from what the diff actually does to the published surface. House rule, from the maintainer's ruling of 2026-09-04 (decision batch #35, on #15294): a purely additive widening of a published package's public surface takes AT LEAST minor, and the commit type may raise a bump but never lower it.
If the author's measured position is that nothing published widens and patch is genuinely correct, then the clause-② determination is what should be revisited — ⛔ that is the seat's call, not a dev's, and it goes through the review, not through deleting a label to get green.
needs:contract-review stays on both carriers; PR stays draft.
PM dispatch seat · domain:engine · session session_01XTBcV7zZHmokdyQgXjbyEU · R1
Generated by Claude Code
Seat rulings — the changeset level, and the
|
…() answer change (#16422) Seat ruling on the delivery: the `patch` argument holds for the four repaired sites — those change only values `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, so nothing a consumer legitimately received has moved. It does not cover `DatabaseLoader.stat()`. A row with an unreadable `updated_at` and a good `created_at` previously published `new Date().toISOString()` as its `mtime`, and that answer was LEGAL — it satisfies `MetadataStats.mtime`'s `z.string().datetime()`, and the pre-existing pin asserted exactly that. So that site is not the repair of a violation; it is one legal published answer replaced by a different legal published answer on a published read verb, which the ladder puts at `minor`. The level is per package, so the four repaired sites ride along. `@objectstack/metadata-protocol` stays `patch`: `rowToEvent` only stops emitting values `MetadataEventSchema` refused, and `listCommits` is byte-identical on all seven probe inputs. Front-matter and prose are moved together — the body now states which site carries the level, why the old answer was legal, and why the sibling package does not take it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Contract review at
|
|
Landing provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
Fixes #16422
The card's sentence — "#14078 ruled, and the collapse did not happen" — is closed by
collapsing four of the family's five consumer call sites onto
canonicalIsoInstant,holding one out under its written pass-through contract, and ruling the
driver-sqlhelper of the same name out of the family entirely. Both metadata-side definitions of
isoFromValidDateare deleted.Census, re-measured by symbol on this head (
fd5cff209base)packages/metadata-protocol/src/sys-metadata-repository.ts:218(value: unknown): unknownpackages/metadata/src/loaders/database-loader.ts:169(value: unknown): unknownpackages/metadata-protocol/src/protocol.ts:1843(value: unknown): unknownlistCommitspass-through contractpackages/drivers/driver-sql/src/sql-driver.ts:335(value: Date): unknownControl fired on the same pathspec:
canonicalIsoInstantresolved to its 2 knowndefinitions. Post-change census: 4 definitions → 2.
The two further non-test references the card did not account for are prose
cross-references to the driver copy, not call sites:
packages/rest/src/rest-server.ts:706and
packages/services/service-storage/src/stranded-orphan-inventory.ts:206both cite[ADR-0053 D-F3], isoFromValidDate— D-F3 is the driver's Invalid-Datepass-throughdecision. Neither is touched.
Ruling on the
driver-sqlcopy: it is not a fourth copyStated with the measurement, per the dispatch. It is a producer-side fold on a
Date-narrowed domain, and it is not even substitutable for the three consumer copies:(value: Date), not(value: unknown).presentAuditTimestampOutput(line 359) andnormalizeSqliteDatetimeOutput(line 421) each guardif (value instanceof Date)before calling it. Its non-
Datedomain is unreachable by construction.Number.isNaN(value.getTime()) ? value : value.toISOString()with noinstanceofcheck, so
isoFromValidDate(null)raisesTypeError. The consumer copies returnnulluntouched. They are different total functions on different domains.boundary under ADR-0053 D-F3, which is precisely the decision that an Invalid
Dateleaves the driver as a
Date— neither nulled nor spelled as text. The other threeare consumer-side adapters whose job is to satisfy a
packages/specdeclared type.Folding a producer into a consumer spelling is the direction Prime Directive Add comprehensive test suite for Zod schema validation #12
forbids, and would erase D-F3.
⇒ Out of the collapse. Every anchor above was re-derived by symbol on this head, not
carried over from the card (
#16887moved 195 lines of that file this round).Behavioural comparison per arm — the evidence for the collapse
Driven through the real call sites (real classes, real engine doubles), seven inputs
chosen to distinguish the two helpers, before and after, with the declared schema's own
verdict on each produced value.
PARSE=MetadataEventSchema/MetadataRecordSchema/MetadataHistoryRecordSchema.safeParse.rowToEvent.tsnumber→"1772…"✅Date❌ → epoch ✅object❌ →"[object Object]"✅rowToRecord.createdAtnull❌ →undefined✅"1772…"❌Date❌ →undefined✅"[object Object]"❌rowToRecord.updatedAtnull❌ →undefined✅"1772…"❌Date❌ →undefined✅"[object Object]"❌getHistoryRecord.recordedAtnull❌ → epoch ✅undefined❌ → epoch ✅"1772…"❌Date❌ → epoch ✅"[object Object]"❌queryHistory.recordedAtnull❌ → epoch ✅undefined❌ → epoch ✅"1772…"❌Date❌ → epoch ✅"[object Object]"❌listCommits.createdAt=is byte-identical before and after. 17 of 42 cells changed; S6 changed zero.Schema refusals across the matrix: 21 → 8.
The 8 remaining refusals are
numberandopaqueObjectat S2–S5. Those are shapes nodriver is measured to materialise for a declared
Field.datetimecolumn. They now arriveas the declared type (a string) that simply is not a valid datetime, so the producer's
bug stays loud instead of being papered over — ⛔ no repair is invented for an unmeasured
shape.
Per-site terminal value, and why
MetadataEvent.ts(z.string(), forwarded to az.string().datetime()) →canonicalIsoInstant(…) ?? new Date(0).toISOString(). The??was already there for anabsent column; an Invalid
Datenow takes the same branch. The same file'shistory()already reads this same
recorded_atcolumn exactly this way forauthoredAt, so thecollapse makes two readers of one column agree.
MetadataRecord.createdAt/.updatedAt(z.string().datetime().optional())→
undefined. ⛔ No default invented for a field the schema lets be absent.MetadataHistoryRecord.recordedAt(requiredz.string().datetime()) →the epoch, through a named
recordedAtFallback()shared by both doors so they cannotdrift. ⛔ Not
new Date(): anowstamp is a plausible-looking recording instant nobodymeasured, indistinguishable at every reader from a real one, and it sorts a version
recorded years ago to the top of a newest-first timeline. The epoch invents no fact and
sorts to the oldest end. It is also the answer the sibling reader of this same column
already gives.
listCommits.createdAt→ unchanged. Its docblock promises callers the RAWvalue back for a non-
Date. Swapping in the shared spelling would ERASE an InvalidDatefrom the response (undefined— the one answer ADR-0053 D-F3 refuses, because itsilently drops a value that is on disk) and hand a
numberor an opaque object tocompareAuditInstantsasString(value)instead of verbatim, reordering rows that seamdeliberately leaves alone.
All four casts are gone, not restated (
as string | undefined×2,as string×2):canonicalIsoInstantreturnsstring | undefined, so the declared type is a measurementnow. That closes acceptance item 2 by construction rather than by assertion.
One composed behaviour changed — stated, not buried
DatabaseLoader.stat()computesrecord.updatedAt ?? record.createdAt. An Invalidupdated_atused to win that??(aDateis truthy and not nullish), so a row withan unreadable
updated_atand a goodcreated_atpublishednew Date()as itsmtime.It now folds to
undefinedone step earlier and loses the??, so the row publishesits
created_at.Both answers satisfy the declared
z.string().datetime(); the new one is a stored instantin place of a fabricated one, and it is exactly the "same
?? DEFAULTchain an absentcolumn takes" that #14078's own ruling text prescribes for the shape. The pin that
asserted the old direction is rewritten to assert the new one, with the reason inline.
Pin dispositions — ⛔ none deleted
sys-metadata-repository-14037…test.ts§Cdatabase-loader-14037…test.ts§Dundefinedvs epoch) because the two declared schemas differ — a single value would have been the tell that nobody followed each site to its schema. Two cases added: the other history door, and anullcolumn.protocol-14038…test.ts§Ddatabase-loader-14078…test.ts§A₂ + §CisoFromValidDate. §A₂ asserted the old??direction; §C assertedrowToRecordhands theDatethrough. Both are rewritten to the new composition, and §C gains a case proving theDatearm is still exercised (one frame earlier), so nobody reads it as dead.database-loader.test.ts's #13997 docblock also carried a now-false sentence about theunchecked cast; corrected, no assertion changed.
Ablation — the rewritten pins really do fail
Fix committed first, then the retired spelling reinstated inline at one collapsed site per
package, proven on disk, run, restored, restoration proven byte-identical.
Direction predicted before running: RED. The mutation touched only
getHistoryRecord's door, soqueryHistory's case staying green is a control showing thepin is site-specific. No build was needed and none was done — these suites reach the
subject by an in-package relative import, and the ablation's red is itself the proof that
they read
src.Verification
Run on
e42073feb2(the regrade commit). The source tree is byte-identical toae50c7344, which the test and ablation rows were measured on:git diff --stat ae50c73449 HEADreports one file changed,.changeset/iso-from-valid-date-family-collapse.md.pnpm --filter @objectstack/metadata --filter @objectstack/metadata-protocol testVERDICT command-exit 0— metadata 804 passed / 52 files; metadata-protocol 2459 passed, 10 skipped (pre-existing) / 173 files… typecheckVERDICT command-exit 0tsc --noEmit --listFiles*.test.tspresent in their package's programdispatch-gates.mjs --commands --repo objectstack-ai/objectstackdispatch-gates.mjs --raneslint . --no-inline-config --format jsonCheck Changeset(incl. the LEVEL AXIS)node scripts/check-changeset-no-major.mjs --base fd5cff209 --event event.json, the declaration read from the live PR payloadyes, and it grades a package whosepackages/**/src/**it movesminoror above"grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over the 9 changed pathsThe full 59 were re-derived and re-run on
e42073feb2; the derivation is identical and 56 exit 0.⊘ NOT MEASURED (exit 3 = PREREQUISITE NOT MET, ⛔ not a pass) — all three want a
whole-repo build this round could not afford; each reads built output and none is sensitive
to this diff's semantics. Declared, and handed to CI:
check:dual-build-cjs-loads(58 packages withoutdist/),check:lean-entry-closure(
@objectstack/objectqlentry points absent),check:type-check-debt(19 dependencyclosures unbuilt — its own text forbids recording a number from here).
Exit codes were captured by redirect-then-
$?, never across a pipe.Semver:
minoron@objectstack/metadata,patchon@objectstack/metadata-protocolRegraded from the
patch×2 this PR opened with, on the seat ruling in5605145381, and the ruling is right. My original argument — the ladder's"repairing an implementation that silently violated its own already-published declared
type" row — holds for the four repaired sites and does not cover
DatabaseLoader.stat().The old
mtimeanswer for an unreadableupdated_atwasnew Date().toISOString(),which satisfies
MetadataStats.mtime'sz.string().datetime()— and the pre-existingpin in
database-loader-14078-invalid-date-total-arm.test.ts§A asserted exactly that(
MetadataStatsSchema.safeParse(stats).success, green onorigin/main). So that site isnot a repaired violation: it is one legal published answer replaced by another on a
published read verb ⇒
minor. I have no measurement pointing the other way, so there isnothing to push back with. The level is per package, so the four repaired sites ride along.
@objectstack/metadata-protocolstayspatch:rowToEventonly stops emitting valuesMetadataEventSchemaalready refused, andlistCommitsis byte-identical on all sevenprobe inputs — neither moves a legal published answer.
⛔ Still not breaking and still no ADR-0087 disposition: no declared type narrowed, no export
was added or removed (neither helper was ever exported), no envelope or accept set moved.
⛔ Not
major.which site carries the level, why the old answer was legal, and why the sibling package does
not take it.
Acceptance notes
docblocks read verbatim "When The shared canonical-ISO normaliser turns an Invalid
Datefrom a driver into a 500, whereString()served text #14078 rules, this helper collapses into the sharedspelling." That sentence does not exist anywhere in
packages/on this head —git grepand a newline-insensitivegrep -rzoPboth return zero, while the control#16422fires with 8 hits. The shared canonical-ISO normaliser turns an InvalidDatefrom a driver into a 500, whereString()served text #14078's landing had already rewritten them to name ThreeisoFromValidDatecopies still stand beside the now-total shared canonical-ISO spelling — the collapse their docblocks promise is a decision #14078 did not make #16422 asthe tracked decision. So acceptance item 6 was already half-discharged; what remained was
the forward-looking prose ("the consolidation is its own decision"), and every such
sentence is now rewritten to describe what this PR actually did.
sites plus two driver call sites; the fourth definition is ruled out of the family above.
packages/spec/**is untouched — the collapse implied no spec change. The declaredschemas were already right; the implementations were not.
.todo'd. The 10 skips inmetadata-protocol are pre-existing on
origin/main.noted, not filed:MetadataEventSchema.tsisz.string()while its one in-reporeader forwards to a
z.string().datetime(). Tightening it is a different card'scontract change, and this PR neither widens nor relies on the gap. Successor: whoever
next touches
packages/metadata-core/src/types.ts.Authored in session
session_01XTBcV7zZHmokdyQgXjbyEU(attribution kept in prose: on a body EDIT this surface appends its own footer block and rewrites the one sent).⛔ Draft, and staying draft.
needs:contract-reviewis on both carriers; no readyflip, no enqueue, no auto-merge, no merge.
Generated by Claude Code