fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999) - #14252

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso
Sep 1, 2026
Merged

fix(cli): os migrate duplicates reports every holder's createdAt as canonical ISO-8601 UTC on every dialect (#13999)#14252
os-justin merged 2 commits into
mainfrom
claude/issue-13999-duplicates-createdat-iso

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes#13999

DuplicateHolder.createdAt is declared string | null, and the holder mapper built it with String(row.created_at). created_at is a builtin audit column — not in datetimeFields, and SqlDriver#formatOutput repairs it only inside its if (this.isSqlite) arm — and the holder probe reads through the raw-SQL seam, so no presentation runs on this path at all. The dialect therefore decided what the operator saw:

Postgres / MySQL Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
SQLite 2026-08-30T10:19:25.947Z

One instant, two spellings, chosen by the dialect: the operator's local zone baked in, whole seconds instead of milliseconds, no Z, and not Date.parse-safe for anything consuming this command's JSON.

The fix, and where it deliberately does not land

Canonicalised at the mapper — a new canonicalHolderCreatedAt in packages/cli/src/commands/migrate/duplicates.ts, called from the one assignment site. The CLI is a leaf consumer with a declared string | null, so it is the side that owes the canonical spelling.

One correction the card could not carry

Ruling 1 cites the repo's existing correct form as packages/metadata-protocol/src/protocol.ts:7710-7715. That range has drifted since the card was authored. Resolved by deepening history and reading the file at b1b7d6088 (the tip at authoring time): the cited range is the occurredAt mapper, which today lives at packages/metadata-protocol/src/protocol.ts:8074-8079, byte-identical. That is the form followed here — the structural twin of this site, a leaf mapper turning a builtin audit timestamp into a declared string.

Two arms left exactly as they were

  • A holder whose object carries no created_at column still reports createdAt: null, through the probe's real withCreatedAt: false retry.
  • A Date carrying no time value keeps its verbatim rendering. toISOString()throws on one (RangeError: Invalid time value), and mysql2 hands back exactly that for a zero date. A non-instant has no canonical spelling; a defect that was a spelling in a read-only report must not become a crashed migration command. This is the one deviation from the cited form, and it follows the other precedent in the same producer file — canonicalVersionInstant guards the identical hazard with Number.isFinite and MAX_TIME_VALUE.

The p3 grade re-derived, since the fix shape rests on it

The card grades this p3 because createdAt here is reported, never compared. Re-measured on this branch: the identifier appears at exactly five lines in duplicates.ts:132 doc comment, :133 the declaration, :325 doc comment, :346 the query's AS created_at projection, :684 the assignment — and at no comparison and no sort. The only sort on holders keys on partition then id. No wrong record is chosen and nothing is written. The grade stands.

The test distinguishes the dialects

packages/cli/src/commands/migrate/duplicates.created-at-canonical.test.ts. Every existing pin on this command drives SQLite, which is the side that was already correct — so a SQLite-only test proves nothing about the defect, and this file exists to separate the two.

  • §A1 the Postgres/MySQL leg: a hand-built raw-SQL seam hands the holder probe a JS Date, which is not this file's claim to make but the fact pinned against live servers in the driver file above. No runner here hosts a Postgres or a MySQL.
  • §A2 the SQLite leg: a real better-sqlite3 database, the real probes, the real collector.
  • §A3 the two legs agree, and what they agree on re-parses to the instant it came from.
  • §A4 non-vacuity, measuring what the removed expression really produced.
  • §B the two arms above.

Both legs run with the process zone forced to Asia/Shanghai, so the canonical spelling §A1 asserts is produced while the process is demonstrably not at UTC.

Ablation — the pin really catches this defect

The implementation was committed first, then the mapper reverted to String(row.created_at) on disk (mutation confirmed by counting both the injected and the removed text: injected 1, removed 0; blob 3e42719 to 97624e5), the file re-run, then restored and proven byte-identical to the HEAD blob (git diff HEAD empty, hash back to 3e42719).

× §A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z
✓ §A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper
× §A3 the two dialects agree — the operator reads one document, not two
✓ §A4 non-vacuity ✓ §B1 null arm ✓ §B2 invalid-Date arm
Test Files 1 failed (1) · Tests 2 failed | 4 passed (6)

The SQLite leg staying green under the ablation is the point: it is the measured form of "a test exercising only SQLite pins the side that was already correct".

Verification

All at bb642f5486, the head of this branch.

  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/duplicatesTest Files 6 passed (6), Tests 35 passed (35), the whole duplicates family including the boot-heavy integration and null-seam suites.
  • pnpm --filter @objectstack/cli exec tsc --noEmit --listFiles — exit 0, no diagnostics. --listFiles used deliberately: both edited files are proven in the tsc program (1 hit each), so this is a measurement and not a green over source nothing read.
  • Gate union: all 35 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack were run, re-derived after the last edit and reconciled with comm in both directions — nothing derived went unrun, nothing run was underived. Exit codes captured before any pipe. 33 green; the two non-zero are both exit 3, PREREQUISITE NOT MET, which each gate's own text states is not a red and not a measurement: check-test-completeness.mjs grades a saved turbo run test log and none exists locally, and scripts/pm/check-half-states.mjs needs repo-scoped REST reads this container's egress refuses (GET /rate_limit 200 with 15000 left, GET /repos/... 403 with no rate-limit headers). Both are recorded as NOT MEASURED.
  • pnpm check:type-check-debt — the ratchet ran whole rather than narrowed: 27 ledger entries re-measured in 469.0s, 1217 raw tsc errors total, none above its recorded number, surplus none.
  • pnpm lint (eslint . --no-inline-config, the repo-wide scan) — run whole, exit 0, 112s. No narrowing claimed and none needed.
  • pnpm check:nul-bytes — OK over 7781 text files; the edited files additionally hand-scanned for raw control bytes, none found.

Generated by Claude Code

…O-8601 UTC (#13999)
`DuplicateHolder.createdAt` is declared `string | null`, but the mapper built it
with `String(row.created_at)`. `created_at` is a builtin audit column, so no
presentation runs on the holder probe's raw-SQL path: Postgres and MySQL
materialise a JS `Date` and the operator read a `Date.toString()` rendering with
their local zone baked in, whole seconds and no `Z`, while SQLite printed
canonical ISO-Z. One instant, two spellings, chosen by the dialect.
Canonicalised at the mapper, following the `occurredAt` form already in
packages/metadata-protocol/src/protocol.ts. No driver file is touched and no
tolerant fallback is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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

  • content/docs/releases/v17.mdx(via os migrate duplicates (command, read off packages/cli/src/commands/migrate/duplicates.ts))

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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16packageMentionDocs.

Which tree this was computed on

This run read content/docs from fced68788dde20f734d16209551e92d801be9b65 — the merge of head bb642f548685350eacec4fc20d9c124b1fa2ab8c into base 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16, 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 fced68788dde20f734d16209551e92d801be9b65 && git checkout fced68788dde20f734d16209551e92d801be9b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 bb642f548685350eacec4fc20d9c124b1fa2ab8c && git checkout -B drift-repro 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 && git merge --no-ff bb642f548685350eacec4fc20d9c124b1fa2ab8c
node scripts/docs-audit/affected-docs.mjs --json 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16

⚠️ 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 7d3b1b79c4fac1b49e96dbd0cfe41a1cf4002d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 16:37
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit d18beddSep 1, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-13999-duplicates-createdat-iso branch September 1, 2026 17:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os migrate duplicates reports each holder's createdAt as a Date.toString() spelling on Postgres/MySQL

2 participants

@os-justin@claude