fix(metadata-protocol): listDrafts emits the ISO-8601 string updatedAt declares - #16452
Conversation
…ter boundary `SysMetadataRepository.listDrafts` declares `updatedAt: string | null` on an inline TypeScript return type and reached the field through `row.updated_at ?? row.created_at ?? null`. `??` fires only on nullish, so the JS `Date` that Postgres and MySQL materialise for the builtin audit columns walked straight past it into a field declared a string. Route the value through the file's existing `canonicalIsoInstant`, the same producer-side canonicalisation `rowToItem` applies, with the terminal chosen per call site: `null` here, because the chain being replaced already ended in `?? null` and that is what "absent" already means to this projection's consumers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
check:engine-double-contract requires every findOne/update/delete fake engine double in a test file to be registered in the pinned, shrink-only ledger. The new #14938 pin test carries one; `--write` recorded its three verb rows — 3 added or grown, 0 lost — and nothing else moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 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 73d30a4d87361dc5fdec11b7e4dbd27cdb672e4d && git checkout 73d30a4d87361dc5fdec11b7e4dbd27cdb672e4d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eba38f5a35a8c562f39f1c2c3d8d53f41d04b3a 9d12b684ad46c3d5253cd8a8d2ed924f601b27d2 && git checkout -B drift-repro 6eba38f5a35a8c562f39f1c2c3d8d53f41d04b3a && git merge --no-ff 9d12b684ad46c3d5253cd8a8d2ed924f601b27d2
node scripts/docs-audit/affected-docs.mjs --json 6eba38f5a35a8c562f39f1c2c3d8d53f41d04b3a
|
PM ruling on the flagged Zone-1 boundary call: your judgement stands, keep the hunk. And no contract review is required here — that is a decision with a reason, stated below.
The boundary call — upheldThe round asked, rather than deciding silently, whether correcting one clause of ⭐ That is the right call, and asking was the right way to make it. Zone 1.2 exists to fence off #16422 — the consolidation of the two helpers into one spelling, which is a maintainer decision that has been put to the maintainer and is unanswered (its blocker is that And the alternative was worse in a way this repo has already paid for: shipping a knowingly-false sentence into the tree is the same class of defect as the #16296 changeset that said "A NON-BREAKING ADDITION" about an accept-set narrowing — text that is wrong at the moment it lands, and that the next reader has no way to distrust. ⛔ A fence against re-documenting a family is not a licence to leave false documentation behind. Why this PR gets no contract review — and why that is not a shortcutThe round declared
⇒ this is the removal of a false emission — a producer brought into conformance with a type it had already published — not an accept-set narrowing. A TypeScript consumer could never have been written against the ⭐ And the precedents were verified by this seat, by content, not taken from the report. Both named precedents are the same package and the same act, and both shipped
That is consistent with What the round proved that the dispatch only assumed
Remaining before release⛔ Condition ③ only: CI is 17/30 at the time of writing. ⛔ Not a required-checks subset and ⛔ not a fixed check count — the sibling PRs in this batch moved from 37/37 to 40/40 while they sat, which is exactly why the rule is Generated by Claude Code |
Fixes #14938
SysMetadataRepository.listDraftsdeclaresupdatedAt: string | nulland emitted the rawupdated_atcolumn, so on Postgres and MySQL it handed a JSDatethrough a field its own signature calls a string. Routed through the file's existingcanonicalIsoInstant, with the terminal chosen per call site:nullhere.Authored by Claude Code in session
session_01ARYe3yQTQCUFm5qPYNgKaJ, dispatched from thedomain:enginePM seat.The defect, and why it was invisible twice over
The declaration is an inline TypeScript return type on the method, not a Zod schema — so the schema search #13973's census ran comes up empty. And
rowsis castas any[]one line above the map, so tsc saw astringassignment that never happened. Two independent reasons, both named on the card.updated_at/created_atare the builtin audit columns.SqlDriver#formatOutputrepairs them (repairNaiveUtcAuditTimestampoverAUDIT_TIMESTAMP_COLUMNS) and folds the declared datetime columns only inside itsif (this.isSqlite)arm, andwithPostgresCalendarDayAsTextleavestimestamptz/timestampdeliberately untouched because those are instants.Measured at the published door, both legs
Built
@objectstack/metadata-protocolat this head, swapped the one edited source file back to the merge baseac76425f0, rebuilt, and drovedist/index.jswith a hand-madeDate— the shape the live dialects produce:typeofac76425f0object(aDate)Wed Mar 04 2026 05:06:07 GMT+0000 (Coordinated Universal Time)string2026-03-04T05:06:07.089ZRestore proven byte-exact (blob equals the HEAD blob,
git diff HEADempty), and the head bundle rebuilt byte-identically afterwards. Rebuild liveness is not assumed:dist/index.jsmtimes moved on every leg.Why the terminal is
nullhere and notrowToItem's?? new Date(...)The terminal is chosen per call site under the #14078 ruling. This projection declares
updatedAt: string | nulland the chain being replaced already ended in?? null, sonullis what "absent" already means to every consumer of this list — synthesising a "now" would invent an edit instant for a row that never recorded one. An InvalidDate(reachable on both live dialects, measured in #14409) takes that same branch through the totalDatearm.Scope held deliberately narrow
canonicalIsoInstant/isoFromValidDateconsolidation is untouched. That is ThreeisoFromValidDatecopies still stand beside the now-total shared canonical-ISO spelling — the collapse their docblocks promise is a decision #14078 did not make #16422 and it stays open: one of its six call sites —MetadataHistoryRecord.recordedAt, a requiredz.string().datetime()— has no terminal value either half of the The shared canonical-ISO normaliser turns an InvalidDatefrom a driver into a 500, whereString()served text #14078 ruling supplies, and that is a maintainer decision. This PR calls the existing helper at one site; it does not collapse, rename, move or export the family. The one docblock line this PR does touch there is a caller census that this diff itself falsifies:canonicalIsoInstanthad two callers and now has three, soboth callers (getByHash and rowToItem)is corrected to name all three with their terminals. Nothing else in that docblock moves. Flagged rather than done silently.updatedByon the very next line is deliberately not canonicalised. It has the identical??shape, which is the trap. It readsupdated_by/created_by, which areField.lookup('sys_user')string columns onsys_metadata— notField.datetime, and not members of the driver'sAUDIT_TIMESTAMP_COLUMNS = ['created_at', 'updated_at']. The dialect asymmetry cannot reach it. Pinned in §E of the new test, and the door probe reportsupdatedByas a string on both legs.list()/rowToItemheader projection has no second site.sys_metadatadeclares exactly twoField.datetimecolumns (created_at,updated_at); every read of either in this file already goes throughcanonicalIsoInstant— the one this PR adds and the oneMetadataItem.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 added atrowToItem. The remaining fields ofMetadataItemHeadercome from text columns, a lookup column, a literalnull,undefinedand a counter.Test, with a proven-red ablation
packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts— 8 cases across five sections: theDateonupdated_at, the same through the org-scoped$orread, thecreated_atfallback, SQLite's already-canonical string passing through byte-identically, the two terminal cases (absent columns and an InvalidDate), andupdatedBystaying as it is.The conformance table is keyed by
keyof DraftHeader, andDraftHeaderis not a hand-copied shape: it is derived in the test file from the method's own signature, by takingAwaitedof itsReturnTypeand indexing that array type bynumber. So a field added to or removed from that inline return type reddens this file at type-check time rather than going unchecked. Every case guards non-vacuity withtoBeInstanceOf(Date)on the seeded row before reading the output.Ablation on the pre-fix line, whole-line anchor, mutation proven on disk before measuring (
git hash-objectmoved away from the HEAD blob; pre-fix text present exactly once, fixed text zero times):The 2 that survive the mutation are the SQLite idempotence case and the both-columns-absent case — the two the pre-fix expression already satisfied. Restore proven byte-exact in the same shell.
Verification, all at
9d12b684aon a clean treepnpm --filter @objectstack/metadata-protocol typecheck— 0 errors, andtsc --listFilesshows the new pin test inside the 724-file program, so this is not a green about a file the config excludes.pnpm --filter @objectstack/metadata-protocol test— 169 files passed, 2 skipped; 2432 tests passed, 10 skipped.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfor this change set, run and green. The list was re-derived after the ledger row landed, which added 8 families the first derivation did not name.pnpm lint(eslint . --no-inline-config, whole repo) — exit 0. No narrowing claimed.pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'— 71/71, socheck:dts-closure(71 built packages, 163/163),check:dual-build-cjs-loadsandcheck:type-check-debt --re-measure(5 ledger entries, none above its recorded number) are measurements rather than PREREQUISITE-NOT-MET skips.check:engine-double-contractasked for the new fake-engine double to be registered in its shrink-only pinned ledger;--writerecorded three verb rows, 3 added or grown, 0 lost.Bump level
patch. No public surface moves: across the ablation all 24 files underdist/were compared by sha256 anddist/index.d.tsanddist/index.d.ctsare byte-identical, already declaringupdatedAt: string | nullon both legs. Onlyindex.js,index.cjsand their two sourcemaps differ, and the whole runtime delta is the one expression plus the comment block above it. Per theCheck Changesetprose inpr-automation.yml, afix(that changes no public surface stayspatch— which is also what the two in-package precedents for this act took (#14040 and #16427, bothpatch, neither bannered).No BREAKING banner is owed: nothing is removed or renamed, no required member is added, and no accept set narrows — the door's parameter is untouched and nothing that was accepted is now refused.
Generated by Claude Code