Repository navigation
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
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 8, 2026 分诊:
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」)。当刻读数:
- #15395's engine-side readonly strip runs AFTER the audit binder, so a plain REST caller's created_at survives on an object that declares it readonly #15964 已关闭(
completed,2026-09-06T16:13:16Z),关闭它的是 PR fix(objectql): stamp created_at from the system clock on an ordinary create, so a caller cannot forge the audit anchor through a plain POST #16313(MERGED),标题即「stamp created_at from the system clock on an ordinary create」。 - 迁移侧的四行仍在树上,逐字如卡面:
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 的依据:
- 这是保真损失,不是完整性/安全问题:没有越权、没有跨租户、没有伪造归属(对照本轮同族的 The audit binder launders a caller-supplied created_by on an ordinary create, the same way #15964 does created_at #16311,那一张是
created_by可被调用方伪造 ⇒ p1 + security)。 - 后果是用户可见但可容忍的:整个收件箱的时间线被压平到一个瞬间(「所有通知都显示今天到达」)。
⚠️ ⇒ 抬级的判据,认领席请先回答:这个迁移会不会删除/归档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
- #15395's engine-side readonly strip runs AFTER the audit binder, so a plain REST caller's created_at survives on an object that declares it readonly #15964 已关闭(
- added a commit that references this issue
on Oct 7, 2026
Filed from the #15964 round, as the ruling required:
One exists. This is it.
The creator
packages/metadata/src/migrations/migrate-sys-notification-to-event.tsmaterializes each legacysys_notificationrow into an inbox row and a receipt, and it deliberately reinstates the ORIGINAL timeline:Both calls pass no options bag at all, so the write context carries neither
preserveAuditnorisSystem. Before the #15964 fix,record.created_at = record.created_at ?? nowin 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.datahere is a realIDataEngine(declared at:110), so in production these inserts run the shippedsys_stamp_audit_inserthook. The reliance is on the HOOK's??, not on the strip, so it does not depend on whether the target object declarescreated_atas readonly.Why no test caught it
migrate-sys-notification-to-event.test.tsdrives the migration through a fake engine double whoseinsertrecords the payload directly. No audit hook runs there, soexpect(inbox.row.created_at).toBe(REPORTED_INSTANT)passes on both sides of the change. Measured: that suite is23 passedat the fixed head. The double is faithful about dispatch (it routes throughassertEngineUpdateDispatch/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 (treatAsHistoricalsets exactly that,packages/rest/src/import-runner.ts). It is a card rather than a rider on the ruled PR because:@objectstack/metadata) and needs its own changeset;Enumeration this came out of
Every non-generated source under
packages/,apps/andexamples/carryingcreated_atas 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":metadata/src/migrations/migrate-sys-notification-to-event.ts:185,197created_atmetadata/src/loaders/database-loader.ts:1357nowrest/src/rest-server.ts:8826new Date().toISOString()services/service-messaging/src/{sql-outbox,sql-http-outbox}.tsnowservices/service-messaging/src/{inbox-channel,messaging-service}.tsservices/service-automation/src/{flow-dispatch-store,suspended-run-store}.tsnow(one already passes a system context)objectql/src/engine.ts:6741new Date().toISOString(), and it callssecretDriver.createDIRECTLYruntime/src/domains/share-links.ts:238Generated by Claude Code