Status: accepted · Date: 2026-10-31
Enforcement: JobName in packages/api/src/platform/jobs/failure.ts is a closed Schema.Literals schema; scripts/leaf-modules.test.mjs and packages/api/src/exports.test.ts keep each *.jobs.ts module private to the tick (no public export, no feature outside platform/jobs/run.ts may import it).
Context
Wundklar's background workload is the usual mix of outbox dispatch (reports, KIM, reminders), housekeeping scans (share-link expiry, retention purge, object cleanup, access-state transitions, visit scheduling), checkpointed exports (exportsBuild), and audit sealing (trail). Cloudflare's Queues and Workflows product line would be the "obvious" home for every one of them. ADR-0005 already runs the one scheduled tick as one Effect program (25 s deadline, one pool, independent jobs). Adding Queues or Workflows per job would mean a second deployment target, a second control plane (DLQs, producer bindings, consumer concurrency), and a second place where "where did my job go?" is asked. We asked whether that cost was worth paying for Wundklar's cadence.
Decision
Report generation, KIM delivery, share-link expiry, markdown and ZIP export building, retention purge, reminder dispatch, visit scheduling, object cleanup, access-state transitions and trail sealing/verification run as leased Postgres queues or idempotent scans inside the one cron tick (packages/api/src/platform/jobs/run.ts). Latency-sensitive paths kick the next tick with waitUntil from the request that produced the work. No Cloudflare Queues or Workflows binding.
Every job in §1.9 of the plan:
- registers one entry in the closed
JobNameschema; - lives in
features/<feature>/<feature>.jobs.ts, imported only byplatform/jobs/run.ts(D4); - runs through
isolated(jobName, …)so a typed failure or a defect is reported alongside its siblings, never cascading; - either leases its own queue row (
report_job,kim_outbox,export_job) or runs an idempotent scan (expired links, patients past retention, prescriptions near expiry).
Consequences
Good. One deployment target (the Worker) and one control plane (Postgres rows) across every background path. DB-only observability: a leased row shows the last attempt, the next deadline and the error, and JobStepFailure aggregates into one tick summary. The tick's deadline, pool and tracer are inherited for free; no second rate limit, no second schema migration, no second DLQ to inspect.
Bad. The 25 s tick is a cap, so a job that would run long must checkpoint and resume on the next tick (exportsBuild is the written-down case, ≤ 15 s per slice). Fan-out above one row per feature per tick needs the batch sizes to be chosen in the job, because Effect.timeoutOption has no notion of "how much is left". The decision is reversible per job: moving one to a Queue is a localised change (one new binding, one consumer) once the cadence outgrows the tick.