# Background work stays in the one cron tick

The repository's architectural decision, rationale and consequences.

# Background work stays in the one cron tick

**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 [#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 [#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 `JobName` schema;
- lives in `features/<feature>/<feature>.jobs.ts`, imported only by `platform/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 [#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.

