feat: recurrence engine — recurring commitments spawn their cycles #20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/recurrence-engine"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A recurring commitment is a standing promise — Rainbow's monthly M&S, Keizen's subscription, the Tethys retainer. This PR is what turns that promise into the month's actual work, nightly and unattended: an emission event recording that the cycle happened, an obligation when the cycle is money owed to us, and an adm task when it needs a human.
The idempotency argument
The unique index is the correctness proof. Nothing in the Python is.
Migration 005 adds:
A cycle is identified by its period label —
2026-09for a monthly,2027for an annual. So September can be emitted exactly once per commitment, and PostgreSQL is what says so. Every guard in the engine — the skip-if-present check, the replay-safeemit_obligation, one transaction per commitment — makes a second run quiet. It was already safe.That distinction matters because the failure modes are all real, and none of them are hypothetical:
next_duealready advanced)next_dueback to re-bill a monthnext_dueis left wound back for them to fix rather than silently swallowedThe index is partial so it constrains only events that are cycles. Every lifecycle event and every hand-made emission carries
period IS NULLand is untouched — verified by test.Two further deliberate properties:
next_duecannot fire a burst of invoices before anyone notices.next_dueadvance are one transaction. Task creation is an HTTP call to the kernel and happens after it commits — a finance record must not be held hostage to another service being reachable. A failed task is stampedtask_failedon the emission event, the money stands, and--repair-tasksfiles the backlog later.The spawn matrix
An obligation is raised only when the cycle is money they owe us and the commitment carries an amount. A task is raised only when
metadata.recurrence.workstreamis set. A commitment can produce both, either or neither — the emission event is the only thing every cycle writes.rainbow-monthly-mswe_owerainbow-centrekeizen-subscriptionthey_oweSGD 2,000next_due+ 5flexi-tethys-maintenancethey_oweSGD 2,600next_due+ 14tianlong-corpsec-renewalwe_owedue_datealready on the dashboardRainbow raises no receivable despite carrying an amount, because the direction is ours: the cycle is work we owe, not money they owe. That is the one non-obvious row and it is pinned by a test.
Driven for real against a scratch PostgreSQL:
Second run, same day:
No commitment is due.next_duewound back to September:3 already present, 0 spawned, and the event/obligation counts do not move.What is in the change
alembic/versions/005_add_recurrence.py—commitment.next_due,commitment.recur_interval,commitment_event.period,uq_commitment_event_period,ix_commitment_next_due. Hand-written guarded SQL in 003/004's style, purely additive, safe to apply with the app running. Models updated to match.CommitmentService.emit_obligation(period=…, event_date=…)— naming the cycle makes the call replay-safe: re-emitting a recorded period returns the original event and the obligation it raised. Withoutperiodthe behaviour is byte-for-byte what it was. The status guard runs after the replay lookup on purpose — once a cycle exists it is history, and history must stay readable after the commitment is fulfilled.scripts/spawn_due_cycles.py— the engine.--dry-run,--repair-tasks,--as-of. Planning is separated from execution (plan_cyclereturns data), so every decision is testable with no database in sight.scripts/seed_recurrence.py— arms the three, idempotently, and only if not already armed, so a rerun cannot wind back anext_duethe engine advanced or a human pulled earlier. Not invoked by CI or the entrypoint.app.json— Dokku scheduled cron,0 1 * * *,concurrency_policy: forbid.entrypoint.sh— a comment, not a change. The cron reaches the script throughexec "$@"; a future "simplification" would silently start a second API container every night instead of billing anyone.HASKOS_API_URL/HASKOS_API_KEY— optional settings. Required-at-boot would take the container down on the very deploy that introduces them.Deploy Runbook
Ordered. Steps 3 and 4 are human-only.
Merge to
dev. CI green first. Merging auto-deploys — nothing below happens until it does.Deploy applies migration 005 and installs the cron. The entrypoint runs
alembic upgrade headat boot; Dokku readsapp.jsonon the same deploy. Verify both:If
cron:listis empty, the deploy did not pick upapp.json— stop and investigate before step 5.Set the kernel API config (values from Vaultwarden; the key needs
contributoror a*scope sorequire_membershippasses forrainbow-centre):This restarts the app. Until it is done, cycles spawn correctly and their tasks are stamped
task_failed.Arm the three commitments:
Expect
3 commitment(s) armed. A rerun reports them already armed and changes nothing.Verify without writing:
Before 2026-09-01 this correctly prints
No commitment is due. To rehearse the real thing:which must print the three-row matrix above and write nothing.
First live fire: 2026-09-01, 01:00 UTC. Read the cron log the next morning; expect 3 spawned, 2 obligations, 1 task in
rainbow-centre.⚠️ August caveat.
next_duestarts at September because Rainbow's August cycle is already handled by hand (ADM-RAINBOW-CENTRE-001/002). Keizen's and Tethys' August status was unknown when this was written. If either turns out to be unbilled for August, before 1 September run:The engine will spawn August on the next run and September on the one after — catch-up is one cycle per run.
If tasks failed (step 3 skipped, or the kernel was unreachable):
... scripts/spawn_due_cycles.py --repair-tasks. Safe at any time; it spawns nothing new.To disarm a commitment:
UPDATE commitment SET next_due = NULL WHERE id = …. There is no status for it, on purpose.Validation
uv syncruff check app/ tests/ scripts/ruff format --check app/ tests/ scripts/uv run alembic headsc5e19a7d3f60 (head)uv run pytest tests/ -qalembic checkon the new objectscommitment/commitment_event— see calloutsHASKOS_API_URL/HASKOS_API_KEYThe database tests (
tests/test_recurrence_db.py) skip unlessHASKOS_TEST_DATABASE_URLis set. They prove the things stubs cannot: that a double run bills September once, that a wound-backnext_duecannot re-bill, that a raw duplicate insert is rejected by the index, that null-period lifecycle events are unaffected, that a dry run writes nothing to a real database, and thatadvance_duelands exactly wheredate + intervalwould for ten cases including month-end clamping and a leap day.Spec Drift Callouts
POST /workstreams/{slug-or-id}/taskstakes a UUID, not a slug.create_taskin smeos isasync def create_task(ws_id: UUID, …)and 422s on a slug.GET /workstreams/by-slug/{slug}is the only slug-addressed route. The engine therefore resolves slug → id first and skips the lookup when the configured value already parses as a UUID.HASKOS_API_URL/HASKOS_API_KEYneeded an explicit alias.FinanceSettingscarriesenv_prefix="FINANCE_", so plain fields would have readFINANCE_HASKOS_API_URL. They are declared withAliasChoices("HASKOS_API_URL", "FINANCE_HASKOS_API_URL")so the spec's spelling is the primary one — it names the kernel's API, and every other kernel var on this app isHASKOS_*.emit_obligationalso gainedevent_date. Beyond the specifiedperiod. One keyword, defaulted, no behavioural change when unset — but without it a late or backfilled run dates the emission event to the day the cron happened to fire rather than the day the cycle fell due, which corrupts the audit trail the period column exists to keep straight.next_due/recur_intervalare accepted byPOST /commitmentsand returned by the API, andperiodis returned on events. Not specified. A column only a seed script can ever set is a wart, and the dashboard cannot show what the API does not return.The advance is computed in Python, not SQL. The spec's "
next_due = next_due + recur_interval" reads as SQL. asyncpg infers a bind parameter's type from the cast around it, soCAST(:interval AS INTERVAL)types the parameter as an interval and then rejects the string it is handed —invalid input for query argument $2: '10 days' ('str' object has no attribute 'days'). Same family as the::uuidhazard from PR #15.advance_duemirrors PostgreSQL exactly, including month-end clamping, and the scratch-DB test asserts the two agree.A task failure exits 1 rather than exiting 0 with a log line. Silent for six months is the worse failure. The financial spawn is already committed and re-running is always safe, so a non-zero cron exit is a notification, not a rollback.
seed_recurrence.pyarms only unarmed commitments. Specified as "idempotent"; unconditionalSETis idempotent but would let a rerun wind back a date the engine advanced or a human corrected. This followsseed_commitments.py's stated philosophy — a rerun never overwrites hand-edits.The seed script was not run, per instruction. Its
ARMstatement andcommitment_id()are exercised —tests/test_recurrence_db.pydrives the shipping statement against a scratch database so the SQL that deploys is the SQL that was tested, and a unit test asserts its ids matchseed_commitments.py's.main()was never called and no real database was touched.CI does not run pytest.
ci.ymlisdetect-changes→lint(ruff only) →docker-build(+ non-debug smoke test) →deploy. The 180 tests are a local gate, not a merge gate. Not fixed here — adding a test job changes this repo's CI posture and belongs in its own PR.alembic checkfails on two pre-existing index drifts, neither related to this change:billing_profile_invoice_prefix_key(fromf64a7ec6edcf) andix_invoice_status(from001) exist in the database but not on the models. Nothing oncommitmentorcommitment_eventis flagged — the new index/column parity is clean. Left alone:env.py's docstring wantsalembic checkas a CI gate, and it cannot be turned on until those two are declared, but that is a separate change to the invoice and billing-profile models.The cron invocation path is not exercised by any automated test. CI's smoke test boots the web process; nothing boots the image with an appended command. Step 5 of the runbook (
dokku run … --dry-run) is what provesexec "$@"+/app/.venv/bin/pythonon the real image, and it must be done before 1 September.No
.gitignorein this repo.__pycache__/shows as untracked in every checkout. Staged explicitly here rather than withgit add -A. Worth a one-line fix in a housekeeping PR.Not merged
Merging auto-deploys. Left for a human.