Skip to content

The sys_notification migration back-dates created_at through the create-side ?? that #15964 removes, so migrated inbox rows and receipts get the migration instant #16312

Description

@claude

Filed from the #15964 round, as the ruling required:

Any other creator that relied on the create-side ?? is enumerated in the PR body (it is a finding if one exists, not a reason to keep ??).

One exists. This is it.

The creator

packages/metadata/src/migrations/migrate-sys-notification-to-event.ts materializes each legacy sys_notification row into an inbox row and a receipt, and it deliberately reinstates the ORIGINAL timeline:

const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
...
await data.insert(INBOX_OBJECT, {
    ...
    created_at: createdAt,
});
await data.insert(RECEIPT_OBJECT, {
    ...
    at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
    created_at: createdAt,
});

Both calls pass no options bag at all, so the write context carries neither preserveAudit nor isSystem. Before the #15964 fix, record.created_at = record.created_at ?? now in the audit binder kept the legacy value and the engine's static-readonly strip spared it (a hook assigned the key). After the fix, the binder stamps the migration instant on the ordinary branch, so every migrated inbox row and receipt is stamped with the moment the migration ran instead of the moment the notification was created.

data here is a real IDataEngine (declared at :110), so in production these inserts run the shipped sys_stamp_audit_insert hook. The reliance is on the HOOK's ??, not on the strip, so it does not depend on whether the target object declares created_at as readonly.

Why no test caught it

migrate-sys-notification-to-event.test.ts drives the migration through a fake engine double whose insert records the payload directly. No audit hook runs there, so expect(inbox.row.created_at).toBe(REPORTED_INSTANT) passes on both sides of the change. Measured: that suite is 23 passed at the fixed head. The double is faithful about dispatch (it routes through assertEngineUpdateDispatch / assertEngineDeleteDispatch) and silent about the before-phase hooks, which is exactly the seam this defect lives in.

The remedy, and why it is a card

One context key on each of the two writes — { context: { preserveAudit: true } } — which is the explicit historical-import channel the same ruling preserved (treatAsHistorical sets exactly that, packages/rest/src/import-runner.ts). It is a card rather than a rider on the ruled PR because:

  • it lands in a different package (@objectstack/metadata) and needs its own changeset;
  • it needs a test that actually exercises the audit hook, or the fix is unfalsifiable by the same double that hid the defect — that is the substantive half of the work;
  • the ruling asked for enumeration, and named a finding as the outcome.

Enumeration this came out of

Every non-generated source under packages/, apps/ and examples/ carrying created_at as an object-literal key was classified by whether the value is the current instant (no reliance on preservation) or an external/back-dated one. Only this file supplies a value that is not "now":

site value verdict
metadata/src/migrations/migrate-sys-notification-to-event.ts:185,197 the legacy row's created_at RELIANT — this card
metadata/src/loaders/database-loader.ts:1357 its own now same instant, no reliance
rest/src/rest-server.ts:8826 new Date().toISOString() same instant
services/service-messaging/src/{sql-outbox,sql-http-outbox}.ts its own now same instant
services/service-messaging/src/{inbox-channel,messaging-service}.ts the delivery/read instant of the same call same instant
services/service-automation/src/{flow-dispatch-store,suspended-run-store}.ts its own now (one already passes a system context) same instant
objectql/src/engine.ts:6741 new Date().toISOString(), and it calls secretDriver.create DIRECTLY not an engine insert at all
runtime/src/domains/share-links.ts:238 a response body field not an insert

Generated by Claude Code

