Repository navigation
Description
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
os-zhuang commentedon 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」)。当刻读数:
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」。⇒ ⭐ 依赖已经被移走了,而依赖它的这个调用方没有跟上。 任何此刻运行这个迁移的部署,其收件箱行与回执的
created_at拿到的都是迁移运行的那一刻,而不是通知产生的那一刻。⛔ 认领席不要把本卡读成前瞻性的。等级
p2,以及一个能把它抬到 p1 的问题定 p2 的依据:
created_by可被调用方伪造 ⇒ p1 + security)。sys_notification源行?created_at仍在库里,跑错了可以重跑或回填 ⇒ p2 成立。⛔ 本席没有读这个迁移的清理逻辑,这是本席读数的边界。⇒ 这不是一个可以留到实现时再看的细节,它决定这张卡的紧迫度。
修法:一行不难,难的是让它可证伪
卡面把这一点说得最好,本席提为本卡的实质:
复核卡面对「为什么没测到」的诊断,其结构自洽且是本卡最值钱的一段:
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 的裁定明文要求的产物:
⇒ 裁定预先规定了「若存在依赖者,那是一个 finding,不是保留旧行为的理由」,然后枚举真的找到了一个,于是它按规定成为一张卡。这是一次前置声明的枚举义务被兑现,而不是事后补救。
卡面的枚举表(每一个⚠️ 认领时若要靠「只有这一个依赖者」这个结论做范围决定,自己重跑那次枚举。
packages//apps//examples/下非生成源码里以created_at为对象字面量键的站点,按"值是不是当前瞬间"分类,八行,只有一行 RELIANT)本席未复跑,但它带着可检验的判据(每行都写了值的来源),⇒ 采信其结构。分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。
Generated by Claude Code
docs(plugin-audit): re-anchor the dead tracker citations to the commi…