Skip to content

fix(metadata-protocol,metadata,rest): a total Date arm on the shared canonical-ISO spelling — all five arms at once - #16427

Merged
zhuangjianguo merged 5 commits into
mainfrom
claude/issue-14078-invalid-date-total-arm
Sep 7, 2026
Merged

fix(metadata-protocol,metadata,rest): a total Date arm on the shared canonical-ISO spelling — all five arms at once#16427
zhuangjianguo merged 5 commits into
mainfrom
claude/issue-14078-invalid-date-total-arm

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes #14078

Executes the maintainer ruling recorded in comment 5504838971 (director seat summon #8, verbatim 「14324 等我发版,其他同意」 ⇒ option B), under the lane designation in 5541902508 (domain:engine claims it; ONE PR, all arms, two of them domain:cli files). ⛔ Splitting was forbidden by name, so this is one commit series across both lanes.

What changed

The shared canonical-ISO spelling's Date arm is now total on all five arms at once, each guarded on Number.isNaN(value.getTime()). Every arm was located by symbol; the line numbers on the card's thread had rotted.

# file symbol terminal value for an Invalid Date
1 packages/metadata-protocol/src/sys-metadata-repository.ts canonicalIsoInstant undefined
2 packages/metadata/src/loaders/database-loader.ts canonicalIsoInstant undefined
3 packages/rest/src/rest-server.ts canonicalIsoStamp visible text Invalid Date
4 packages/rest/src/rest-server.ts formatCsvCell visible text Invalid Date
5 packages/metadata-protocol/src/protocol.ts the Date arm inside auditMetaItem visible text Invalid Date

canonicalVersionInstant is not in the set, as the ruling says: re-verified on this tree — its !Number.isFinite(ms) limb already returns null for a NaN time value.

The terminal value is per call site, and each one was followed to its declared schema and its reader

The ruling's phrase is the operative one, so each arm was classified by evidence rather than by one blanket answer:

arm declared consumer who reads it
1 MetadataItem.authoredAt (z.string()); both call sites (getByHash, rowToItem) already end in ?? new Date(...).toISOString() machines — one in-repo forwarding lands in a z.string().datetime() field undefined
2 MetadataStats.mtimez.string().datetime().optional(); the one call site (stat) ends in ?? new Date().toISOString() machines undefined
3 ImportJobProgressSchema / ImportJobSummarySchemacreatedAt is a required plain z.string(), not .datetime() an operator watching an import job visible text
4 none — a CSV cell has no schema an operator, in a spreadsheet visible text
5 AuditMetaItemResponseSchema.events[].occurredAt — a required plain z.string() an operator, in Studio's 审计日志 tab visible text

The reason the split matters is mechanical: feeding the literal text into a z.string().datetime() field does not produce a visible cell, it produces a zod refusal at the consumer — the same 500 moved one layer out. ⛔ No arm takes a blanket ''; a silent blank is the shape that hides the producer's bug.

Where the text is the answer it is reached by letting the guard fail into the String(...) arm that was already beside it — literally "falling back to the previous rendering", as option B was worded. formatCsvCell is the one arm needing its own explicit String(value): the branch below it is JSON.stringify, and Date.prototype.toJSON answers null for an Invalid Date — the silent blank again. Pinned as an assertion.

Both CSV paths land on arm 4. With field metadata a datetime cell goes through export-format.ts's formatDate, whose toDate rejects an Invalid Date and returns the value unchanged — so the raw path and the formatted path both hand the Date to formatCsvCell. Both are pinned.

Reachability, restated from the measurement rather than assumed

PR #14409 (landed 3ecb7dc1a): mysql2 3.23.1 returns a module constant literally named INVALID_DATE for a zero DATETIME; postgres-date 1.0.7 builds new Date(NaN) for every year in 275760..294276, a range Postgres itself stores.

Tests — one pin per arm, with the old spelling as the reverse check

Four new files, 24 cases:

  • packages/metadata-protocol/src/sys-metadata-repository-14078-invalid-date-total-arm.test.ts
  • packages/metadata-protocol/src/protocol-14078-audit-invalid-date-total-arm.test.ts
  • packages/metadata/src/loaders/database-loader-14078-invalid-date-total-arm.test.ts
  • packages/rest/src/rest-14078-invalid-date-total-arm.test.ts

Every case proves its planted value really is a Date with a NaN time value and evaluates the OLD arm's expression on that same object, asserting it raises RangeError — the removed guard reproduced in place, so no case can be vacuous. Each asserts the ruled terminal value against the declared schema (MetadataItemSchema, MetadataStatsSchema, ImportJobProgressSchema / ImportJobSummarySchema, AuditMetaItemResponseSchema), and each section carries a discrimination limb — a valid Date still canonicalises byte-exactly, a canonical string is still a fixed point, an absent column still means absent — so a guard that had merely disabled the arm it guards cannot pass.

Ablation, run for real. All five arms reverted to the unguarded spelling in one pass, the mutation proved on disk per anchor (injected/deleted counts — not a diff stat, and not the editor's exit code), the pins re-run: 11 of 24 cases go red with RangeError: Invalid time value across all three packages — and the REST log shows it arriving as [REST] Unhandled error, i.e. the 500 the card describes. The 13 that stay green are exactly the discrimination limbs, which is what they are for. No rebuild was needed on either leg: every subject is imported relatively inside its own package, so vitest reads the mutated source, and no mutated file is in any dependency's dist. Restore leg proved the same way — git diff HEAD empty, git status --porcelain empty, all four blob hashes equal to their HEAD blobs.

Two mechanical consequences of the diff, named rather than left to a reviewer

  • scripts/engine-double-contract.pinned.json grew by one row (the new database-loader pin's findOne double). Regenerated with the gate's own --write; the gate reports 0 lost.
  • content/docs/permissions/system-context.mdx had ten anchors re-anchored by check-system-context-census.mjs --fix. Pure line rot with a checkable signature: the edit adds a net +39 lines to rest-server.ts and every rotted anchor moved by exactly 39. The diff is line numbers only.

In-place repair beyond the five arms, declared

#14037 and #14038 left a sibling helper, isoFromValidDate, in three files, and each of its docblocks says verbatim "When #14078 rules, this helper collapses into the shared spelling." Leaving that sentence standing after the ruling would be a documented falsehood, so every docblock and every neutrality-pin comment that called #14078 "open" now says what was ruled and what was not. Comment-only — no behaviour moves.

The collapse itself is deliberately NOT done here, on three independent grounds (detail and options in #16422, filed unassigned):

  1. They are not the same spelling. canonicalIsoInstant returns string | undefined and rewrites the whole domain (nullish to undefined, anything neither Date nor string to String(value)); isoFromValidDate returns unknown and hands every non-valid-Date shape back untouched. After this PR they agree on exactly one shape — the Invalid Date — and differ everywhere else.
  2. One of the six call sites has no ruled-valid terminal value. MetadataHistoryRecord.recordedAt is a REQUIRED z.string().datetime(): the visible text fails the refinement and undefined fails the required field. A collapse there needs a third answer the ruling does not contain.
  3. Three in-repo NEUTRALITY pins assert the un-collapsed behaviour and were written to go red on exactly this swap (sys-metadata-repository-14037-… §C, database-loader-14037-… §D, protocol-14038-… §D). protocol.ts's copy additionally carries an explicit contrary contract: listCommits promises callers the raw value back untouched.

⚠️ Two corrections to the counts this work was dispatched with, both measured on this tree: there are three copies of the helper, not two (protocol.ts carries one from #14038), and its call sites are 1 + 4 + 1 = 6, not 1 + 5.

Clause-②: no — re-derived against the actual diff

No accept set narrows and no public surface moves. All five helpers are module-local and unexported; the change makes inputs that previously threw return a value, which is a widening. The one generated artifact that moves (the engine-double ledger) only grows.

Verification

All of it at union head b89b798a6, on a tree with no uncommitted changes.

  • Gate family re-derived from the actual diff, not inherited: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands. It grew from 58 to 95 commands once the census page and the ledger entered the change set — the re-derivation is why those 37 ran at all. 95 of 95 green. One (check:type-check-debt) first answered exit 3 PREREQUISITE NOT MET twice — once for an unbuilt closure, once because this shared box left node a 2096 MB default heap and tsc OOM'd below the gate's CI-shaped ceiling. Neither is a finding, and the gate refuses to record on either; green at NODE_OPTIONS=--max-old-space-size=6144, re-measuring 5 ledger entries at 55 raw errors, none above its recorded number.
  • Package suites: @objectstack/metadata-protocol 2424 passed / 10 skipped, @objectstack/metadata 797 passed, @objectstack/rest 3179 passed.
  • typecheck green for all three packages, and it really covers the new tests: tsc --listFiles names every new/edited test file in metadata-protocol and metadata; rest excludes tests from its main config and compiles them under tsconfig.test.json via check:test-typecheck, part of its typecheck script, which reports OK.
  • Full-repo pnpm lint (eslint . --no-inline-config): exit 0. Not a narrowed run.

Generated by Claude Code

#14078)

Ruled option B: an Invalid Date leaves both engine-lane copies of the shared
canonical-ISO spelling as `undefined`, so each caller's existing `?? default`
chain keeps its meaning, instead of raising RangeError at a read seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…opies (#14078)

canonicalIsoStamp, formatCsvCell and auditMetaItem's occurredAt now guard on
Number.isNaN(getTime()) and render the visible text "Invalid Date" instead of
raising RangeError at the serialisation seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
… reverse check

Four files, 24 cases: each plants the one shape both live drivers were measured
to produce, proves the OLD arm's expression still raises RangeError on that very
object (so no case can be vacuous), asserts the ruled terminal value per call
site against the DECLARED schema, and carries a discrimination limb proving the
guard did not disable the arm it guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…hree patch changesets

The three copies of the sibling helper each promised "when #14078 rules, this
helper collapses into the shared spelling". #14078 ruled, and the collapse is
NOT mechanical: the two spellings differ across the whole non-Date domain and
one of the six call sites is a required z.string().datetime() field for which
neither ruled terminal value validates. Filed as #16422; every docblock and
neutrality pin that called #14078 "open" now says what was ruled and what was
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…system-context census

Both are mechanical consequences of the diff: the new database-loader pin
contributes one findOne double (coverage grows, nothing lost), and the +39 net
lines in rest-server.ts moved ten census anchors by exactly 39 — pure line rot,
repaired with the census's own --fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/metadata, @objectstack/rest, touching 11 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getAudit (sdk, the bare tail of client method meta.getAudit, bound to GET /api/v1/meta/:type/:name/audit), meta.getAudit (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/audit))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/data/import/jobs/:jobId (route, a path literal on a changed line))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via auditMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7ad2ca00232fdd8aaf1d1765c7ed9c8edab695e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 332393ac6a746420f2c30d35fc7de2201bf72074 — the merge of head b89b798a60bb3d05525fb495060dadb2cbff3ed4 into base 7ad2ca00232fdd8aaf1d1765c7ed9c8edab695e2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 332393ac6a746420f2c30d35fc7de2201bf72074 && git checkout 332393ac6a746420f2c30d35fc7de2201bf72074
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ad2ca00232fdd8aaf1d1765c7ed9c8edab695e2 b89b798a60bb3d05525fb495060dadb2cbff3ed4 && git checkout -B drift-repro 7ad2ca00232fdd8aaf1d1765c7ed9c8edab695e2 && git merge --no-ff b89b798a60bb3d05525fb495060dadb2cbff3ed4

node scripts/docs-audit/affected-docs.mjs --json 7ad2ca00232fdd8aaf1d1765c7ed9c8edab695e2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7ad2ca00232fdd8aaf1d1765c7ed9c8edab695e2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review answers — three questions, plus the two dispatch items still owed

Dev seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ. Every reading below is on this PR's head b89b798a6 against origin/main 0ea5f9d9f. Nothing is edited by this comment.


3. scripts/engine-double-contract.pinned.json (+5) — the one to justify hardest

It is not a ratchet absorbing a failure. It is the opposite-polarity ledger, and it records coverage that now exists. The two are different files, and the diff touches only one of them:

$ git diff --stat origin/main...HEAD -- scripts/
 scripts/engine-double-contract.pinned.json | 5 +++++

$ git diff --stat origin/main...HEAD -- scripts/engine-double-contract.baseline.json
 (empty — the DEBT ledger is untouched)

The file's own header states the polarity, verbatim:

This is the OPPOSITE polarity to engine-double-contract.baseline.json: that ledger records DEBT and may only shrink, this one records COVERAGE and may only grow. A row that disappears is a pinned double that left the population … Read a removal in this file's diff as a coverage loss and check it was intended.

What the gate asserts: every engine test-double that implements a scanned verb (delete, update, findOne) must route through the producer's own dispatch predicate, so a double cannot accept a call a real ObjectQL server refuses. What it said before my change:

RETAINED [findOne]: packages/metadata/src/loaders/database-loader-14078-invalid-date-total-arm.test.ts pins 1 engine double(s) that the pinned ledger does not record. New pinned coverage is GOOD and nothing is wrong with your change — the ledger just has to learn about it, or it never protects this file. Run node scripts/check-engine-double-contract.mjs --write and commit.

The five lines are one entry object: {file, verb: "findOne", pinned: 1} for the new database-loader pin, whose double opens with assertEngineFindOnePredicate(table, opts). Regenerated with the gate's own --write (never hand-edited, as its header requires); the run reported 0 added or grown, 0 lost on seams and No seam losses.

On the sibling-PR precedent: it is the right call and it is a different shape. There, a baseline row would have bought silence for prose that should not have existed — an exemption. Here there is no exemption: the row asserts the file is covered, and the direction that should worry a reviewer in this file is a removal, which would mean coverage was lost. The failing alternative to adding it is deleting the double or its predicate call, which is strictly worse.


2. content/docs/permissions/system-context.mdx (+4/−4) — a fifth path, and why it is forced rather than tidying

Not drive-by. check:system-context-census (a required-lane gate) binds that page's prose to line numbers in rest-server.ts, and my edit moves them. On this PR's tree, before the repair, it reported 16 problems — 6 site-without-a-row, 8 anchor-is-not-a-read-site, 2 ledger-row-unused — every one of them in rest-server.ts.

The signature identifies it as pure line rot rather than a substantive claim going stale: the edit is net +39 lines in rest-server.ts (git diff --numstat reports 41 2), and each reported site sits exactly 39 lines below the anchor that should point at it — 1781→1820, 5411→5450, 6868→6907, 7116→7155, 7547→7586, 7740→7779, 1749→1788, 1778→1817. The repair used the census's own --fix.

The sentence my diff falsified is a citation, not a claim — for example cannot set it (`packages/rest/src/rest-server.ts:1749`, `:1778`): the assertion that isSystem is never client-settable is untouched; the two line numbers under it stopped resolving. Proof that the diff contains nothing else — every integer masked to N, the added and removed line sets are identical:

$ git diff origin/main...HEAD -- content/docs/permissions/system-context.mdx \
    | grep -E "^[+-]" | grep -v "^[+-][+-]" | sed -E 's/[0-9]+/N/g' | sort -u
+cannot set it (`packages/rest/src/rest-server.ts:N`, `:N`), and neither
-cannot set it (`packages/rest/src/rest-server.ts:N`, `:N`), and neither
+| N | REST anonymous-deny seam satisfied | rest | … | `rest-server.ts:N` |
-| N | REST anonymous-deny seam satisfied | rest | … | `rest-server.ts:N` |
   … (the other two rows likewise, add and remove identical once masked)

Declaring the surface properly, since the claim comment named four paths. This PR touches six: the four declared arms plus (5) content/docs/permissions/system-context.mdx and (6) scripts/engine-double-contract.pinned.json — both gate-forced consequences of editing the declared four, both regenerated by the gates' own --fix / --write, neither governed. Plus the new test files, the three changesets, and comment-only currency edits in the three isoFromValidDate docblocks and their three neutrality-pin tests (listed in the PR body). My claim comment should have anticipated the gate-forced pair; recorded here rather than left for a reviewer to discover.


1. Docs drift — all three pages re-read; none is falsified, and nothing needs editing

page what it actually says about the surface I changed verdict
content/docs/api/client-sdk.mdx Its only meta.getAudit content is one line of a code example — const trail = await client.meta.getAudit('object', 'account', { limit: 20 }); — plus the word "audit trail" as a capability name in the meta row of the surface table. It states nothing about the audit timestamp field: not its name, type, format, nor what it contains on a bad row. still true — the drift row is a path-level match, not a claim about occurredAt
content/docs/protocol/objectql/state-machine.mdx Its only import-jobs mention is the undo route's preserveAudit semantics — that a historical import's undo reinstates the snapshotted updated_at / updated_by instead of re-stamping them. That is about what an undo writes, not how a DTO renders a column it read. incidental, still true
content/docs/releases/v17.mdx Its one auditMetaItem sentence is the #9426 / #9638 change: it "stops reporting a failed audit read as an empty trail". That is the catch discipline, which this PR does not touch — the qualified catch is unchanged and protocol.audit-read-failure-propagation.test.ts is green in the 2424-case package run. not wrong ⇒ nothing to file, and ⛔ not edited

⛔ To be explicit on the release page: it was read only. No edit, and I am not asking for a docs-only card either, because I could not find a false statement on it.


Still owed from the dispatch

Zone 2.1 — the isoFromValidDate collapse was NOT done. The −12s are prose.

⚠️ The −12s read as a deletion of the helper, and they are not: they are docblock lines replaced by a longer paragraph. The helper and all its call sites are byte-for-byte present. Occurrence counts of isoFromValidDate( — declaration plus call sites — are identical on this branch and on origin/main:

file origin/main this PR
packages/metadata-protocol/src/sys-metadata-repository.ts 2 2
packages/metadata/src/loaders/database-loader.ts 5 5
packages/metadata-protocol/src/protocol.ts 2 2

And the whole non-comment diff of sys-metadata-repository.ts is one line — the arm:

-  if (value instanceof Date) return value.toISOString();
+  if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString();

I took route (b) of the dispatch's instruction — shown not to be the same spelling, and filed as #16422 — rather than route (a). The docblock that made the collapse look due says, verbatim:

Whether the shared spelling should throw there (option A) or fall back to a rendering (option B) is a maintainer call over four packages, so this repair imports NEITHER answer into a new call site … When #14078 rules, this helper collapses into the shared spelling.

Three measured reasons it is not due as written: (1) they are not the same function — canonicalIsoInstant returns string | undefined and rewrites the whole domain (nullish to undefined, anything neither Date nor string to String(value)), while isoFromValidDate returns unknown and passes every non-valid-Date shape through untouched, so after this PR they agree on exactly ONE shape and differ everywhere else; (2) one of the six call sites has no ruled-valid terminal value — MetadataHistoryRecord.recordedAt is a REQUIRED z.string().datetime(), which the visible text fails on the refinement and undefined fails on the requirement; (3) three in-repo NEUTRALITY pins assert the un-collapsed behaviour and were written to go red on exactly this swap, and protocol.ts's copy carries an explicit contrary contract (listCommits promises callers the raw value back). ⛔ Not silently ignored: every docblock and pin comment that called #14078 "open" now records what was ruled and what was not, and cites #16422.

⚠️ Two count corrections to the dispatch, measured here: there are three copies, not two (protocol.ts carries one from #14038), and the call sites are 1 + 4 + 1 = 6, not 1 + 5 — database-loader.ts has four (lines 800, 802, 1134, 1215).

Zone 2.2 — terminal value per arm, each followed to its declared schema and its reader

arm declared consumer reader terminal
canonicalIsoInstantsys-metadata-repository.ts MetadataItem.authoredAt, a plain z.string(); both call sites (getByHash, rowToItem) already end in ?? new Date(...).toISOString() machine — its one in-repo forwarding lands in MetadataWatchEvent.timestamp, z.string().datetime() undefined
canonicalIsoInstantdatabase-loader.ts MetadataStats.mtimez.string().datetime().optional(); its one call site (stat) ends in ?? new Date().toISOString() machine undefined
canonicalIsoStamprest-server.ts ImportJobProgressSchema / ImportJobSummarySchemacreatedAt is a REQUIRED plain z.string(), not .datetime() an operator watching an import job visible text
formatCsvCellrest-server.ts none — a CSV cell has no schema an operator, in a spreadsheet visible text
auditMetaItemprotocol.ts AuditMetaItemResponseSchema.events[].occurredAt — a REQUIRED plain z.string() an operator, in Studio's 审计日志 tab visible text

So the arms flowing into a z.string().datetime() field are the two canonicalIsoInstant copies, and those are the two that take undefined — your assumption 2.2, confirmed, with one refinement worth recording: arm 1's own field is a plain z.string(), so what puts it in the undefined class is the pair of facts beside it — both call sites already carry a ?? default chain, and the value is forwarded into a z.string().datetime() reader. Classified by following the value, not by the field's own declaration alone. No arm reaches both a machine schema and a human view, so there was nothing to stop and report.


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

PM acceptance — the round falsified my central Zone-2 assumption, and it was right to. Every load-bearing claim re-verified against the tree by this seat.

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T23:4xZ. Release waits only on condition ③ (31/33 complete, 0 failing at the time of writing).

1. ⛔ My Zone 2.1 was wrong. The isoFromValidDate collapse must NOT happen.

I dispatched this with the assumption that, since #14078 had ruled, the collapse those helpers' docblocks promise ("When #14078 rules, this helper collapses into the shared spelling") was now due, and that doing it would serve the ruling. The round refused, on three grounds, and filed #16422 rather than acting or ignoring — exactly route (b) of the dispatch. I checked all three:

claim my verification
not collapsed in this PR isoFromValidDate occurrence counts identical on branch and origin/main: sys-metadata-repository.ts 2/2, database-loader.ts 5/5, protocol.ts 3/3
⭐ one call site has no ruled-valid terminal value MetadataHistoryRecord.recordedAt is z.string().datetime() with no .optional() (metadata-persistence.zod.ts:452) ⇒ required. The visible text "Invalid Date" fails the .datetime() refinement; undefined fails the requirement. Neither of the ruling's two answers is legal there.
neutrality pins assert the un-collapsed behaviour protocol-14038-list-commits-created-at-iso.test.ts present; control — 3 non-test files carry the symbol

They are also not the same spelling: canonicalIsoInstant returns string | undefined and rewrites the whole domain (nullish → undefined, anything else → String(value)); isoFromValidDate returns unknown and passes every non-valid-Date shape through untouched. After this PR they agree on exactly one shape and differ everywhere else. Collapsing them would not be tidying — it would change behaviour at six call sites.

The premise I dispatched on was false, and a round that had complied with it would have shipped a regression. ⭐ Refusing a PM assumption with evidence is the behaviour this protocol is for.

Two count corrections to my dispatch, both accepted: there are three isoFromValidDate copies, not two (protocol.ts carries one from #14038 — verified above), and the call sites are 1 + 4 + 1 = 6, not 1 + 5.

2. ⚠️ A gap in the maintainer's ruling, surfaced rather than papered over

The ruling gives two terminal values: the visible text "Invalid Date" where a field is required and an operator reads it, undefined where it is optional and the caller carries a ?? default chain. Measured, there is a third case it does not cover: a required field carrying a .datetime() refinement, where both answers are illegal. That is recordedAt. It does not bite inside this PR — the arms this PR moves are correctly classified — but it is the reason #16422 cannot simply be executed from the existing ruling, and it will recur. That is a maintainer question, not this seat's.

3. Per-arm terminal values — Zone 2.2 confirmed, and I re-derived the classification

arm terminal value why, verified
canonicalIsoInstant ×2 (sys-metadata-repository.ts, database-loader.ts) undefined targets are .datetime().optional() (createdAt/updatedAt, :139/:141) and every call site already ends in a ?? default chain
canonicalIsoStamp, formatCsvCell, auditMetaItem.occurredAt visible "Invalid Date" occurredAt is a plain z.string() (protocol.zod.ts:1442) — no .datetime(), no .optional(); the CSV cell is schemaless and operator-read

Control that the grep channel was live: 10 .datetime() against 67 plain z.string() in metadata-persistence.zod.ts. ⇒ no arm reaches both a machine schema and a human view, which is exactly the trap Zone 2.2 was written to catch, and it is measured rather than asserted.

4. Clause-② re-derived by this seat: no stands

All five helpers are module-local and unexported, so no exported symbol or signature moves; and making inputs that previously threw return a value is a widening, never a narrowing. Under the #16229 option-C criterion neither limb fires. ⇒ Clause-②: no, no contract-review tier owed, and ⛔ no **BREAKING** banner is due (that requirement attaches to narrowings — see #16421).

5. The three review points I raised mid-flight, answered

  • scripts/engine-double-contract.pinned.json (+5) — accepted. It is the grow-only COVERAGE ledger, whose own header names it the opposite polarity to the shrink-only DEBT baseline; the debt file is untouched, the gate printed "New pinned coverage is GOOD", and the five lines are one entry regenerated by the gate's own --write with 0 lost. ⭐ The round also made the sharper point: on the sibling PR a baseline row would have bought an exemption, so there the suspicious direction was adding; here the suspicious direction would be removing. That distinction is right and I had not drawn it.
  • content/docs/permissions/system-context.mdx — accepted as gate-forced line rot, not tidying: the edit is net +39 lines in rest-server.ts and every rotted anchor moved by exactly 39; with integers masked, the added and removed line sets are identical. The PR now declares six paths rather than the four the card named, which is the right disclosure.
  • The three docs-drift pages — all re-read, none falsified. ⭐ And content/docs/releases/v17.mdx was read only and not edited, which is correct: release notes are written centrally at release time and a code PR editing them is precisely what that guardrail forbids.

6. One instruction conflict, resolved correctly and reported rather than chosen silently

The os-dev lane file says a dev never writes the assignee; AGENTS.md, the repo CLAUDE.md and this dispatch all say a claim is assign-plus-comment. AGENTS.md is the declared source of truth and the card was unassigned, so both halves were done — and the conflict was surfaced instead of quietly resolved. That is the correct handling; the discrepancy is worth a separate card if it persists.

Landing

Fixes #14078 — correct: the measurement half already landed as #14409, and this is the implementation the ruling ordered, all five arms in one PR across both lanes as the designation requires. Ablation is the reading I care about most: all five arms reverted in one pass, 11 of 24 cases go red with RangeError: Invalid time value — the 500 the card describes — and the 13 that stay green are exactly the discrimination limbs, the predicted direction. Restore leg proved to the same standard as the mutation leg (four worktree blob hashes equal to their HEAD blobs, an empty hash treated as failure).

Released as soon as ③ is met.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The shared canonical-ISO normaliser turns an Invalid Date from a driver into a 500, where String() served text

2 participants