effect-jobs 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +22 -0
  3. package/README.md +142 -0
  4. package/dist/Job.d.ts +33 -0
  5. package/dist/Job.d.ts.map +1 -0
  6. package/dist/Job.js +66 -0
  7. package/dist/Job.js.map +1 -0
  8. package/dist/JobConsumer.d.ts +34 -0
  9. package/dist/JobConsumer.d.ts.map +1 -0
  10. package/dist/JobConsumer.js +28 -0
  11. package/dist/JobConsumer.js.map +1 -0
  12. package/dist/JobContract.d.ts +128 -0
  13. package/dist/JobContract.d.ts.map +1 -0
  14. package/dist/JobContract.js +15 -0
  15. package/dist/JobContract.js.map +1 -0
  16. package/dist/JobFailure.d.ts +51 -0
  17. package/dist/JobFailure.d.ts.map +1 -0
  18. package/dist/JobFailure.js +34 -0
  19. package/dist/JobFailure.js.map +1 -0
  20. package/dist/JobId.d.ts +5 -0
  21. package/dist/JobId.d.ts.map +1 -0
  22. package/dist/JobId.js +4 -0
  23. package/dist/JobId.js.map +1 -0
  24. package/dist/JobIdentity.d.ts +54 -0
  25. package/dist/JobIdentity.d.ts.map +1 -0
  26. package/dist/JobIdentity.js +82 -0
  27. package/dist/JobIdentity.js.map +1 -0
  28. package/dist/JobLifecycle.d.ts +173 -0
  29. package/dist/JobLifecycle.d.ts.map +1 -0
  30. package/dist/JobLifecycle.js +234 -0
  31. package/dist/JobLifecycle.js.map +1 -0
  32. package/dist/JobPayload.d.ts +18 -0
  33. package/dist/JobPayload.d.ts.map +1 -0
  34. package/dist/JobPayload.js +10 -0
  35. package/dist/JobPayload.js.map +1 -0
  36. package/dist/JobPayloadCodec.d.ts +14 -0
  37. package/dist/JobPayloadCodec.d.ts.map +1 -0
  38. package/dist/JobPayloadCodec.js +78 -0
  39. package/dist/JobPayloadCodec.js.map +1 -0
  40. package/dist/JobPolicy.d.ts +50 -0
  41. package/dist/JobPolicy.d.ts.map +1 -0
  42. package/dist/JobPolicy.js +85 -0
  43. package/dist/JobPolicy.js.map +1 -0
  44. package/dist/JobProducer.d.ts +14 -0
  45. package/dist/JobProducer.d.ts.map +1 -0
  46. package/dist/JobProducer.js +23 -0
  47. package/dist/JobProducer.js.map +1 -0
  48. package/dist/JobQueue.d.ts +16 -0
  49. package/dist/JobQueue.d.ts.map +1 -0
  50. package/dist/JobQueue.js +16 -0
  51. package/dist/JobQueue.js.map +1 -0
  52. package/dist/JobRegistry.d.ts +37 -0
  53. package/dist/JobRegistry.d.ts.map +1 -0
  54. package/dist/JobRegistry.js +47 -0
  55. package/dist/JobRegistry.js.map +1 -0
  56. package/dist/JobStore.d.ts +348 -0
  57. package/dist/JobStore.d.ts.map +1 -0
  58. package/dist/JobStore.js +19 -0
  59. package/dist/JobStore.js.map +1 -0
  60. package/dist/JobTelemetry.d.ts +7 -0
  61. package/dist/JobTelemetry.d.ts.map +1 -0
  62. package/dist/JobTelemetry.js +61 -0
  63. package/dist/JobTelemetry.js.map +1 -0
  64. package/dist/JobTransaction.d.ts +24 -0
  65. package/dist/JobTransaction.d.ts.map +1 -0
  66. package/dist/JobTransaction.js +7 -0
  67. package/dist/JobTransaction.js.map +1 -0
  68. package/dist/JobWorker.d.ts +24 -0
  69. package/dist/JobWorker.d.ts.map +1 -0
  70. package/dist/JobWorker.js +155 -0
  71. package/dist/JobWorker.js.map +1 -0
  72. package/dist/JobWorkerRuntime.d.ts +18 -0
  73. package/dist/JobWorkerRuntime.d.ts.map +1 -0
  74. package/dist/JobWorkerRuntime.js +5 -0
  75. package/dist/JobWorkerRuntime.js.map +1 -0
  76. package/dist/PollingJobWorker.d.ts +9 -0
  77. package/dist/PollingJobWorker.d.ts.map +1 -0
  78. package/dist/PollingJobWorker.js +54 -0
  79. package/dist/PollingJobWorker.js.map +1 -0
  80. package/dist/PostgreSqlDrizzleSchema.d.ts +432 -0
  81. package/dist/PostgreSqlDrizzleSchema.d.ts.map +1 -0
  82. package/dist/PostgreSqlDrizzleSchema.js +72 -0
  83. package/dist/PostgreSqlDrizzleSchema.js.map +1 -0
  84. package/dist/PostgreSqlJobs.d.ts +25 -0
  85. package/dist/PostgreSqlJobs.d.ts.map +1 -0
  86. package/dist/PostgreSqlJobs.js +38 -0
  87. package/dist/PostgreSqlJobs.js.map +1 -0
  88. package/dist/PostgreSqlSchema.d.ts +30 -0
  89. package/dist/PostgreSqlSchema.d.ts.map +1 -0
  90. package/dist/PostgreSqlSchema.js +63 -0
  91. package/dist/PostgreSqlSchema.js.map +1 -0
  92. package/dist/PostgreSqlTransaction.d.ts +42 -0
  93. package/dist/PostgreSqlTransaction.d.ts.map +1 -0
  94. package/dist/PostgreSqlTransaction.js +9 -0
  95. package/dist/PostgreSqlTransaction.js.map +1 -0
  96. package/dist/index.d.ts +7 -0
  97. package/dist/index.d.ts.map +1 -0
  98. package/dist/index.js +7 -0
  99. package/dist/index.js.map +1 -0
  100. package/dist/internal/JobTransaction.d.ts +16 -0
  101. package/dist/internal/JobTransaction.d.ts.map +1 -0
  102. package/dist/internal/JobTransaction.js +21 -0
  103. package/dist/internal/JobTransaction.js.map +1 -0
  104. package/dist/internal/codec/CanonicalJson.d.ts +9 -0
  105. package/dist/internal/codec/CanonicalJson.d.ts.map +1 -0
  106. package/dist/internal/codec/CanonicalJson.js +184 -0
  107. package/dist/internal/codec/CanonicalJson.js.map +1 -0
  108. package/dist/internal/codec/ProtectedProjection.d.ts +12 -0
  109. package/dist/internal/codec/ProtectedProjection.d.ts.map +1 -0
  110. package/dist/internal/codec/ProtectedProjection.js +121 -0
  111. package/dist/internal/codec/ProtectedProjection.js.map +1 -0
  112. package/dist/internal/lifecycle/Artifacts.d.ts +6 -0
  113. package/dist/internal/lifecycle/Artifacts.d.ts.map +1 -0
  114. package/dist/internal/lifecycle/Artifacts.js +49 -0
  115. package/dist/internal/lifecycle/Artifacts.js.map +1 -0
  116. package/dist/internal/lifecycle/Reconciliation.d.ts +82 -0
  117. package/dist/internal/lifecycle/Reconciliation.d.ts.map +1 -0
  118. package/dist/internal/lifecycle/Reconciliation.js +98 -0
  119. package/dist/internal/lifecycle/Reconciliation.js.map +1 -0
  120. package/dist/internal/postgresql/Producer.d.ts +14 -0
  121. package/dist/internal/postgresql/Producer.d.ts.map +1 -0
  122. package/dist/internal/postgresql/Producer.js +55 -0
  123. package/dist/internal/postgresql/Producer.js.map +1 -0
  124. package/dist/internal/postgresql/Readiness.d.ts +5 -0
  125. package/dist/internal/postgresql/Readiness.d.ts.map +1 -0
  126. package/dist/internal/postgresql/Readiness.js +53 -0
  127. package/dist/internal/postgresql/Readiness.js.map +1 -0
  128. package/dist/internal/postgresql/Rows.d.ts +45 -0
  129. package/dist/internal/postgresql/Rows.d.ts.map +1 -0
  130. package/dist/internal/postgresql/Rows.js +75 -0
  131. package/dist/internal/postgresql/Rows.js.map +1 -0
  132. package/dist/internal/postgresql/Store.d.ts +5 -0
  133. package/dist/internal/postgresql/Store.d.ts.map +1 -0
  134. package/dist/internal/postgresql/Store.js +211 -0
  135. package/dist/internal/postgresql/Store.js.map +1 -0
  136. package/dist/internal/worker/Capability.d.ts +106 -0
  137. package/dist/internal/worker/Capability.d.ts.map +1 -0
  138. package/dist/internal/worker/Capability.js +11 -0
  139. package/dist/internal/worker/Capability.js.map +1 -0
  140. package/dist/internal/worker/Drain.d.ts +22 -0
  141. package/dist/internal/worker/Drain.d.ts.map +1 -0
  142. package/dist/internal/worker/Drain.js +59 -0
  143. package/dist/internal/worker/Drain.js.map +1 -0
  144. package/dist/internal/worker/Execution.d.ts +7 -0
  145. package/dist/internal/worker/Execution.d.ts.map +1 -0
  146. package/dist/internal/worker/Execution.js +125 -0
  147. package/dist/internal/worker/Execution.js.map +1 -0
  148. package/dist/internal/worker/Results.d.ts +108 -0
  149. package/dist/internal/worker/Results.d.ts.map +1 -0
  150. package/dist/internal/worker/Results.js +3 -0
  151. package/dist/internal/worker/Results.js.map +1 -0
  152. package/package.json +169 -0
  153. package/specs/architecture-decisions.md +841 -0
  154. package/specs/core-contract.md +323 -0
  155. package/specs/drizzle-compatibility.md +167 -0
  156. package/specs/postgresql-backend.md +291 -0
  157. package/specs/release-qualification.md +107 -0
