You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
migrate-sys-notification-to-event writes String(row.created_at) into the new timestamp columns — on Postgres/MySQL that is a Date.toString() spelling, not ISO #13998
Found by the driver-materialisation consumer census on #13973 (class (c) — genuinely wrong on one side). That sweep is not addressed by this card and does not close it.
The site
packages/metadata/src/migrations/migrate-sys-notification-to-event.ts:103 and :131:
row comes from selectLegacyRows(driver) — a driver read of the legacy sys_notification table.
Why it is wrong on the production default driver
created_at is a builtin audit column: not in datetimeFields, and SqlDriver#formatOutput repairs it only inside if (this.isSqlite). Pinned in packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts — the live dialects hand it out as a JS Date, SQLite as canonical ISO-Z text.
So on Postgres and MySQL this migration reads a Date and writes
"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"
back into created_at and at on the new inbox and receipt rows. Unlike the read-side sites in this census, this one persists the wrong spelling — the migration is one-way, so whatever lands is what the platform carries afterwards.
Two failure shapes are possible depending on the target column's type and the write path's coercion, and which one occurs should be measured against a live server rather than reasoned:
the value is accepted and stored with milliseconds dropped and the migrating host's timezone baked in (Date.prototype.toString renders whole seconds in the process zone), so the migrated history is silently skewed and de-precisioned; or
the value is rejected — the trailing (China Standard Time) is not part of any dialect's timestamp grammar — and the migration throws or degrades mid-run.
Neither is visible today: this migration's tests drive SQLite/memory, where String(row.created_at) is the identity on already-canonical text.
Why this is not a ?? fallback
Per #13973's standing prohibition, the question is which side owes the canonical spelling:
A — canonicalise at the migration, which is also the only shape that repairs already-migrated deployments if any exist. The repo's existing correct form is one file family over, packages/metadata-protocol/src/protocol.ts:7710-7715.
B — normalise at the producer (one presented shape per dialect at the read door) — fixes every sibling site, but reverses a deliberate driver decision (withPostgresCalendarDayAsText) and is a maintainer call.
⚠️ Worth deciding first: whether any deployment has already run this migration against Postgres or MySQL. If so, the repair has a data half as well as a code half, and that changes its priority.
The carrier the enqueue gate's content limb reads is the card's claim comment, which belongs to the dispatching PM; this seat does not edit another actor's comment, so the line is declared here and reads as MISPLACED rather than ABSENT until the PM carries it across. Reasoning: this diff changes no Zod schema, no accept/reject boundary and no exported surface — canonicalTimestampText is module-private. It normalises the spelling of a value that was already being written.
{
"issue": 13998,
"status": "done",
"branch": "claude/issue-13998-migration-timestamp-canonicalisation",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14024",
"premise_still_valid": true,
"summary": "A2.1 ANSWERED FIRST, and it leaves the data half OPEN: the repo keeps no record that could say whether any deployment ran this migration against PG/MySQL, so it cannot be ruled out and NO backfill was written. Code half A delivered: one canonicaliser applied at both sites (:103 created_at, :131 read_at), matching the named reference form in metadata-protocol/src/protocol.ts; a Date becomes toISOString(), ISO text passes through, anything else keeps its previous String() rendering rather than having a unit guessed on a one-way write path. The real content is the pin: three cases in the existing test file drive a hand-made Date through the migration's read path under a forced process zone, which is what breaks the SQLite identity that kept every prior case green while the defect was live. They reuse the file's already-pinned engine doubles, so check:engine-double-contract's ledger is untouched. Draft PR carries 'Part of #13998', not a closing keyword, per triage.",
"tests": "All at 3fc4e3bf24 (branch head; the gate union was run on the final commit). BUILD: pnpm --filter '@objectstack/metadata^...' build - os-verify-lock VERDICT command-exit 0. UNIT: pnpm --filter @objectstack/metadata test - 'Test Files 38 passed (38)', 'Tests 628 passed (628)', VERDICT command-exit 0. WORKSPACE BUILD (prerequisite for two gates): turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' - 'Tasks: 70 successful, 70 total', VERDICT command-exit 0. ABLATION on the COMMITTED tree, mutation = both call sites reverted to String(...): proven on disk in both directions (injected String(row.created_at)/String(row.read_at) count 2, removed canonicalTimestampText(row. count 0) and by blob 1f9ef52ee8 to fb4534c3e5, asserted non-empty and asserted different before any measurement; result 'Tests 1 failed | 7 passed (8)' failing exactly on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch...' - the production spelling reproduced - while the other 7 cases stayed GREEN under the mutation, which demonstrates the SQLite identity rather than asserting it. NO REBUILD LEG APPLIES and none was fabricated: the pin imports './migrate-sys-notification-to-event.js' RELATIVELY, and packages/metadata/vitest.config.ts aliases only cross-package @objectstack/* specifiers, so vitest resolves SOURCE and dist/ is not on this path. RESTORE leg under trap ... EXIT INT TERM with absolute paths (REPO_ROOT from git rev-parse --show-toplevel), restored with 'git checkout HEAD -- ABSPATH' not a bare checkout, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8. ESLINT: a DECLARED narrowing, not a skip - run on the changed files only with --no-inline-config --format json, 0 errors 0 warnings, with all three evidences: (1) population read from ESLint's own config via --print-config, which resolves a real config for both .ts files (6 and 5 active rules) while ESLint itself reports the changeset as 'File ignored because no matching configuration was supplied'; (2) file count read from --format json, 3 result objects, 2 linted 1 ignored; (3) invariance for untouched files - --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records, measured with a positive control, that this repo 'never enables type-aware linting ... for ANY file', so each file's verdict depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file. TYPECHECK of the new test code, measured not assumed: packages/metadata has no typecheck script and sits in the TEST_DEBT ledger at 89 frozen errors, so the reading comes from check:type-check-debt --re-measure at this head, which reports 'surplus: none - every entry sits exactly at its measurement, so any new error is red' - zero headroom, so the new test file adds zero tsc errors.",
"gates": "34 commands harvested runnably via 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands' (both spellings, 15 pnpm + 15 node, plus the test-file convention kind), each exit code captured BEFORE any pipe by redirecting to a file. 33 exit 0. TWO hit exit 3 = PREREQUISITE NOT MET = NOT MEASURED, never a pass: 'pnpm check:dual-build-cjs-loads' (reads built output; 61 packages had no dist) and 'node scripts/check-test-completeness.mjs' (needs a saved test-run log; its own output states 'the local reading for this gate is NOT MEASURED' and 'It is not a red, and there is nothing here to fix'). dual-build-cjs-loads was then RE-RUN GREEN after the workspace build, as was check:type-check-debt. check-test-completeness stays NOT MEASURED - there is no local log to hand it. Green, quoting each gate's own verdict line rather than a bare $?: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:doc-authoring, check:dual-build-cjs-loads (post-build), check:durability-log-level, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage, check:type-check-debt (post-build), check:type-source-resolution, check:where-matcher, check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs, check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states, pm/release-rehearsal-clone --self-test.",
"a2_1_deployment_reading": "CANNOT BE RULED OUT, therefore the DATA HALF IS OPEN and no backfill was written. Four measurements, none of which can answer yes-or-no because the platform records nothing: (1) the migration has NO caller anywhere in the repo - no CLI command under packages/cli/src/commands/migrate/, no boot hook; the only references are the export in migrations/index.ts, the tests, seven package CHANGELOGs and the docs. (2) docs/handoff/adr-0030-notification-convergence.md heads the section 'Data migration (not auto-run)' and hands operators a manual cut-over sequence whose step 2 is 'Run migrateSysNotificationToEvent({ driver, data })'. (3) It is NOT in the sys_migration ledger: DATA_MIGRATION_FLAG_OBJECT's well-known ids in packages/spec/src/system/migration.zod.ts are only adr-0104-file-references and adr-0104-value-shapes, so no applied-migrations row, no verified_at, no last_run_at exists for it on any deployment. (4) It IS published - the API ships from @objectstack/metadata/migrations and the ADR-0030 change landed. What NARROWS the population is the finding filed as #14023: no driver in this repo defines .raw, so the documented call errors out at the guard before touching data. That leaves only an operator who passed a knex-like handle of their own - which the repo cannot see either way. Per triage this is a maintainer floor: a dev must not write an existing-data backfill, so this seat stopped and reports.",
"a2_2_failure_shape": "NOT MEASURED - and deliberately not replaced by a reasoned guess. No live PG or MySQL is reachable from this container: OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL (the two the live-dialect matrix testkit reads) are unset, and nothing is listening. The fix is safe under EITHER shape because it removes the input to both: the value written is canonical ISO text on every dialect, so there is nothing left to be accepted-and-skewed or rejected-mid-run. The half that needs no server IS measured, in the pin's control case: String(Date) drops the milliseconds (Date.parse of the spelling equals value.getTime() minus value.getMilliseconds()) and bakes the process zone (the fixture spells 'Sun Aug 30 2026 18:19:25 GMT+0800' under a forced Asia/Shanghai).",
"a2_3_read_at_verdict": "CONFIRMED, and the two columns diverge by DIFFERENT gates, as the card assumed - with one correction the card did not make. Correction first: this read path bypasses formatOutput ENTIRELY. SqlDriver#execute returns 'await builder' untouched (sql-driver.ts:7956), the same class of bypass the driver already names for aggregate and distinct at sql-driver.ts:3964-3965, so no arm of formatOutput runs on these rows at all. The per-column divergence still holds at the record read door and is why neither could be repaired upstream: created_at is a BUILTIN AUDIT column - it is in AUDIT_TIMESTAMP_COLUMNS = ['created_at','updated_at'] (sql-driver.ts:269), never in datetimeFields, and formatOutput repairs it only inside 'if (this.isSqlite)' via repairNaiveUtcAuditTimestamp. read_at is NEITHER: it is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime (datetimeFields is built from declared datetime columns, and grep for read_at across packages/platform-objects/src returns nothing today) and it is not an audit column, so no gate could ever reach it on any dialect. The fix covers both - both call sites go through the one canonicaliser, and the pin asserts inbox.created_at, receipt.created_at AND receipt.at, with the read stamp distinct from created_at so 'at' cannot pass by echoing it.",
"a2_4_sibling_migrations": "NO sibling in that directory has the same shape. The card's own expression, run verbatim - grep -rn 'String(row\\.(created_at|read_at))' packages/metadata/src/migrations/ - returns exactly the two lines in this file, which is also its own FIRING POSITIVE CONTROL: the expression demonstrably hits when the shape is present. Widened twice: (a) 'String\\([A-Za-z_.]*(created_at|updated_at|read_at|_at|At)\\b[^)]*\\)' over the same directory returns the same two lines, and the other four files there (drop-projection-tables.ts, migrate-env-id-to-project-id.ts, migrate-project-id-to-environment-id.ts, index.ts) contain no '_at' reference at all; (b) 'String\\((row|r|legacy|old)\\.[A-Za-z_]*_at\\b' across all of packages (excluding tests) returns 18 hits, of which the non-migration ones are the read-side sites already accounted for by the #13973 census family: objectql/src/engine.ts (7346, 7347, 7505, 7512, 7513), rest/src/rest-server.ts (520, 534, 535), platform-objects/src/system/migration-flag.ts (76-90), metadata-protocol/src/protocol.ts:7715 (the reference form itself), cli/src/commands/migrate/duplicates.ts:684, plus services/service-queue/src/db-queue-adapter.ts:262 where the coerced value feeds a lexicographic window comparison. None folded in; none is a migration in this directory.",
"mcp_calls": "3 - one targeted search_issues (dedup for the finding), one issue_write create, one create_pull_request. This report comment was posted over REST, not MCP. Channel: repo-scoped REST was probed green and used for every read (card body, comments, PR read-back). The REST SEARCH endpoint is NOT served to this seat ('This GitHub API path is not available: sessions are bound to their configured repositories'), so the dedup read switched to one targeted MCP search_issues - declared here. It returned 17 non-empty results, so it is self-evidencing and needed no control term.",
"open_questions": [
{
"question": "Has any deployment already run migrateSysNotificationToEvent against Postgres or MySQL - and therefore is an existing-data repair owed for rows this migration already wrote with a Date.toString() spelling? The repo cannot answer it (no ledger id, no caller, no telemetry), and per triage a backfill is a maintainer floor a dev must not write. Filing this as its own decision card is the ask.",
"options": [
"A - file a decision card asking the maintainer to confirm from operational knowledge whether the ADR-0030 cut-over was ever run on a live PG/MySQL deployment, and write a backfill only if the answer is yes",
"B - write a defensive idempotent repair pass now that rewrites any non-ISO created_at/at on sys_inbox_message and sys_notification_receipt, on the theory that it is a no-op where nothing ran",
"C - register this migration in the sys_migration ledger so the question becomes answerable for every future deployment, and treat the historical question as unanswerable"
],
"recommendation": "A, with C as a follow-up card. Real business need: B repairs a population nobody has shown exists - #14023 measures that the documented call path errors out before touching data on every driver this repo ships, so the only candidates are operators who hand-built a knex handle, and writing a data-mutating pass for a population of unknown and possibly zero size inverts the risk. Long-term soundness: C is the contract-first answer to why this was unanswerable at all - the platform has a migration ledger and this migration is simply not in it, which is the actual defect behind the question; A is what settles today's instance without guessing. AI-authored-metadata safety: B is the worst of the three - a speculative one-way write over production timestamp columns, authored without a confirmed target, is exactly the shape that should require a human decision rather than a dev's inference. Startup scope discipline: A costs one card and no code; B costs a data-mutating code path plus its own verification burden on a hypothesis. The four axes agree here, so there is no trade-off to surface - but the maintainer still owns the yes-or-no, because only he can see the deployments."
}
],
"out_of_scope_findings": [
"filed as #14023: every migration in packages/metadata/src/migrations/ requires driver.raw(...), a method NO driver in this repo defines - SqlDriver, MemoryDriver, MongoDriver and the Turso transport all expose execute(), and IDataDriver declares execute?() and no raw. Measured with a firing positive control on the same expression (the raw-method sweep returns only an unrelated HTTP harness; the identical execute sweep returns all four drivers). So the cut-over step the ADR-0030 handoff doc gives operators verbatim returns {status:'error', migrated:0} and does nothing. The repo already has the correct dual-surface resolver one package over, in metadata-protocol/src/migrations/partial-index-probe.ts:94 and seed-tenancy-backfill.ts:347. Not folded in: different defect class from this card's timestamp canonicalisation, so it fails the bounded-in-place test on clause 1. Deduped against the backlog before filing."
]
}
Clause ② — carried onto the card's carrier by the PM, and the data half stays OPEN
domain:engine lane PM, session session_01F3jdziLbAPGeceVNmSox5L.
Clause-②: no
The seat declared this in its report and correctly noted it could not place it: the gate's declaration limb reads the claim comment, which is mine, and ⛔ a seat does not edit another actor's comment. It said so instead of leaving the line silently misplaced — which is the right call, and the same shape #13914 was filed for. Carrying it across here:
Path limb — does not fire. No Zod schema, no ledger, nothing under packages/spec/src/**.
Content limb — does not fire. No accept/reject boundary moves and no exported surface changes; canonicalTimestampText is module-private. The diff normalises the spelling of a value that was already being written.
⛔ The code half is delivered. The DATA half is open, and it is not the seat's to close.
A2.1 was answered before any code, as required, and the answer is cannot be ruled out:
Reading
Result
Callers of migrateSysNotificationToEvent in this repo
none — no CLI command, no boot hook; only the export, its tests, changelogs and docs
The operator path
docs/handoff/adr-0030-notification-convergence.md, section headed "Data migration (not auto-run)", step 2 = run it by hand
sys_migration ledger
⚠️not registered — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes, so no applied-migrations row, no verified_at, no last_run_at exists for it on any deployment
Published?
yes — ships from @objectstack/metadata/migrations, and ADR-0030 landed
⇒ The platform records nothing that could answer the question, which is itself the more interesting fact. ⛔ Per triage, an existing-data backfill is a maintainer floor — the seat stopped and reported rather than writing one. Filed for the maintainer as a decision card; ⛔ this card is not closed by the code PR, which correctly says Part of #13998.
⭐ What narrows the population dramatically — #14023: every migration in that directory calls driver.raw(...), and no driver in this repo defines raw. SqlDriver, MemoryDriver, MongoDriver and the Turso transport all expose execute(); IDataDriver declares execute?() and no raw. Measured with a firing positive control on the same expression. ⇒ The cut-over step the handoff doc gives operators verbatim returns {status:'error', migrated:0} and does nothing. The only remaining candidates are operators who hand-built a knex-like handle — a population of unknown and possibly zero size.
⭐ One correction the seat made that the card did not
A2.3 confirmed both columns diverge, but not by the mechanism the card assumed: this read path bypasses formatOutputentirely — SqlDriver#execute returns await builder untouched (sql-driver.ts:7956), the same bypass class the driver already names for aggregate and distinct. ⇒ No arm of formatOutput runs on these rows at all. And read_at is neither a builtin audit column nor a declared Field.datetime (ADR-0030 removed it from the object), so no gate could ever have reached it on any dialect.
⚠️ That is a third bypass of the authority #13973's census named, alongside the aggregate/distinct one. Recorded on the census.
Activity
claude commentedon Sep 1, 2026
os-dev-report
Clause-②: no
The carrier the enqueue gate's content limb reads is the card's claim comment, which belongs to the dispatching PM; this seat does not edit another actor's comment, so the line is declared here and reads as MISPLACED rather than ABSENT until the PM carries it across. Reasoning: this diff changes no Zod schema, no accept/reject boundary and no exported surface —
canonicalTimestampTextis module-private. It normalises the spelling of a value that was already being written.{ "issue": 13998, "status": "done", "branch": "claude/issue-13998-migration-timestamp-canonicalisation", "pr": "https://github.com/objectstack-ai/objectstack/pull/14024", "premise_still_valid": true, "summary": "A2.1 ANSWERED FIRST, and it leaves the data half OPEN: the repo keeps no record that could say whether any deployment ran this migration against PG/MySQL, so it cannot be ruled out and NO backfill was written. Code half A delivered: one canonicaliser applied at both sites (:103 created_at, :131 read_at), matching the named reference form in metadata-protocol/src/protocol.ts; a Date becomes toISOString(), ISO text passes through, anything else keeps its previous String() rendering rather than having a unit guessed on a one-way write path. The real content is the pin: three cases in the existing test file drive a hand-made Date through the migration's read path under a forced process zone, which is what breaks the SQLite identity that kept every prior case green while the defect was live. They reuse the file's already-pinned engine doubles, so check:engine-double-contract's ledger is untouched. Draft PR carries 'Part of #13998', not a closing keyword, per triage.", "tests": "All at 3fc4e3bf24 (branch head; the gate union was run on the final commit). BUILD: pnpm --filter '@objectstack/metadata^...' build - os-verify-lock VERDICT command-exit 0. UNIT: pnpm --filter @objectstack/metadata test - 'Test Files 38 passed (38)', 'Tests 628 passed (628)', VERDICT command-exit 0. WORKSPACE BUILD (prerequisite for two gates): turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' - 'Tasks: 70 successful, 70 total', VERDICT command-exit 0. ABLATION on the COMMITTED tree, mutation = both call sites reverted to String(...): proven on disk in both directions (injected String(row.created_at)/String(row.read_at) count 2, removed canonicalTimestampText(row. count 0) and by blob 1f9ef52ee8 to fb4534c3e5, asserted non-empty and asserted different before any measurement; result 'Tests 1 failed | 7 passed (8)' failing exactly on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch...' - the production spelling reproduced - while the other 7 cases stayed GREEN under the mutation, which demonstrates the SQLite identity rather than asserting it. NO REBUILD LEG APPLIES and none was fabricated: the pin imports './migrate-sys-notification-to-event.js' RELATIVELY, and packages/metadata/vitest.config.ts aliases only cross-package @objectstack/* specifiers, so vitest resolves SOURCE and dist/ is not on this path. RESTORE leg under trap ... EXIT INT TERM with absolute paths (REPO_ROOT from git rev-parse --show-toplevel), restored with 'git checkout HEAD -- ABSPATH' not a bare checkout, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8. ESLINT: a DECLARED narrowing, not a skip - run on the changed files only with --no-inline-config --format json, 0 errors 0 warnings, with all three evidences: (1) population read from ESLint's own config via --print-config, which resolves a real config for both .ts files (6 and 5 active rules) while ESLint itself reports the changeset as 'File ignored because no matching configuration was supplied'; (2) file count read from --format json, 3 result objects, 2 linted 1 ignored; (3) invariance for untouched files - --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records, measured with a positive control, that this repo 'never enables type-aware linting ... for ANY file', so each file's verdict depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file. TYPECHECK of the new test code, measured not assumed: packages/metadata has no typecheck script and sits in the TEST_DEBT ledger at 89 frozen errors, so the reading comes from check:type-check-debt --re-measure at this head, which reports 'surplus: none - every entry sits exactly at its measurement, so any new error is red' - zero headroom, so the new test file adds zero tsc errors.", "gates": "34 commands harvested runnably via 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands' (both spellings, 15 pnpm + 15 node, plus the test-file convention kind), each exit code captured BEFORE any pipe by redirecting to a file. 33 exit 0. TWO hit exit 3 = PREREQUISITE NOT MET = NOT MEASURED, never a pass: 'pnpm check:dual-build-cjs-loads' (reads built output; 61 packages had no dist) and 'node scripts/check-test-completeness.mjs' (needs a saved test-run log; its own output states 'the local reading for this gate is NOT MEASURED' and 'It is not a red, and there is nothing here to fix'). dual-build-cjs-loads was then RE-RUN GREEN after the workspace build, as was check:type-check-debt. check-test-completeness stays NOT MEASURED - there is no local log to hand it. Green, quoting each gate's own verdict line rather than a bare $?: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:doc-authoring, check:dual-build-cjs-loads (post-build), check:durability-log-level, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage, check:type-check-debt (post-build), check:type-source-resolution, check:where-matcher, check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs, check-empty-changeset, check-keyed-text-bounds, check-plugin-teardown-shape, check-shard-attestation, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states, pm/release-rehearsal-clone --self-test.", "a2_1_deployment_reading": "CANNOT BE RULED OUT, therefore the DATA HALF IS OPEN and no backfill was written. Four measurements, none of which can answer yes-or-no because the platform records nothing: (1) the migration has NO caller anywhere in the repo - no CLI command under packages/cli/src/commands/migrate/, no boot hook; the only references are the export in migrations/index.ts, the tests, seven package CHANGELOGs and the docs. (2) docs/handoff/adr-0030-notification-convergence.md heads the section 'Data migration (not auto-run)' and hands operators a manual cut-over sequence whose step 2 is 'Run migrateSysNotificationToEvent({ driver, data })'. (3) It is NOT in the sys_migration ledger: DATA_MIGRATION_FLAG_OBJECT's well-known ids in packages/spec/src/system/migration.zod.ts are only adr-0104-file-references and adr-0104-value-shapes, so no applied-migrations row, no verified_at, no last_run_at exists for it on any deployment. (4) It IS published - the API ships from @objectstack/metadata/migrations and the ADR-0030 change landed. What NARROWS the population is the finding filed as #14023: no driver in this repo defines .raw, so the documented call errors out at the guard before touching data. That leaves only an operator who passed a knex-like handle of their own - which the repo cannot see either way. Per triage this is a maintainer floor: a dev must not write an existing-data backfill, so this seat stopped and reports.", "a2_2_failure_shape": "NOT MEASURED - and deliberately not replaced by a reasoned guess. No live PG or MySQL is reachable from this container: OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL (the two the live-dialect matrix testkit reads) are unset, and nothing is listening. The fix is safe under EITHER shape because it removes the input to both: the value written is canonical ISO text on every dialect, so there is nothing left to be accepted-and-skewed or rejected-mid-run. The half that needs no server IS measured, in the pin's control case: String(Date) drops the milliseconds (Date.parse of the spelling equals value.getTime() minus value.getMilliseconds()) and bakes the process zone (the fixture spells 'Sun Aug 30 2026 18:19:25 GMT+0800' under a forced Asia/Shanghai).", "a2_3_read_at_verdict": "CONFIRMED, and the two columns diverge by DIFFERENT gates, as the card assumed - with one correction the card did not make. Correction first: this read path bypasses formatOutput ENTIRELY. SqlDriver#execute returns 'await builder' untouched (sql-driver.ts:7956), the same class of bypass the driver already names for aggregate and distinct at sql-driver.ts:3964-3965, so no arm of formatOutput runs on these rows at all. The per-column divergence still holds at the record read door and is why neither could be repaired upstream: created_at is a BUILTIN AUDIT column - it is in AUDIT_TIMESTAMP_COLUMNS = ['created_at','updated_at'] (sql-driver.ts:269), never in datetimeFields, and formatOutput repairs it only inside 'if (this.isSqlite)' via repairNaiveUtcAuditTimestamp. read_at is NEITHER: it is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime (datetimeFields is built from declared datetime columns, and grep for read_at across packages/platform-objects/src returns nothing today) and it is not an audit column, so no gate could ever reach it on any dialect. The fix covers both - both call sites go through the one canonicaliser, and the pin asserts inbox.created_at, receipt.created_at AND receipt.at, with the read stamp distinct from created_at so 'at' cannot pass by echoing it.", "a2_4_sibling_migrations": "NO sibling in that directory has the same shape. The card's own expression, run verbatim - grep -rn 'String(row\\.(created_at|read_at))' packages/metadata/src/migrations/ - returns exactly the two lines in this file, which is also its own FIRING POSITIVE CONTROL: the expression demonstrably hits when the shape is present. Widened twice: (a) 'String\\([A-Za-z_.]*(created_at|updated_at|read_at|_at|At)\\b[^)]*\\)' over the same directory returns the same two lines, and the other four files there (drop-projection-tables.ts, migrate-env-id-to-project-id.ts, migrate-project-id-to-environment-id.ts, index.ts) contain no '_at' reference at all; (b) 'String\\((row|r|legacy|old)\\.[A-Za-z_]*_at\\b' across all of packages (excluding tests) returns 18 hits, of which the non-migration ones are the read-side sites already accounted for by the #13973 census family: objectql/src/engine.ts (7346, 7347, 7505, 7512, 7513), rest/src/rest-server.ts (520, 534, 535), platform-objects/src/system/migration-flag.ts (76-90), metadata-protocol/src/protocol.ts:7715 (the reference form itself), cli/src/commands/migrate/duplicates.ts:684, plus services/service-queue/src/db-queue-adapter.ts:262 where the coerced value feeds a lexicographic window comparison. None folded in; none is a migration in this directory.", "mcp_calls": "3 - one targeted search_issues (dedup for the finding), one issue_write create, one create_pull_request. This report comment was posted over REST, not MCP. Channel: repo-scoped REST was probed green and used for every read (card body, comments, PR read-back). The REST SEARCH endpoint is NOT served to this seat ('This GitHub API path is not available: sessions are bound to their configured repositories'), so the dedup read switched to one targeted MCP search_issues - declared here. It returned 17 non-empty results, so it is self-evidencing and needed no control term.", "open_questions": [ { "question": "Has any deployment already run migrateSysNotificationToEvent against Postgres or MySQL - and therefore is an existing-data repair owed for rows this migration already wrote with a Date.toString() spelling? The repo cannot answer it (no ledger id, no caller, no telemetry), and per triage a backfill is a maintainer floor a dev must not write. Filing this as its own decision card is the ask.", "options": [ "A - file a decision card asking the maintainer to confirm from operational knowledge whether the ADR-0030 cut-over was ever run on a live PG/MySQL deployment, and write a backfill only if the answer is yes", "B - write a defensive idempotent repair pass now that rewrites any non-ISO created_at/at on sys_inbox_message and sys_notification_receipt, on the theory that it is a no-op where nothing ran", "C - register this migration in the sys_migration ledger so the question becomes answerable for every future deployment, and treat the historical question as unanswerable" ], "recommendation": "A, with C as a follow-up card. Real business need: B repairs a population nobody has shown exists - #14023 measures that the documented call path errors out before touching data on every driver this repo ships, so the only candidates are operators who hand-built a knex handle, and writing a data-mutating pass for a population of unknown and possibly zero size inverts the risk. Long-term soundness: C is the contract-first answer to why this was unanswerable at all - the platform has a migration ledger and this migration is simply not in it, which is the actual defect behind the question; A is what settles today's instance without guessing. AI-authored-metadata safety: B is the worst of the three - a speculative one-way write over production timestamp columns, authored without a confirmed target, is exactly the shape that should require a human decision rather than a dev's inference. Startup scope discipline: A costs one card and no code; B costs a data-mutating code path plus its own verification burden on a hypothesis. The four axes agree here, so there is no trade-off to surface - but the maintainer still owns the yes-or-no, because only he can see the deployments." } ], "out_of_scope_findings": [ "filed as #14023: every migration in packages/metadata/src/migrations/ requires driver.raw(...), a method NO driver in this repo defines - SqlDriver, MemoryDriver, MongoDriver and the Turso transport all expose execute(), and IDataDriver declares execute?() and no raw. Measured with a firing positive control on the same expression (the raw-method sweep returns only an unrelated HTTP harness; the identical execute sweep returns all four drivers). So the cut-over step the ADR-0030 handoff doc gives operators verbatim returns {status:'error', migrated:0} and does nothing. The repo already has the correct dual-surface resolver one package over, in metadata-protocol/src/migrations/partial-index-probe.ts:94 and seed-tenancy-backfill.ts:347. Not folded in: different defect class from this card's timestamp canonicalisation, so it fails the bounded-in-place test on clause 1. Deduped against the backlog before filing." ] }Generated by Claude Code
Generated by Claude Code
zhuangjianguo commentedon Sep 1, 2026
Clause ② — carried onto the card's carrier by the PM, and the data half stays OPEN
domain:enginelane PM, sessionsession_01F3jdziLbAPGeceVNmSox5L.Clause-②: no
The seat declared this in its report and correctly noted it could not place it: the gate's declaration limb reads the claim comment, which is mine, and ⛔ a seat does not edit another actor's comment. It said so instead of leaving the line silently misplaced — which is the right call, and the same shape #13914 was filed for. Carrying it across here:
packages/spec/src/**.canonicalTimestampTextis module-private. The diff normalises the spelling of a value that was already being written.⛔ The code half is delivered. The DATA half is open, and it is not the seat's to close.
A2.1 was answered before any code, as required, and the answer is cannot be ruled out:
migrateSysNotificationToEventin this repodocs/handoff/adr-0030-notification-convergence.md, section headed "Data migration (not auto-run)", step 2 = run it by handsys_migrationledgeradr-0104-file-referencesandadr-0104-value-shapes, so no applied-migrations row, noverified_at, nolast_run_atexists for it on any deployment@objectstack/metadata/migrations, and ADR-0030 landed⇒ The platform records nothing that could answer the question, which is itself the more interesting fact. ⛔ Per triage, an existing-data backfill is a maintainer floor — the seat stopped and reported rather than writing one. Filed for the maintainer as a decision card; ⛔ this card is not closed by the code PR, which correctly says
Part of #13998.⭐ What narrows the population dramatically — #14023: every migration in that directory calls
driver.raw(...), and no driver in this repo definesraw. SqlDriver, MemoryDriver, MongoDriver and the Turso transport all exposeexecute();IDataDriverdeclaresexecute?()and noraw. Measured with a firing positive control on the same expression. ⇒ The cut-over step the handoff doc gives operators verbatim returns{status:'error', migrated:0}and does nothing. The only remaining candidates are operators who hand-built a knex-like handle — a population of unknown and possibly zero size.⭐ One correction the seat made that the card did not
A2.3 confirmed both columns diverge, but not by the mechanism the card assumed: this read path bypasses
formatOutputentirely —SqlDriver#executereturnsawait builderuntouched (sql-driver.ts:7956), the same bypass class the driver already names foraggregateanddistinct. ⇒ No arm offormatOutputruns on these rows at all. Andread_atis neither a builtin audit column nor a declaredField.datetime(ADR-0030 removed it from the object), so no gate could ever have reached it on any dialect.aggregate/distinctone. Recorded on the census.Generated by Claude Code
zhuangjianguo commentedon Sep 1, 2026
重新界定范围 —— 代码半边已落地。⛔ 本卡剩下的是数据半边,它是维护者裁决,不是 dev 卡
domain:engine车道 PM,sessionsession_01F3jdziLbAPGeceVNmSox5L。PR #14024 已落地,按内容验于
origin/main=c9d4de3d18(⛔ 未看merged布尔)。它按分诊要求用的是Part of #13998而不是 closing keyword ⇒ 本卡正确地保持 open。⇒ 卡态调整:摘
pm:dispatched、取消指派、改挂needs-user-decision。⛔ 不要再向本卡派发 dev 席位 —— 剩下的工作没有一行是 dev 该写的。✅ 已完成(⛔ 不要重做)
canonicalTimestampText在两个写入点(created_at与at)就位,形状与metadata-protocol的既有先例一致:Date→toISOString(),ISO 文本直通,其余保持原有String()渲染而不去猜单位(一条单向写盘路径上,猜是最坏的选项)。⭐ 真正的内容是那条 pin:三个用例在强制的进程时区下把手工构造的
Date驱过迁移的读路径 —— 这正是打破「SQLite 恒等」的做法,而那个恒等就是缺陷活着时每个既有用例都绿的原因。消融时其余 7 个用例保持绿,⇒ 那不是断言,是演示了 SQLite 恒等的存在。⛔ 剩下的:存量数据修复,以及一个平台本身答不出的问题
A2.1 在动手前就被回答了,答案是 cannot be ruled out:
migrateSysNotificationToEvent的调用方docs/handoff/adr-0030-notification-convergence.md,小节标题 "Data migration (not auto-run)",第 2 步是手工运行sys_migration台账adr-0104-file-references与adr-0104-value-shapes⇒ 任何部署上都不存在它的 applied 记录、verified_at或last_run_at@objectstack/metadata/migrations出口⇒ ⭐ 平台记录不下任何能回答这个问题的东西 —— 这本身才是更值得注意的事实。
三个选项,本席推荐 A,⛔ 明确不推荐 B
sys_migration台账,让这个问题对未来每个部署可答;历史那一问按不可答处理。为什么不是 B:⭐ #14023 把人口收窄到可能为零 —— 本仓没有任何驱动定义
driver.raw(带发火正对照测过),⇒ 运维手册逐字给出的那条切换命令,在本仓出货的每个驱动上都返回{status:'error', migrated:0}而什么都不做。剩下的候选只有「自己手搓了一个 knex 式句柄的运维」。⇒ 为一个规模未知、可能为零的人口写一条会改数据的单向写路径,是把风险倒过来担。 一次在生产时间戳列上的推测性单向写入、在没有确认目标的情况下由 AI 写出来 —— 这正是应当由人来裁的形状。为什么 C 是配套而非替代:⭐ C 才是「这个问题为什么一开始就不可答」的契约级答案 —— 平台有迁移台账,而这条迁移根本不在里面。那是问题背后的真正缺陷。A 解决今天这一例,C 让它不再复发。
⇒ ⛔ 本卡在数据半边被裁清之前不关闭。
Generated by Claude Code
huangyiirene commentedon Sep 1, 2026
裁决:数据半边关闭 —— 「没跑过」(维护者 2026-09-01,总监批 #21);本卡关闭 completed
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t维护者逐字:「13998 没跑过」—— ADR-0030 通知切换从未在真实 Postgres/MySQL 部署上运行过。这来自只有维护者持有的运维知识,与 #14023 的收窄读数(每个出货驱动上文档给的命令都在
driver.raw守卫处报错、零行迁移)相互印证。⇒ 不存在带着
Date.toString()拼写的存量行,不欠 backfill。选项 B(推测性防御修复 pass)随之失去对象,按裁决排除。关卡依据
migrateSysNotificationToEventwrites #14024 → mainc9d4de3d18(canonicaliser 双写入点就位,pin 在强制时区下打破 SQLite 恒等);sys_migration台账,让此类问题对未来部署可答)获批派发 —— 由姊妹决策卡 [decision] Was the ADR-0030 notification cut-over ever run against a live Postgres/MySQL deployment — and should the migration be registered in the sys_migration ledger so the question stops being unanswerable? #14025 承载,已转pm:queue,⛔ 不在本卡重复。状态转移(同笔)
关闭 completed;摘
needs-user-decision;bugpriority:p1domain:engine留卡。Generated by Claude Code