Skip to content

Commit 6f23f0e

Browse files
os-muskclaude
andauthored
fix(metadata): declare the historical-import channel on the sys_notification migration's two L5 writes (#16834)
* fix(metadata): declare the historical-import channel on the sys_notification migration's two L5 writes `migrateSysNotificationToEvent` back-dates each materialized `sys_inbox_message` and `sys_notification_receipt` to the notification's own `created_at`. Both writes passed no options bag, so they were living on the audit binder's create-side `record.created_at ?? now` — the laundering #15964 removed on the maintainer ruling of 2026-09-06. Without it the ordinary branch stamps the migration instant on every migrated row: a user's whole bell history collapses to "all arrived today". Both writes now carry `{ context: { preserveAudit: true } }`, the explicit historical-import channel the same ruling deliberately kept (#3493, what REST import's `treatAsHistorical` sets). The `data.update` that rewrites the event row deliberately does NOT carry it — that write really is happening now, and `sys_stamp_audit_update` never touches `created_at`. The substantive half is the test. The existing suite drives the migration through a fake engine double that runs no hooks, so its `created_at` assertions passed on both sides of #15964 and the defect went unmeasured. A new case boots a real `ObjectKernel` + `ObjectQLPlugin` + `SqliteWasmDriver`, so the shipped `sys_stamp_audit_insert` hook actually runs, and it carries two anti-vacuity controls that fail if the hook is absent. It lives in `packages/runtime` because `@objectstack/objectql` depends on `@objectstack/metadata`, so importing it from that package would close a cycle turbo rejects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(runtime): assert the surviving source instant predates the migration run The last case held `runWindowStart` only to keep the binding used. It now carries the reading the triage seat asked for as a real assertion: the source `sys_notification` row's `created_at` still predates the run, which is exactly why a bad run is recoverable by backfill rather than terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1692157 commit 6f23f0e

4 files changed

Lines changed: 458 additions & 24 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/metadata': patch
3+
---
4+
5+
fix(metadata): keep the original notification instant when migrating `sys_notification` to the event model (#16312)
6+
7+
`migrateSysNotificationToEvent` materializes each legacy inbox row into a
8+
`sys_inbox_message` and a `sys_notification_receipt`, back-dating both to the
9+
notification's own `created_at`. Both writes passed no options bag, so they
10+
relied on the audit binder's create-side `record.created_at ?? now` — the
11+
laundering #15964 removed, on the maintainer ruling of 2026-09-06. Without
12+
that accident, every migrated inbox row and receipt is stamped with the moment
13+
the migration RAN: a user's whole bell history collapses to "all arrived
14+
today".
15+
16+
The two writes now declare `{ context: { preserveAudit: true } }`, the explicit
17+
historical-import channel the same ruling deliberately kept (#3493; it is what
18+
REST import's `treatAsHistorical` sets). This is not a bypass of audit — it is
19+
the door audit left open for a historical import. No exported symbol, schema or
20+
config key moves.
21+
22+
**Release ordering.** `@objectstack/objectql`'s side of #15964 is itself still
23+
an unreleased changeset, so no published version of this migration has ever
24+
written the flattened timeline. Releasing the two together keeps it that way.
25+
26+
**If a deployment did run it from a build that has both halves**, the original
27+
timeline is recoverable rather than lost: the source `sys_notification` rows
28+
are rewritten in place, never deleted or archived, and `created_at` is not
29+
among the legacy columns the run clears — so the notification's own instant is
30+
still on the event row and reachable from both new rows through
31+
`notification_id`.

packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ interface FakeLedger {
6767
const LEDGER_OBJECT = 'sys_migration';
6868

6969
function fakeEngine(ledger?: FakeLedger) {
70-
const inserts: Array<{ object: string; row: any }> = [];
70+
const inserts: Array<{ object: string; row: any; options?: Record<string, unknown> }> = [];
7171
const updates: Array<{ object: string; data: any }> = [];
7272
const finds: Array<{ object: string; query: any }> = [];
7373
const stored = new Map<string, Record<string, unknown>>(
@@ -89,8 +89,8 @@ function fakeEngine(ledger?: FakeLedger) {
8989
},
9090
}
9191
: {}),
92-
async insert(object: string, row: any) {
93-
inserts.push({ object, row });
92+
async insert(object: string, row: any, options?: Record<string, unknown>) {
93+
inserts.push({ object, row, options });
9494
if (object === LEDGER_OBJECT) {
9595
if (ledger?.failWrites) throw new Error(ledger.failWrites);
9696
stored.set(String(row.id), { ...row });
@@ -300,6 +300,69 @@ async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Prom
300300
}
301301
}
302302

303+
// ---------------------------------------------------------------------------
304+
// [#16312] The historical-import channel is DECLARED on the two L5 writes
305+
// ---------------------------------------------------------------------------
306+
//
307+
// ⚠️ Read what this can and cannot say, because the card exists because the
308+
// difference was missed once already.
309+
//
310+
// This double runs NO hooks. It is faithful about DISPATCH — its write verbs
311+
// route through the producer's own predicates — and silent about the before
312+
// phase, so it cannot observe whether `sys_stamp_audit_insert` kept or
313+
// overwrote `created_at`. That is exactly the seam the defect lived in: every
314+
// assertion in this file about `created_at` passed on BOTH sides of #15964's
315+
// change, and the suite read `23 passed` while migrated rows were being
316+
// stamped with the migration instant.
317+
//
318+
// ⇒ what follows is a pin on the CALL SHAPE, which is inside what this double
319+
// can see, and nothing more. The EFFECT — that the real audit hook honours it
320+
// and the row lands with the notification's own instant — is measured on a real
321+
// engine, with a real driver, in
322+
// `packages/runtime/src/notification-migration-audit-preservation.integration.test.ts`.
323+
// ⛔ Do not read a green here as evidence the timeline is preserved; that is the
324+
// reading this card was filed to retire.
325+
// ---------------------------------------------------------------------------
326+
327+
describe('#16312 the two L5 writes declare the historical-import channel', () => {
328+
it('inbox and receipt inserts both carry `context.preserveAudit`', async () => {
329+
const d = fakeDriver([
330+
{
331+
id: 'n1', recipient_id: 'u1', type: 'task.assigned', title: 'T',
332+
body: 'B', url: '/r/1', actor_name: 'Ada', is_read: 1,
333+
read_at: REPORTED_READ_INSTANT, created_at: REPORTED_INSTANT,
334+
organization_id: 'org1',
335+
},
336+
]);
337+
const e = fakeEngine();
338+
await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });
339+
340+
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
341+
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
342+
expect(inbox.options).toEqual({ context: { preserveAudit: true } });
343+
expect(receipt.options).toEqual({ context: { preserveAudit: true } });
344+
});
345+
346+
it('the event rewrite does NOT ask for it — that write really is happening now', async () => {
347+
const d = fakeDriver([
348+
{
349+
id: 'n1', recipient_id: 'u1', type: 'task.assigned', title: 'T',
350+
body: null, url: null, actor_name: null, is_read: 0,
351+
read_at: null, created_at: REPORTED_INSTANT, organization_id: null,
352+
},
353+
]);
354+
const e = fakeEngine();
355+
await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });
356+
357+
// `sys_stamp_audit_update` stamps `updated_at`, never `created_at`, so
358+
// the source row keeps its own instant without asking for anything —
359+
// and `updated_at = now` is the true fact about this write.
360+
const rewrite = e.updates.find((u) => u.object === 'sys_notification');
361+
expect(rewrite).toBeDefined();
362+
expect(e.updates.filter((u) => u.object === 'sys_notification')).toHaveLength(1);
363+
});
364+
});
365+
303366
describe('#13998 the timestamp spelling written into the new rows', () => {
304367
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
305368
const value = new Date(REPORTED_INSTANT);

packages/metadata/src/migrations/migrate-sys-notification-to-event.ts

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,56 @@ const EVENT_OBJECT = 'sys_notification';
5151
const INBOX_OBJECT = 'sys_inbox_message';
5252
const RECEIPT_OBJECT = 'sys_notification_receipt';
5353

54+
/**
55+
* The write context the two L5 materializing inserts carry: this is a
56+
* HISTORICAL IMPORT, so the row's `created_at` is the notification's own
57+
* instant and not the moment this migration ran.
58+
*
59+
* ## Read the fossil before you move this
60+
*
61+
* `preserveAudit` is not a bypass of audit — it is the door audit left open
62+
* for exactly this case, and it has a ruling behind it:
63+
*
64+
* - **#3493** put it there. `sys_stamp_audit_insert` / `sys_stamp_audit_update`
65+
* (`@objectstack/objectql`'s `plugin.ts`) read `session.preserveAudit` and,
66+
* when it is set, keep a supplied `created_at` / `updated_at` / `updated_by`
67+
* instead of overwriting it with the write instant. Its own words: "a
68+
* 'historical' import reinstates the ORIGINAL timeline". Opt-in and
69+
* server-set only — `ExecutionContext.preserveAudit` is documented as never
70+
* client-supplied, and REST import reaches it only through the
71+
* `treatAsHistorical` request flag (`packages/rest/src/import-runner.ts`).
72+
* - **#15964** (maintainer ruling 2026-09-06, decision batch #54, option A)
73+
* reaffirmed it while removing the accident this migration was living on.
74+
* `created_at` used to be `record.created_at ?? now` on EVERY insert —
75+
* client-preferred with no flag at all — which laundered a forged
76+
* `created_at` past the static-`readonly` strip on an ordinary
77+
* authenticated POST. The ruling made the ordinary branch stamp `now` and
78+
* kept the `preserveAudit` branch, deliberately: "Under `preserveAudit` the
79+
* preservation is DECLARED, so the same keep is the ruled historical-import
80+
* channel and stays — which is why the fix is this ternary and not a bare
81+
* `= now`."
82+
*
83+
* ⇒ the fossil says the channel exists FOR this; declaring it is the ruled
84+
* remedy, and ⛔ restoring the create-side `??` is not (#16312).
85+
*
86+
* ## Why the strip does not eat these keys
87+
*
88+
* The 2026-08-08 ruling narrowed the CREATE-side `readonly` strip's exemption
89+
* to `isSystem` alone, so a non-system create asking for `preserveAudit` is
90+
* warned that the exemption is UPDATE-only. That does not bite here:
91+
* `staticReadonlyInsertSubject` returns `null` for an object whose name starts
92+
* with `sys_` or that carries `managedBy` — both target objects are `sys_` and
93+
* `sys_notification_receipt` is `managedBy: 'engine-owned'` — so no create-side
94+
* static strip runs on them at all and no warning is owed. Pinned, on a real
95+
* engine, by `packages/runtime/src/notification-migration-audit-preservation.integration.test.ts`.
96+
*
97+
* ⛔ NOT carried on the `data.update` that rewrites the event row below. That
98+
* write really is happening now, so `updated_at = now` is the true fact;
99+
* `sys_stamp_audit_update` never touches `created_at`, which is why the source
100+
* row keeps its own.
101+
*/
102+
const HISTORICAL_IMPORT = { context: { preserveAudit: true } };
103+
54104
/** Legacy inbox columns cleared once a row is rewritten to the event shape. */
55105
const LEGACY_COLUMNS = [
56106
'recipient_id',
@@ -173,29 +223,37 @@ async function runNotificationEventMigration(
173223
const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : 'legacy';
174224

175225
// L5 in-app materialization.
176-
await data.insert(INBOX_OBJECT, {
177-
user_id: recipientId,
178-
notification_id: id,
179-
topic: eventTopic,
180-
title,
181-
body_md: row.body ?? null,
182-
severity: 'info',
183-
action_url: row.url ?? null,
184-
organization_id: orgId,
185-
created_at: createdAt,
186-
});
226+
await data.insert(
227+
INBOX_OBJECT,
228+
{
229+
user_id: recipientId,
230+
notification_id: id,
231+
topic: eventTopic,
232+
title,
233+
body_md: row.body ?? null,
234+
severity: 'info',
235+
action_url: row.url ?? null,
236+
organization_id: orgId,
237+
created_at: createdAt,
238+
},
239+
HISTORICAL_IMPORT,
240+
);
187241

188242
// L5 receipt (read-state spine).
189-
await data.insert(RECEIPT_OBJECT, {
190-
notification_id: id,
191-
delivery_id: null,
192-
user_id: recipientId,
193-
channel: 'inbox',
194-
state: isRead ? 'read' : 'delivered',
195-
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
196-
organization_id: orgId,
197-
created_at: createdAt,
198-
});
243+
await data.insert(
244+
RECEIPT_OBJECT,
245+
{
246+
notification_id: id,
247+
delivery_id: null,
248+
user_id: recipientId,
249+
channel: 'inbox',
250+
state: isRead ? 'read' : 'delivered',
251+
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
252+
organization_id: orgId,
253+
created_at: createdAt,
254+
},
255+
HISTORICAL_IMPORT,
256+
);
199257

200258
// Rewrite the row itself to the L2 event shape (engine handles JSON).
201259
await data.update(

0 commit comments

Comments
 (0)