@@ -22,6 +22,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
2222 * Writers: `DbQueueAdapter` (publish/lease/complete/fail).
2323 * Readers: Studio DLQ view, ops dashboards, the adapter's worker loop.
2424 *
25+ * Retention: `completed` rows are swept by the platform LifecycleService —
26+ * see the `lifecycle` block below (#5179).
27+ *
2528 * @namespace sys
2629 */
2730export const SysJobQueue = ObjectSchema . create ( {
@@ -31,6 +34,56 @@ export const SysJobQueue = ObjectSchema.create({
3134 icon : 'inbox' ,
3235 isSystem : true ,
3336 managedBy : 'engine-owned' ,
37+
38+ /**
39+ * [ADR-0057 §3.1/§3.3, #5179] The queue table only ever GREW: the adapter
40+ * marks a delivered message `completed` and nothing ever touched the row
41+ * again (`purge()` had zero production callers, `purgeFailed()` is a manual
42+ * dead-letter API). Since #5160 that is one permanent row per email.
43+ *
44+ * Bounded declaratively rather than by a sweeper inside `DbQueueAdapter`:
45+ * ADR-0057 §3.3 puts ONE reaper in the platform (`LifecycleService`), not N
46+ * per-plugin ones — the same call the sibling `sys_job_run` already makes.
47+ * That the writer is the adapter itself (never user data) is what makes an
48+ * unattended delete safe here; the declaration is where an operator can see
49+ * the window, and `lifecycle` settings can override it per environment
50+ * without a code change.
51+ *
52+ * `onlyWhen: { status: 'completed' }` is the whole safety story:
53+ * - `pending` / `running` are LIVE work — reaping them would drop
54+ * undelivered messages;
55+ * - `dlq` / `failed` are the dead-letter surface and exist precisely to
56+ * wait for a human (`listFailed` / `replay` / `purgeFailed`), so they
57+ * are never swept automatically, at any age.
58+ * This is also why the policy is `retention` (age by `created_at` + row
59+ * filter) and not `ttl` on `completed_at`: TTL has no row filter, and `dlq`
60+ * rows stamp `completed_at` too — a TTL would eat the dead-letter queue.
61+ *
62+ * Window = 7d, and it MUST stay ≥ the adapter's idempotency window
63+ * (`DbQueueAdapterOptions.idempotencyWindowMs`, default 24h): publish
64+ * dedups against terminal rows by comparing `created_at` to that window
65+ * (`db-queue-adapter.ts`), and the Reaper cuts off on the very same
66+ * `created_at` axis — so a retention ≥ the dedup window means a row the
67+ * dedup check still needs can never have been reaped, with no clock skew
68+ * between the two rules. 7d gives a week of delivery history for debugging
69+ * and 7× headroom over the default dedup window. `DbQueueAdapter` reads
70+ * this declaration and refuses to start when the two are configured the
71+ * wrong way round, so the invariant cannot drift apart silently.
72+ *
73+ * `class: 'transient'` ("workflow / ephemeral state" — ADR-0057 §3.1), not
74+ * `telemetry`: this is live work state, not a log, and per §3.6 a
75+ * `telemetry`/`event`/`audit` class RELOCATES the table to the dedicated
76+ * `telemetry` datasource wherever one is registered. Moving a live queue's
77+ * store is a migration, not a cleanup — `transient` deliberately stays on
78+ * the primary.
79+ */
80+ lifecycle : {
81+ class : 'transient' ,
82+ retention : {
83+ maxAge : '7d' ,
84+ onlyWhen : { status : 'completed' } ,
85+ } ,
86+ } ,
3487 description : 'Durable job/message queue including dead letters' ,
3588 displayNameField : 'queue' ,
3689 nameField : 'queue' , // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
0 commit comments