ADR 0004. Promotion runs on its own cron

Status: accepted. Implemented 2026-08-24 in kevinvu184/up. Amends ADR 0003.

Context

As first built, silver promotion ran as step 4 of the daily poller. The module-level separation held (pure transform, I/O at the edges, one composition root), but the run-level coupling had two concrete defects. First, entangled failure semantics: a transform rejection threw, so the run wrote last_error and never wrote last_sync_at, reporting a failed sync even when backfill and the incremental window had succeeded and committed their pages. Second, budget contention: promotion competed with backfill for the same 45 seconds, and during backfill that is the wrong trade, because syncing from the rate-limited external API is the scarce work while promotion reads local data and can run any time.

Decisions

A separate promote cron

promote.cron.ts derives silver from bronze on its own hourly schedule; the poller goes back to syncing only. Each cron is its own composition root with the same shape as before: migrate, lock, work in budget-checked batches, record state, release. The poller's success record returns to backfill and incremental counts.

No coordination between the two crons

The promoter needs no mutual exclusion against the poller, only against itself. Three properties already in place make this true: the promoter reads only committed bronze rows, silver upserts are idempotent, and each silver batch lands its rows and watermark in one transaction. Anything the poller commits mid-promotion is picked up on the promoter's next pass. Nothing new was built to make the split safe; the split is cheap because ADR 0002 and 0003 paid for it.

Per-job lock rows

The promoter gets its own seeded lock row, promote_lock, via migration 4. The lock store is parameterised by key (makeLockStore(key)) instead of hardcoding one row; the lock module itself did not change, since LockDeps never named the row. Overlapping manual triggers of the promoter exclude each other exactly as the poller's do, with the same TTL recovery.

Per-job state keys

The promoter records promote_last_run (timestamp and rows promoted) and promote_last_error, with the same reset semantics as the poller's keys. Without this, the two jobs would clobber a shared last_error, and the panel could not say which job failed. In code, the run-state factory split the same way: makeRunState serves the poller, makePromoteState serves the promoter, and neither carries the other's methods.

Both crons migrate first

Each cron runs the migration runner at the top, because either may fire first after a deploy that adds a migration. The runner is versioned, so the second arrival applies nothing. Two crons applying the same pending migration at the same instant would fail loudly on the second CREATE or INSERT, which is the project's preferred failure mode and self-heals on the next trigger.

Consequences

Sync failures and promotion failures now report independently: a transform rejection halts promotion and writes promote_last_error while the poller keeps syncing and last_sync_at stays truthful. Silver lag drops from a day to at most an hour. The costs are two schedules and two log streams to keep in mind, and one more seeded row in meta. The module layout is unchanged; only the composition roots multiplied, which is what composition roots are for.