@@ -0,0 +1,841 @@
1
+ # Architecture decisions
2
+
3
+ D1–D8 are accepted. This document is the canonical extraction contract, including
4
+ later refinements recorded within the decisions. Earlier Multica proposals and
5
+ auth specifications are historical reference where they conflict with it.
6
+ Statements about downstream gates describe dependencies, not unaccepted choices
7
+ now that all eight decisions are recorded. Exact API spellings and supported
8
+ adapter coverage still need specification and qualification.
9
+
10
+ Decision acceptance alone did not dispatch implementation. The owner subsequently
11
+ authorized continuing the extraction and committing these decisions. Execution
12
+ must still follow task dependencies, isolated ownership, and the agreed review
13
+ workflow; this does not authorize push, publication, database resets, or changes
14
+ to unrelated auth work.
15
+
16
+ ## D1 — focused initial scope and package boundary
17
+
18
+ Status: accepted by the owner on 2026-10-04. Tracked in Multica as UPVE-849.
19
+
20
+ ### Decision
21
+
22
+ Choose variant A: extract and generalize the existing durable jobs substrate
23
+ before adding new queue features. The first release is an alpha, not a claim of
24
+ production qualification.
25
+
26
+ Ship one npm package, `effect-jobs`, with focused public subpaths. Keep the core
27
+ independent of PostgreSQL and Drizzle imports; isolate PostgreSQL persistence
28
+ behind a backend subpath with optional backend peers. Do not split core, worker,
29
+ and PostgreSQL into separate npm packages for the initial release.
30
+
31
+ The catalog supports application-defined jobs from the outset. This does not
32
+ require effect-auth to expose its own catalog to arbitrary application jobs.
33
+
34
+ ### Initial scope
35
+
36
+ - Schema-defined, explicitly versioned jobs and logical queues.
37
+ - Application-defined catalogs and handler installation.
38
+ - Atomic enqueue within an application-owned transaction.
39
+ - Semantic deduplication and the existing durable lifecycle guarantees.
40
+ - Fixed leases, ownership fencing, and bounded unknown-outcome reconciliation.
41
+ - Persisted retries, bounded recovery, and retention.
42
+ - PostgreSQL persistence with application-owned migrations.
43
+ - Bounded worker drain and explicit scoped Node/Bun polling.
44
+
45
+ Imports must not start workers. Applications explicitly compose persistence,
46
+ handler, and runtime Layers. Core imports must work without backend peers.
47
+
48
+ ### Ownership boundary
49
+
50
+ `effect-jobs` owns the generic durable execution mechanism and never depends on
51
+ `effect-auth`. `effect-auth` retains domain schemas, job definitions, handlers,
52
+ authority, providers, producer transactions, and domain receipt/retention rules.
53
+ There is one durable lifecycle and authority, not a second auth-specific runtime
54
+ or a dual-write migration.
55
+
56
+ ### Deferred features
57
+
58
+ Standalone enqueue convenience, additional backends, recurring schedules,
59
+ dashboards, flows, pause/resume/cancel/redrive APIs, and a ready-made Cloudflare
60
+ coordinator are outside the first release. Bounded drain alone is not a
61
+ Cloudflare support claim.
62
+
63
+ ### Proposed module shape
64
+
65
+ The following illustrates the accepted packaging direction, not implemented
66
+ exports or finalized symbol names:
67
+
68
+ ```ts
69
+ import { Job } from "effect-jobs/Job"
70
+ import { JobQueue } from "effect-jobs/JobQueue"
71
+ import * as JobWorker from "effect-jobs/JobWorker"
72
+ import * as PostgreSqlJobs from "effect-jobs/PostgreSqlJobs"
73
+ ```
74
+
75
+ ### Rationale and downstream gates
76
+
77
+ A focused extraction limits simultaneous protocol changes and preserves the
78
+ existing transaction-bound producer guarantees. A single package avoids
79
+ premature release/version coordination while subpaths isolate backend loading.
80
+
81
+ D1 approves scope and ownership, not the illustrative method signatures from
82
+ the discussion. Envelope and handler inputs, IDs/deduplication, failure policy,
83
+ payload protection, transaction bridge/driver, physical storage compatibility,
84
+ and retention contracts remain gated by D2–D8. Baseline and regression
85
+ qualification tasks remain required. Acceptance does not dispatch agents or
86
+ authorize implementation, merge, push, or publication.
87
+
88
+ ## D2 — domain payload, envelope, and handler input
89
+
90
+ Status: accepted by the owner on 2026-10-04 after clarification of the existing
91
+ auth API. Tracked in Multica as UPVE-851.
92
+
93
+ Choose variant B: a definition's payload Schema describes domain data only.
94
+ The library owns the standard envelope Schema, separate from mutable lifecycle
95
+ state. Enqueue accepts an explicit transaction and an input containing producer
96
+ identity, policy, optional initial availability, and decoded domain payload. D3 refines
97
+ the original job-ID example: the library assigns the job ID rather than requiring
98
+ the caller to preserve it across production retries.
99
+
100
+ Handlers receive `{ payload, context }`. The proposed minimal context exposes
101
+ job ID, catalog identity (queue/kind/version), producer identity, and current
102
+ attempt number. Exact field names and attempt counting semantics must be
103
+ specified before implementation. It does not expose a database client, lease
104
+ token, or ack/retry/complete capabilities; the worker owns lifecycle transitions.
105
+ Handler service dependencies remain ordinary Effect/Layer requirements, not an
106
+ ambient execution-context service.
107
+
108
+ Auth subject/actor/event fields remain domain-owned rather than mandatory
109
+ generic envelope fields. Auth retains any necessary cross-field validation when
110
+ adapting its definitions. The existing auth `Job.make` payload option points to
111
+ a full claimed-job Schema; that is an API modeling fact, not a claim that every
112
+ envelope field is physically stored in a payload blob.
113
+
114
+ Producers use decoded Schema types; durable encoding is bounded and workers
115
+ validate persisted artifacts before handlers receive decoded payloads. Required
116
+ encoding/decoding services must remain explicit in types and Layer composition.
117
+ Protected payload behavior remains gated by D5. The suggested existing 64 KiB
118
+ payload/projection limits are a starting point, not separately finalized here.
119
+
120
+ This separation makes the generic API clearer without changing transaction
121
+ atomicity or allowing handlers to own finalization. D3 still determines IDs and
122
+ deduplication; D4 determines failure outcomes; D6 determines the transaction
123
+ bridge. No runtime exports or implementation are introduced by this decision.
124
+
125
+ ## D3 — producer identity, IDs, and semantic deduplication
126
+
127
+ Status: accepted by the owner on 2026-10-04. Tracked in Multica as UPVE-852.
128
+
129
+ ### Producer identity and declaration
130
+
131
+ Deduplicate by the required tuple `(operation, operationId, slot)` within one
132
+ configured jobs storage, not separately per job kind or version. `operation`
133
+ names the business operation, `operationId` identifies its particular instance,
134
+ and `slot` identifies one consequence. Multiple slots may produce the same job
135
+ kind. Applications should namespace operation names.
136
+
137
+ Declare a producer once in an application-owned domain module and import it from
138
+ all callers. The accepted API direction is:
139
+
140
+ ```ts
141
+ export const IssueInvoiceProducer = JobProducer.make({
142
+ operation: "billing.issue-invoice",
143
+ slots: ["generate-pdf", "send-email"]
144
+ })
145
+
146
+ const producer = IssueInvoiceProducer.identity({
147
+ operationId,
148
+ slot: "generate-pdf"
149
+ })
150
+ ```
151
+
152
+ The factory is immutable configuration, not a service or running producer.
153
+ `operation` retains its literal type; `slot` is inferred as the union of declared
154
+ literals. `operationId` is a bounded opaque string; application-branded strings
155
+ are accepted without requiring an additional library brand. Validate external
156
+ values at boundaries.
157
+
158
+ The owner subsequently accepted these identifier boundaries and diagnostics:
159
+
160
+ - `operationId` is 1–256 UTF-8 bytes, contains valid Unicode scalar values and
161
+ no NUL, and is compared exactly without trimming, case folding or normalization.
162
+ - `operation` and each `slot` are 1–128 ASCII characters matching
163
+ `^[a-z][a-z0-9.-]*$`. The simple regular expression is accepted.
164
+ - Invalid declaration names fail immediately when creating the producer, not
165
+ later during enqueue. Ordinary TypeScript string types do not validate this
166
+ grammar; inferred slot unions still reject undeclared slots statically.
167
+ - Configuration errors identify the exact field (including a slot index), the
168
+ invalid declaration name, and the expected length/format so callers can fix it.
169
+ Invalid operation IDs report the violated rule and, for length violations, the
170
+ actual UTF-8 byte length without echoing the potentially sensitive value.
171
+ - Tests must cover these boundaries and actionable diagnostics. These accepted
172
+ rules do not claim that runtime validation has already been implemented.
173
+
174
+ Start with `identity({ operationId, slot })`; fluent `forOperation(...).slot(...)`
175
+ is not required for the initial implementation. Do not introduce a mutable
176
+ builder or global registry to detect semantically similar names. Where multiple
177
+ callers share the same production logic, use an ordinary application-domain
178
+ function to bind producer slot, job definition, and input construction. Such
179
+ wrappers are not mandatory for every enqueue.
180
+
181
+ ### Job IDs and enqueue results
182
+
183
+ The library assigns a unique opaque branded `JobId` on first production. The
184
+ caller is not required to supply or preserve a job ID. A matching duplicate
185
+ returns the existing ID:
186
+
187
+ ```ts
188
+ type EnqueueResult =
189
+ | { readonly _tag: "Inserted"; readonly jobId: JobId }
190
+ | { readonly _tag: "AlreadyPresent"; readonly jobId: JobId }
191
+ ```
192
+
193
+ This supersedes the earlier proposal requiring stable caller-supplied job IDs
194
+ and rejecting production retries solely because a newly generated ID differs.
195
+ An explicit caller-ID override is not approved by this decision. There is no
196
+ mandatory generic event ID; event IDs remain domain-owned.
197
+
198
+ ### Equality and transaction guarantees
199
+
200
+ For the same producer identity, compare catalog identity and domain payload
201
+ semantics, including protected-field fingerprints. Changed catalog identity or
202
+ content produces a typed integrity error. The library-generated job ID is not a
203
+ new candidate input to compare. There is no payload overwrite, debounce, or
204
+ last-write-wins behavior.
205
+
206
+ Exclude mutable lifecycle state, counters, leases, rescheduled availability,
207
+ and encryption randomness/raw ciphertext from semantic equality. Protected-field
208
+ equality remains subject to D5.
209
+
210
+ The owner subsequently refined D3: policy and initial availability are immutable
211
+ after creation but are not part of duplicate semantic equality. For a matching
212
+ duplicate, retain the first stored policy and availability, even if the caller
213
+ supplies different values on a production retry. This is first-write-wins for
214
+ execution configuration only, not for payload content. Changed deployment
215
+ defaults must not mutate existing jobs or turn otherwise matching retries into
216
+ integrity conflicts.
217
+
218
+ `availableAt` is optional. If omitted on first insertion, the backend assigns it
219
+ using database time. A matching duplicate never resets it to a new current time.
220
+ A supplied past timestamp is valid and makes the job immediately eligible;
221
+ a future timestamp postpones eligibility, not guarantees execution at that time.
222
+ Availability is not a domain expiration deadline.
223
+
224
+ Policy is validated when creating the job and persisted durably. Re-enqueue is
225
+ not an API for rescheduling or changing policy. Such an operation would need a
226
+ separate contract, outside the current initial scope. A new producer identity
227
+ denotes a new consequence, not an update of the old job. This refinement
228
+ supersedes earlier discussion requiring callers to retain identical scheduling
229
+ timestamps and policy across production retries.
230
+
231
+ Storage uniqueness and conflict handling must enforce this behavior under
232
+ concurrent enqueue, not merely a pre-insert lookup. Enqueue remains inside the
233
+ application-owned transaction; integrity conflicts must not be swallowed to
234
+ commit the remaining domain writes. Results are provisional until commit.
235
+
236
+ ### Application responsibilities and limits
237
+
238
+ Applications supply a stable operation ID across retries, for example an
239
+ idempotency key or durable operation/event ID. A new operation ID denotes a new
240
+ operation even if payload or job kind matches. Sharing producer declarations
241
+ across endpoints does not deduplicate requests with different operation IDs.
242
+
243
+ Retrying execution or recovering an existing job preserves its job ID; that is
244
+ distinct from retrying the business operation that produces a job. Deduplication
245
+ lasts only while durable identity evidence is retained; D8 determines retention.
246
+ It does not imply exactly-once external handler effects.
247
+
248
+ Auth should adapt its existing operation/consequence tuple through a shared
249
+ domain declaration. Its former job/event ID binding requires explicit adaptation
250
+ and must not be blindly copied. Exact storage representation is gated by D7.
251
+ This decision records a contract, not implementation or dispatch authorization.
252
+
253
+ ## D4 — handler error adapter and execution outcomes
254
+
255
+ Status: accepted by the owner on 2026-10-04, including the clarified
256
+ `OutcomeUnknown` name and separation from durable lifecycle status. Tracked in
257
+ Multica as UPVE-854.
258
+
259
+ Choose ordinary Effect composition in the handler adapter rather than a separate
260
+ `classifyError` callback in handler configuration. Domain services retain their
261
+ domain errors. The application-owned adapter maps those errors into explicit job
262
+ failures, for example using `Effect.mapError` and exhaustive matching, before
263
+ installing the handler through `handlerLayer(execute)`.
264
+
265
+ Both proposed variants can preserve exhaustive typing, shared mapping, and
266
+ domain independence. Variant A avoids an extra library classification mechanism
267
+ and stays closer to the existing implementation. Mapping must not require
268
+ domain services to depend on the queue library. Handler service requirements
269
+ remain ordinary Effect/Layer requirements; the worker still owns durable
270
+ transitions.
271
+
272
+ ### Outcome semantics
273
+
274
+ - Success: the worker attempts fenced completion.
275
+ - `Retry`: request a persisted retry according to policy and within its limits.
276
+ - `Dead`: a known terminal failure without another retry.
277
+ - `Isolate`: an invalid artifact or violated invariant requiring investigation,
278
+ rather than an ordinary business rejection.
279
+ - `OutcomeUnknown`: the operation may have executed, but its outcome cannot be
280
+ confirmed. This supersedes the proposed name `Unresolved`.
281
+
282
+ `OutcomeUnknown` is an attempt outcome, not a new durable lifecycle status.
283
+ Absent confirmed finalization, the row remains Active pending reconciliation or
284
+ fenced lease expiry/recovery. Recovery can invoke the handler again, so external
285
+ effects still need idempotency or domain reconciliation. Unknown does not mean
286
+ not executed and does not provide exactly-once execution.
287
+
288
+ For example, an explicit temporary provider rejection can be mapped to Retry;
289
+ a lost response after sending a request can be mapped to OutcomeUnknown.
290
+ Defects, interruptions, composite causes, and uncertain timeouts must not be
291
+ automatically interpreted as known-safe retries or successful completion.
292
+ An unknown finalization-write result requires storage reconciliation, not an
293
+ immediate rerun of the handler.
294
+
295
+ Only explicit bounded failure codes are persisted, not arbitrary error messages
296
+ or raw `Cause.pretty`. Preserve the existing persisted FixedDelayV1 retry and
297
+ bounded recovery protocol during extraction rather than adding exponential retry.
298
+ The owner subsequently accepted preserving the existing attempt-counting protocol:
299
+ `context.attemptNumber = attemptsMade + 1`. Claim/release do not increment
300
+ `attemptsMade`; a confirmed, fenced Complete/Retry/Dead/Isolate transition
301
+ increments it exactly once. An unknown outcome leaves it unchanged; recovery
302
+ uses a separate `stalledCount` and limit. Thus the exposed one-based attempt number
303
+ may repeat after an unknown outcome and recovery. It is neither an external
304
+ idempotency key nor a count of all handler invocations. `maxAttempts` bounds
305
+ finalized attempts, while `maxStalledCount` separately bounds uncertain recovery.
306
+ This accepts counter semantics, not unpresented exact time-boundary comparisons.
307
+
308
+ The owner subsequently accepted preserving these exact time boundaries:
309
+
310
+ - A job is due when `availableAt <= dbNow`.
311
+ - Fenced owned writes require `leaseExpiresAt > dbNow`; expiry recovery is
312
+ eligible when `leaseExpiresAt <= dbNow`.
313
+ - `attemptTimeoutMillis < leaseDurationMillis` is required.
314
+ - A claim or reconciled lease is usable only with remaining lease time
315
+ `>= attemptTimeoutMillis + operationResponseBudgetMillis`.
316
+ - A retry whose computed next availability is `>= notAfter` becomes Dead rather
317
+ than being scheduled. `notAfter` limits retry scheduling, not handler execution
318
+ or initial availability.
319
+ - Durable transitions use one database-time snapshot rather than process time.
320
+ Tests must exercise equality and immediately neighboring values.
321
+
322
+ Exact constructor spellings and remaining boundary details must be specified,
323
+ and the accepted behavior must be qualified before implementation; no unbounded retry or
324
+ new lifecycle status is approved. Acceptance does not authorize dispatch.
325
+
326
+ ### Accepted policy input refinement
327
+
328
+ The owner accepted `JobPolicy.make` with all configuration fields optional and
329
+ documented defaults, including retention. Public callers do not manually write
330
+ internal tagged retry/retention objects or use choice callbacks.
331
+
332
+ - Counters use integer numbers.
333
+ - Timeout, lease duration and fixed retry delay use finite Effect `Duration`
334
+ values. Exact valid numeric ranges and execution defaults still need explicit
335
+ specification and qualification; example values are not approved defaults.
336
+ - Completed and Dead retention accept finite Effect `Duration` values or
337
+ `Duration.infinity`. Default Completed retention is 90 days; default Dead
338
+ retention is infinite. Retention is measured from terminal completion, not an
339
+ absolute timestamp. `availableAt` remains a separate enqueue input.
340
+ - The helper resolves a complete validated policy, including cross-field checks,
341
+ and the backend persists those resolved values in its versioned internal
342
+ representation. Updated defaults do not alter already stored jobs.
343
+ - Purging Completed jobs can remove deduplication evidence after the retention
344
+ period. Infinite retention avoids automatic expiry but can grow storage.
345
+
346
+ Illustrative public input:
347
+
348
+ ```ts
349
+ const policy = JobPolicy.make({
350
+ maxAttempts: 3,
351
+ retryDelay: Duration.seconds(5),
352
+ completedRetention: Duration.days(90),
353
+ deadRetention: Duration.infinity
354
+ })
355
+ ```
356
+
357
+ `JobPolicy.make()` also resolves the documented defaults. This decision changes
358
+ the earlier proposal requiring explicit retention; it does not introduce
359
+ exponential retry, automatic Isolated cleanup, or unlimited Pending/Active cleanup.
360
+
361
+ The owner accepted these execution defaults (public names remain illustrative):
362
+
363
+ ```ts
364
+ {
365
+ leaseDuration: Duration.seconds(90),
366
+ attemptTimeout: Duration.seconds(30),
367
+ retryDelay: Duration.seconds(5),
368
+ maxAttempts: 3,
369
+ maxStalledCount: 1
370
+ }
371
+ ```
372
+
373
+ The attempt limit includes the first finalized attempt, not three additional
374
+ retries. One stalled recovery is permitted by default; a subsequent recovery
375
+ exceeding the stored limit ends the job as Dead. Unknown outcomes can repeat an
376
+ attempt number and lead to additional handler invocations. An attempt timeout
377
+ does not prove that external effects did not occur.
378
+
379
+ The owner reaffirmed fixed-delay retry for this extraction. Exponential backoff
380
+ with jitter is recorded as a future strategy, not activated implementation scope.
381
+ Its multiplier, cap, jitter distribution and persisted/versioned representation
382
+ must be specified and qualified separately before adding it.
383
+
384
+ ## D5 — explicit application-owned protection and codec boundaries
385
+
386
+ Status: accepted by the owner on 2026-10-04, including variant A, the separate-key
387
+ contract, deferred generic binding, and codec/equality direction. Tracked in
388
+ Multica as UPVE-856.
389
+
390
+ Choose explicit application-owned protection rather than automatic encryption
391
+ of Schema-marked subtrees. Producers call a protection service to seal sensitive
392
+ data before enqueue; handler adapters call it to open protected data. The
393
+ payload Schema describes the protected representation, not plaintext that the
394
+ queue would automatically encrypt.
395
+
396
+ Use `JobPayload.protected(...)` as the proposed honest marker name. The marker
397
+ identifies an already-protected envelope and keyed semantic fingerprint for the
398
+ codec; it does not encrypt, authenticate, or decrypt by itself. An arbitrary
399
+ plaintext value with a marker is not a valid substitute for the protected
400
+ representation. Domain services and protection adapters remain application-owned.
401
+
402
+ The queue core persists protected bytes and compares semantic fingerprints
403
+ without requiring key access. Application producers and handler adapters need
404
+ the protection service and its key configuration via ordinary Effect/Layer
405
+ composition. They may run in the same process as the queue; this is a dependency
406
+ and responsibility boundary, not a physical security isolation claim.
407
+
408
+ The owner subsequently selected protected representation option A: the
409
+ application defines the envelope and fingerprint Schemas. The protected marker
410
+ requires an own `fingerprint` field with a validated canonical JSON
411
+ representation; semantic equality uses that fingerprint and excludes the rest
412
+ of the marked subtree, including randomized ciphertext. The library does not
413
+ mandate a fixed encrypted-envelope format or a caller-supplied fingerprint
414
+ projection callback. The application remains responsible for sealing, opening
415
+ and cryptographic validation; the marker does not prove protection.
416
+
417
+ ```ts
418
+ const Protected = JobPayload.protected(
419
+ Schema.Struct({
420
+ envelope: MyEncryptedEnvelope,
421
+ fingerprint: MyFingerprint
422
+ })
423
+ )
424
+ ```
425
+
426
+ This API spelling is illustrative until implementation qualification. Existing
427
+ codec support and explicit unsupported-shape requirements below still apply.
428
+
429
+ Rationale: explicit encryption/decryption points, application control of keyring
430
+ or KMS, reusable protection services, and less new generic crypto machinery in
431
+ the focused extraction. The trade-off is additional producer/handler adapter
432
+ code; this choice is not inherently more secure than correctly implemented
433
+ automatic protection.
434
+
435
+ This refines D2: decoded domain payload does not mean automatically decrypted
436
+ plaintext. For protected fields, handlers receive the decoded protected envelope
437
+ and explicitly open it. The library must not silently persist plaintext under an
438
+ encryption marker. Existing auth protection is reference code, not a generic
439
+ implementation that can be copied unchanged.
440
+
441
+ Authenticated context binding (AAD) is deferred by explicit owner decision; it
442
+ is not a requirement of the generic library or its V1 protection API. Keep the
443
+ idea as a reference, not an implementation task. Codec boundaries are specified
444
+ below. The reproduced protected-subtree Union bug remains a required
445
+ regression fix. Automatic generic encryption is not required for the first
446
+ release. No implementation or agent dispatch is authorized by this choice.
447
+
448
+ ### Deferred reference: authenticated context binding (AAD)
449
+
450
+ The owner found insufficient incremental value to justify introducing generic
451
+ context binding during extraction. Proposed scenarios included misassigned
452
+ ciphertexts during migrations and ciphertext substitution by a database writer
453
+ without key access. These were not accepted as sufficient motivation for added
454
+ library complexity. AAD does not protect against arbitrary database compromise
455
+ and is not a substitute for domain authority checks.
456
+
457
+ The following illustrates the deferred idea only, not an approved or existing
458
+ API. An application protection service could authenticate a canonical encoding
459
+ of context alongside ciphertext, using the same encryption key across jobs:
460
+
461
+ ```ts
462
+ const example = Effect.gen(function* () {
463
+ const protectedCode = yield* protection.seal({
464
+ plaintext: otpCode,
465
+ aad: { purpose: "otp-delivery", challengeId: "challenge-A" }
466
+ })
467
+
468
+ const code = yield* protection.open({
469
+ protected: protectedCode,
470
+ aad: { purpose: "otp-delivery", challengeId: "challenge-A" }
471
+ }) // Matching context: succeeds.
472
+
473
+ yield* protection.open({
474
+ protected: protectedCode,
475
+ aad: { purpose: "otp-delivery", challengeId: "challenge-B" }
476
+ }) // Different expected context: authentication fails.
477
+ })
478
+ ```
479
+
480
+ Any benefit requires the expected context to be independently trustworthy;
481
+ copying ciphertext and all its expected context together defeats that check.
482
+ Deferring generic AAD does not disable authenticated encryption or remove
483
+ existing auth protections. In particular, do not silently strip the existing
484
+ OTP binding or break decryption of existing artifacts; any such consumer change
485
+ needs its own explicit decision and compatibility qualification. No new generic
486
+ AAD binding is to be added as part of this extraction.
487
+
488
+ ### Separate encryption and fingerprint keys
489
+
490
+ The owner accepted two independent application-managed secrets: one for
491
+ encryption and one for keyed semantic fingerprints. A fingerprint is not the
492
+ deduplication identity: the producer tuple locates a duplicate, and fingerprints
493
+ help detect changed protected content under that same identity without giving
494
+ the queue access to decryption keys.
495
+
496
+ The fingerprint key stays stable while retained jobs must be compared with new
497
+ production attempts. Routine encryption-key rotation must not change it. All
498
+ producers that need to compare within that storage/protection namespace use
499
+ compatible fingerprint configuration. Applications may provide secrets through
500
+ environment configuration or a secret manager; storage contains key identifiers
501
+ and fingerprints, never the secret keys. No particular environment variable names
502
+ or key-management implementation are mandated by this decision.
503
+
504
+ Preserve old decryption keys for as long as retained encrypted artifacts require
505
+ them. Changing a fingerprint key does not erase data, but without compatibility
506
+ handling the same plaintext yields a different fingerprint and can be rejected
507
+ as a semantic conflict instead of AlreadyPresent. Removing required encryption
508
+ keys can independently make old ciphertext unreadable.
509
+
510
+ Automatic fingerprint-key rotation with cross-key semantic comparison is not a
511
+ V1 requirement. Key compromise or deliberate fingerprint-key replacement needs
512
+ an explicit operational procedure; no transparent transition is promised.
513
+ Deriving purpose-separated keys from a single master secret is not inherently
514
+ unsafe, but it is not the selected V1 configuration because master rotation
515
+ couples the two key lifecycles. Auth's existing envelope/keyring implementation
516
+ still requires adaptation and qualification rather than an unreviewed rewrite.
517
+
518
+ ### Codec and equality contract
519
+
520
+ Allow finite JavaScript numbers, including fractional values, rather than
521
+ retaining the existing codec's safe-integer-only restriction. Reject NaN and
522
+ positive/negative Infinity before persistence. This does not provide exact
523
+ decimal arithmetic or approximate equality; applications can use integer minor
524
+ units or string-encoded decimal types when required.
525
+
526
+ Domain types may differ from persisted types (for example dates or bigints
527
+ encoded as strings). Support them only through Schemas with a persistable encoded
528
+ representation, not by silently coercing arbitrary JavaScript values. Encoding
529
+ and decoding service requirements remain explicit.
530
+
531
+ Use a deterministic canonical representation: object property order is
532
+ irrelevant, key ordering must not depend on locale, and array order is meaningful.
533
+ Compare an explicitly defined validated semantic representation, not mutable
534
+ lifecycle state or raw ciphertext. Decoded-domain equivalence and encoded-value
535
+ equivalence must not be silently interchanged across transformations.
536
+
537
+ Prefer Effect's `Schema.toEquivalence` where a structural comparator is needed,
538
+ with controlled fingerprint equivalence for protected values, rather than a
539
+ custom deep-equality implementation. Comparing correctly generated canonical
540
+ semantic-projection bytes also remains valid; this decision does not require
541
+ replacing that existing mechanism or honoring arbitrary user overrides that
542
+ ignore immutable fields. Equivalence is not serialization or validation.
543
+
544
+ Local runtime probes against Effect 4.0.0 confirmed order-independent struct
545
+ comparison, fractional-number equality, and fingerprint-only overrides inside
546
+ a tested Union. These probes do not qualify every Schema shape or repair the
547
+ existing projection traversal bug.
548
+
549
+ Support structs, arrays, optional fields, and unions with correct protected-field
550
+ handling. Transformations are supported only where encoded-side handling and
551
+ protected-field mapping can be made correct. Do not promise arbitrary custom or
552
+ recursive Schemas; unsupported shapes must fail explicitly rather than silently
553
+ skip protection. The known protected-subtree Union regression must be fixed.
554
+
555
+ Retain initial bounds: 64 KiB each for payload and semantic projection, depth 32,
556
+ at most 16 protected subtrees, and the existing 256-byte UTF-8 schema-path bound.
557
+ Exact supported-transform diagnostics and service provisioning must be specified
558
+ and tested before implementation is considered qualified. No new runtime API,
559
+ implementation, or agent dispatch is authorized by acceptance.
560
+
561
+ ## D6 — transaction composition and integration boundaries
562
+
563
+ Status: composition contract accepted by the owner on 2026-10-04. Tracked in
564
+ Multica as UPVE-858. Concrete shipped adapter coverage still needs qualification.
565
+
566
+ The owner subsequently selected the backend callback join approach (option A).
567
+ The core transaction capability has a private constructor; a qualified backend
568
+ bridge provides it inside a callback joining the application's active transaction:
569
+
570
+ ```ts
571
+ appDatabase.transaction((appTx) =>
572
+ PostgreSqlJobs.joinTransaction(appTx, (jobsTx) => definition.enqueue(jobsTx, input))
573
+ )
574
+ ```
575
+
576
+ These names remain illustrative. The bridge uses the same active connection and
577
+ source, without a fallback connection, new transaction, savepoint, independent
578
+ commit or replay. The capability is invalidated when the callback scope ends,
579
+ including interruption; later enqueue must fail with an actionable error.
580
+ Custom backend adapters need an explicit documented extension contract and
581
+ qualification, not a public core constructor accepting any insert callback.
582
+ This is lifetime/atomicity correctness, not a sandbox for trusted application code.
583
+
584
+ ### Two distinct capabilities
585
+
586
+ Keep `definition.enqueue(jobsTx, input)` as the canonical primitive: it uses the
587
+ supplied transaction and performs no BEGIN, COMMIT, ROLLBACK, connection borrowing,
588
+ or replay of domain operations.
589
+
590
+ A backend bridge joins an existing application transaction and exposes a scoped
591
+ job transaction capability. It does not open a transaction or create a savepoint.
592
+ Accept qualified transaction handles or an explicitly implemented adapter
593
+ contract, not arbitrary objects merely possessing a query method.
594
+
595
+ A configured `JobTransactions` integration may expose
596
+ `withTransaction((jobsTx) => body)`: delegate to the application transaction
597
+ manager to join a compatible active transaction or establish one when absent.
598
+ This is not a second transaction engine or a universal SQL client. Exact adapter
599
+ factory names are illustrative until specified and qualified.
600
+
601
+ ### Composition examples
602
+
603
+ ```ts
604
+ const program = Effect.gen(function* () {
605
+ const transactions = yield* JobTransactions
606
+ const billing = yield* Billing
607
+
608
+ return yield* transactions.withTransaction((jobsTx) =>
609
+ Effect.gen(function* () {
610
+ const invoice = yield* billing.insertInvoice(invoiceInput)
611
+ const result = yield* GenerateInvoiceV1.enqueue(jobsTx, {
612
+ producer,
613
+ payload: { invoiceId: invoice.id },
614
+ policy,
615
+ availableAt
616
+ })
617
+ yield* billing.insertReceipt(operationId, result.jobId)
618
+ return invoice
619
+ })
620
+ )
621
+ })
622
+ ```
623
+
624
+ Billing and the jobs integration must actually share transaction infrastructure;
625
+ wrapping an Effect does not make an independently configured pool join. Running
626
+ this program within a compatible outer transaction joins without another BEGIN,
627
+ savepoint, or independent commit. Otherwise the configured manager establishes
628
+ the transaction.
629
+
630
+ An application with explicit handles can retain its own transaction runner:
631
+
632
+ ```ts
633
+ const program = appDatabase.transaction((appTx) =>
634
+ PostgreSqlJobs.joinTransaction(appTx, (jobsTx) =>
635
+ Effect.gen(function* () {
636
+ yield* insertInvoice(appTx, invoice)
637
+ const result = yield* GenerateInvoiceV1.enqueue(jobsTx, input)
638
+ yield* insertReceipt(appTx, operationId, result.jobId)
639
+ })
640
+ )
641
+ )
642
+ ```
643
+
644
+ These examples describe API direction, not existing exports or blanket support
645
+ for every application's transaction handle.
646
+
647
+ ### Auth and other application integrations
648
+
649
+ The auth adapter delegates join-or-establish behavior to its existing
650
+ `PostgreSqlExecutor`, using the same connection-source service, rather than
651
+ copying the executor into jobs. The current auth implementation joins same-source
652
+ transactions without savepoints and disables local mutation replay/reconciliation
653
+ when joining. Matching connection strings alone do not establish shared scope.
654
+
655
+ Other applications can supply backend transaction bridges and manager adapters
656
+ without depending on auth. Effect SQL integration must join its actual active
657
+ transaction connection, not call nested `withTransaction` merely to enqueue:
658
+ Effect SQL 4 normally uses savepoints for nested wrappers. Drizzle integration
659
+ must execute and await the Effect within its owning transaction callback.
660
+ These are integration requirements, not claims of qualified adapter support.
661
+
662
+ ### Ownership and failure semantics
663
+
664
+ The outer owner controls commit, rollback, and replay of the complete business
665
+ operation. Joined operations do not independently replay or reconcile the outer
666
+ commit. Unknown commit knowledge stays unknown and requires application-owned
667
+ workflow reconciliation. Inner success is provisional until outer commit.
668
+
669
+ Sequence operations sharing a transaction; keep transaction capabilities scoped
670
+ to the owning invocation, not application-lifetime Layers. A bridge must not
671
+ silently fall back to a different connection when asked to join a particular
672
+ transaction. Application-owned pools and migrations remain outside the queue;
673
+ workers must not close borrowed pools or run DDL on startup.
674
+
675
+ ### One-off production and deferred convenience
676
+
677
+ One-off production can explicitly use the configured wrapper even without domain
678
+ writes to compose:
679
+
680
+ ```ts
681
+ const program = Effect.gen(function* () {
682
+ const transactions = yield* JobTransactions
683
+ return yield* transactions.withTransaction((jobsTx) =>
684
+ GenerateInvoiceV1.enqueue(jobsTx, input)
685
+ )
686
+ })
687
+ ```
688
+
689
+ The owner withdrew the subsequent `unsafeEnqueue` question and accepted the
690
+ preceding explicit-transaction design. Do not add `unsafeEnqueue`, a one-argument
691
+ enqueue overload, or a non-atomic fast path as part of this decision. D1's
692
+ deferred standalone convenience scope remains unchanged.
693
+
694
+ Before backend implementation, specify and qualify the concrete driver/bridge,
695
+ Layer construction, scoped-handle behavior, source sharing, commit-loss handling,
696
+ and rollback guarantees. The initial recommendation is to qualify the existing
697
+ auth/pg execution integration first, without making it a dependency of core or
698
+ claiming untested Effect SQL/Drizzle adapter support. No implementation, dispatch,
699
+ or publication is authorized by this decision.
700
+
701
+ ## D7 — new generic PostgreSQL storage without legacy compatibility
702
+
703
+ Status: accepted by the owner on 2026-10-04. Tracked in Multica as UPVE-859.
704
+ The owner confirmed effect-auth has no users or data requiring preservation.
705
+
706
+ Choose variant B: design the generic storage directly, without an auth-specific
707
+ legacy adapter, historical-data backfill, dual reader/writer, or parallel queue
708
+ runtime. This is a breaking alpha storage change for new installations, not
709
+ authorization to delete data or reset a database.
710
+
711
+ ### Storage structure
712
+
713
+ Use two generic relations:
714
+
715
+ - `jobs`: generated job ID, producer identity, catalog identity, persisted policy,
716
+ availability, and mutable lifecycle state, counters, and lease ownership.
717
+ - `job_payloads`: immutable encoded payload and its semantic projection, linked
718
+ to the parent job. Lifecycle attempts do not rewrite the payload.
719
+
720
+ Do not add auth-specific user/challenge/event fields to the generic table.
721
+ Domain data remains in the application payload or application-owned relations.
722
+ Auth keeps its domain authority, definitions, handlers, receipts, and transactions
723
+ that atomically compose domain, audit, job, and receipt writes.
724
+
725
+ ### Schema integration and migration ownership
726
+
727
+ Provide optional Drizzle schema declarations with configurable namespace and
728
+ table names. The library defines required columns, constraints, and indexes;
729
+ arbitrary per-column remapping is not required. Core does not depend on Drizzle.
730
+ Backend and migration configuration must agree on the same storage mapping.
731
+
732
+ Proposed API direction, not existing or finalized exports:
733
+
734
+ ```ts
735
+ import { makeJobTables } from "effect-jobs/PostgreSqlDrizzleSchema"
736
+
737
+ export const jobTables = makeJobTables({
738
+ schema: "public",
739
+ jobsTable: "jobs",
740
+ payloadsTable: "job_payloads"
741
+ })
742
+
743
+ const JobsLayer = PostgreSqlJobs.layer({
744
+ connectionSource,
745
+ tables: jobTables
746
+ })
747
+ ```
748
+
749
+ Applications generate and run migrations. Runtime must not create, migrate, or
750
+ rebuild tables automatically. Concrete DDL, schema readiness checks, mapping
751
+ types, and backend constructors still need specification and qualification.
752
+ Optional Drizzle integration must not impose Drizzle on applications using a
753
+ different migration tool.
754
+
755
+ ### Compatibility and qualification
756
+
757
+ There is no obligation to migrate historical Pending/Active/Completed/Dead/
758
+ Isolated rows or old receipts under the confirmed deployment profile. A physical
759
+ storage change alone does not change a job's catalog version; changes to its
760
+ semantic contract must be evaluated separately.
761
+
762
+ Tests must still qualify atomicity, concurrent production, conflict handling,
763
+ ownership fencing, recovery, packed imports, and migration/readiness behavior.
764
+ Absence of existing consumers removes upgrade machinery, not correctness
765
+ requirements. Acceptance does not authorize implementation, agent dispatch,
766
+ database resets, commit, push, or publication.
767
+
768
+ ## D8 — replaceable cleanup with application-domain retention integration
769
+
770
+ Status: variant A and terminal-state rules accepted by the owner on 2026-10-04.
771
+ Tracked in Multica as UPVE-862.
772
+
773
+ ### Decision and ownership
774
+
775
+ Provide a replaceable `JobCleanup` port with a bounded cleanup operation. Provide
776
+ a generic PostgreSQL implementation for installations without additional domain
777
+ references, and an application-owned implementation for domains that need to
778
+ coordinate jobs with receipts or other relations. Exact service and constructor
779
+ names are proposed, not existing exports.
780
+
781
+ Effect-auth retains a PostgreSQL cleanup adapter that understands registration
782
+ receipts and OTP challenge/issue-receipt relationships while using the generic
783
+ jobs storage. Do not move auth-specific cleanup SQL into the generic package or
784
+ install generic cleanup alongside the auth adapter in a way that bypasses its
785
+ retention checks. An application with domain references must supply a coordinated
786
+ adapter rather than assume generic cleanup knows those references.
787
+
788
+ Do not introduce generic retention holds or their additional persistence and
789
+ lifecycle machinery in V1. The adapter is a qualified backend integration, not
790
+ an arbitrary SQL callback claimed to be safe merely because it implements a port.
791
+
792
+ ### Terminal-state rules
793
+
794
+ - Pending and Active jobs are never cleanup candidates.
795
+ - Completed jobs can be removed after their persisted retention period and
796
+ applicable domain protection conditions are satisfied.
797
+ - Dead jobs require explicit retention policy; do not infer a deletion period.
798
+ - Isolated jobs are never automatically purged.
799
+
800
+ Retention supports explicit duration or Forever choices where applicable. The
801
+ illustrative 30-day duration is not an approved mandatory default. Retention is
802
+ persisted execution configuration subject to D3's first-write-wins rule, not a
803
+ value silently replaced by a new deployment or duplicate enqueue.
804
+
805
+ ### Deduplication and reference safety
806
+
807
+ Retention must cover the application's required deduplication window. Once all
808
+ durable evidence of a producer identity is removed, re-enqueue can create a new
809
+ job; no permanent deduplication guarantee is made. Receipts still needed for
810
+ reconciliation or idempotent response replay must protect associated jobs for
811
+ their required lifetime.
812
+
813
+ Coordinate eligibility checks, applicable expired-receipt removal, and terminal
814
+ job deletion atomically, with a specified lock order and structural referential
815
+ integrity. Do not check references separately and later delete without guarding
816
+ the intervening race. The current auth cleanup SQL is prior art and must be
817
+ adapted and qualified rather than blindly copied.
818
+
819
+ ### Maintenance and qualification
820
+
821
+ Applications explicitly invoke cleanup, for example through a scheduled
822
+ maintenance program; imports and handler installation do not start deletion.
823
+ The operation is bounded by a caller-supplied limit. Proposed composition:
824
+
825
+ ```ts
826
+ const maintenance = Effect.gen(function* () {
827
+ const cleanup = yield* JobCleanup
828
+ return yield* cleanup.cleanup({ limit: 100 })
829
+ })
830
+ ```
831
+
832
+ Applications select the generic PostgreSQL implementation or their domain
833
+ implementation through ordinary Layer composition, not two independent cleanup
834
+ paths over the same protected jobs. Maintenance must use database time and the
835
+ configured connection infrastructure.
836
+
837
+ Before qualification, specify exact retention fields, deletion authority, lock
838
+ order, and adapter setup. Test cleanup racing with producer retries, receipt
839
+ protection/expiry, bounded deletion, payload-parent deletion, and exclusion of
840
+ Active/Pending/Isolated jobs. Acceptance closes the architecture decision, not
841
+ runtime qualification, and does not authorize implementation or agent dispatch.