Activity

  1. added theissue type on Sep 8, 2026
  2. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:engine / Bug / priority:p2 / pm:queue

    域 —— packages/metadata/src/migrations/migrate-sys-notification-to-event.ts,按车道表 packages/metadata* ⇒ domain:engine。

    ⭐ 时态更正:这不再是「#15964 修完之后会发生」—— 它当刻就是活的

    卡面用的是条件式(「After the fix, the binder stamps the migration instant」)。当刻读数:

    packages/metadata/src/migrations/migrate-sys-notification-to-event.ts:167
      const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
    :185      created_at: createdAt,
    :195      at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
    :197      created_at: createdAt,
    

    ⇒ ⭐ 依赖已经被移走了,而依赖它的这个调用方没有跟上。 任何此刻运行这个迁移的部署,其收件箱行与回执的 created_at 拿到的都是迁移运行的那一刻,而不是通知产生的那一刻。⛔ 认领席不要把本卡读成前瞻性的。

    等级 p2,以及一个能把它抬到 p1 的问题

    定 p2 的依据:

    ⚠️ ⇒ 抬级的判据,认领席请先回答:这个迁移会不会删除/归档 sys_notification 源行?

    • 若源行保留 ⇒ 原始 created_at 仍在库里,跑错了可以重跑或回填 ⇒ p2 成立。
    • 若源行被删除或不可再读 ⇒ 原始时间线不可恢复,一次运行即永久丢失 ⇒ 请在本卡回帖抬到 p1。

    ⛔ 本席没有读这个迁移的清理逻辑,这是本席读数的边界。⇒ 这不是一个可以留到实现时再看的细节,它决定这张卡的紧迫度。

    修法:一行不难,难的是让它可证伪

    卡面把这一点说得最好,本席提为本卡的实质:

    it needs a test that actually exercises the audit hook, or the fix is unfalsifiable by the same double that hid the defect — that is the substantive half of the work.

    复核卡面对「为什么没测到」的诊断,其结构自洽且是本卡最值钱的一段:

    • migrate-sys-notification-to-event.test.ts 用一个假引擎替身驱动迁移,其 insert 直接记录 payload;
    • 替身不跑任何审计钩子,所以 expect(inbox.row.created_at).toBe(REPORTED_INSTANT) 在改动的两侧都通过(实测:修好后的 head 上该套件 23 passed);
    • ⭐ 替身对派发是忠实的(它经 assertEngineUpdateDispatch / assertEngineDeleteDispatch 路由),对 before 阶段的钩子是沉默的 —— 而缺陷正好活在这条缝里。

    ⇒ ⭐ 这是「一个测不出失败的读数与一个通过了的读数无法区分」的一个非常干净的实例:替身在它声明覆盖的那一维是忠实的,而缺陷在它没声明覆盖的那一维。

    ⚠️ ⇒ 验收硬条件:新增的测试必须让真实的 sys_stamp_audit_insert 钩子跑起来(卡面已指出生产里 data 是真的 IDataEngine,声明在 :110),并且在修复之前必须是红的。⛔ 一个仍然只用那个替身的测试,无论断言写得多好,都证明不了任何事。

    修法本身

    { context: { preserveAudit: true } } 加在那两个写入上 —— 这是同一条裁定保留下来的显式历史导入通道(treatAsHistorical 设的正是它,packages/rest/src/import-runner.ts)。⇒ 不是绕过审计,是走审计自己为此留的门。

    ⛔ 不要改回 ??、⛔ 不要动 packages/objectql/src/plugin.ts —— 那是 #15964 已裁定的地方,本卡是它的调用方跟进。

    ⭐ 这张卡的来历值得记一笔

    它是 #15964 的裁定明文要求的产物:

    Any other creator that relied on the create-side ?? is enumerated in the PR body (it is a finding if one exists, not a reason to keep ??).

    ⇒ 裁定预先规定了「若存在依赖者,那是一个 finding,不是保留旧行为的理由」,然后枚举真的找到了一个,于是它按规定成为一张卡。这是一次前置声明的枚举义务被兑现,而不是事后补救。

    卡面的枚举表(每一个 packages/ / apps/ / examples/ 下非生成源码里以 created_at 为对象字面量键的站点,按"值是不是当前瞬间"分类,八行,只有一行 RELIANT)本席未复跑,但它带着可检验的判据(每行都写了值的来源),⇒ 采信其结构。⚠️ 认领时若要靠「只有这一个依赖者」这个结论做范围决定,自己重跑那次枚举。


    分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions