feat: recurrence engine — recurring commitments spawn their cycles #20

Merged
john merged 1 commit from feat/recurrence-engine into dev 2026-08-03 22:37:55 +00:00
Owner

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:

CREATE UNIQUE INDEX uq_commitment_event_period
    ON commitment_event (commitment_id, period)
 WHERE period IS NOT NULL

A cycle is identified by its period label — 2026-09 for a monthly, 2027 for 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-safe emit_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:

Failure What happens
Dokku's scheduler fires twice Second run finds nothing due (next_due already advanced)
Container retried mid-run Committed cycles are skipped as present; uncommitted ones spawn
Human runs the script by hand Same — skipped as present
Human winds next_due back to re-bill a month Skipped as present, and next_due is left wound back for them to fix rather than silently swallowed
Two runs genuinely concurrent The loser's transaction aborts on the index, is caught per-commitment, logged, and the sweep continues

The index is partial so it constrains only events that are cycles. Every lifecycle event and every hand-made emission carries period IS NULL and is untouched — verified by test.

Two further deliberate properties:

  • Catch-up is one cycle per run. A commitment three months behind spawns June tonight, July tomorrow, August the night after. A mis-set next_due cannot fire a burst of invoices before anyone notices.
  • Money and tasks are separated by the commit. The emission event, the obligation and the next_due advance 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 stamped task_failed on the emission event, the money stands, and --repair-tasks files 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.workstream is set. A commitment can produce both, either or neither — the emission event is the only thing every cycle writes.

Commitment Direction Obligation Task Terms
rainbow-monthly-ms we_owe rainbow-centre Net 45 recorded, invoice raised by hand against PO POHQ-000528
keizen-subscription they_owe SGD 2,000 receivable, due next_due + 5 Net 5
flexi-tethys-maintenance they_owe SGD 2,600 receivable, due next_due + 14 Net 14 (default)
tianlong-corpsec-renewal we_owe not armed not armed Annual, no amount, no workstream — stays manual, due_date already on the dashboard

Rainbow 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:

[spawn  ] Tethys maintenance retainer | period 2026-09 | obligation SGD 2600.00 due 2026-09-15 | no task | next_due 2026-09-01 -> 2026-10-01
[spawn  ] Monthly invoice + analytics report — Rainbow Centre M&S | period 2026-09 | no obligation | task -> rainbow-centre | next_due 2026-09-01 -> 2026-10-01
[spawn  ] Keizen subscription | period 2026-09 | obligation SGD 2000.00 due 2026-09-06 | no task | next_due 2026-09-01 -> 2026-10-01

Second run, same day: No commitment is due. next_due wound 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.pycommitment.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. Without period the 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_cycle returns 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 a next_due the 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 through exec "$@"; 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.

  1. Merge to dev. CI green first. Merging auto-deploys — nothing below happens until it does.

  2. Deploy applies migration 005 and installs the cron. The entrypoint runs alembic upgrade head at boot; Dokku reads app.json on the same deploy. Verify both:

    ssh contabo-sg dokku cron:list haskos-finance      # expect: 0 1 * * * /app/.venv/bin/python scripts/spawn_due_cycles.py
    ssh contabo-sg dokku run haskos-finance /app/.venv/bin/alembic heads   # expect exactly: c5e19a7d3f60 (head)
    

    If cron:list is empty, the deploy did not pick up app.json — stop and investigate before step 5.

  3. Set the kernel API config (values from Vaultwarden; the key needs contributor or a * scope so require_membership passes for rainbow-centre):

    ssh contabo-sg dokku config:set haskos-finance HASKOS_API_URL=https://haskos.haskytech.com HASKOS_API_KEY=...
    

    This restarts the app. Until it is done, cycles spawn correctly and their tasks are stamped task_failed.

  4. Arm the three commitments:

    ssh contabo-sg dokku run haskos-finance /app/.venv/bin/python scripts/seed_recurrence.py
    

    Expect 3 commitment(s) armed. A rerun reports them already armed and changes nothing.

  5. Verify without writing:

    ssh contabo-sg dokku run haskos-finance /app/.venv/bin/python scripts/spawn_due_cycles.py --dry-run
    

    Before 2026-09-01 this correctly prints No commitment is due. To rehearse the real thing:

    ... scripts/spawn_due_cycles.py --dry-run --as-of 2026-09-01
    

    which must print the three-row matrix above and write nothing.

  6. 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_due starts 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:

    UPDATE commitment SET next_due = DATE '2026-08-01' WHERE id = '<keizen or tethys id>';
    

    The engine will spawn August on the next run and September on the one after — catch-up is one cycle per run.

  7. 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

Check Result
uv sync clean
ruff check app/ tests/ scripts/ All checks passed
ruff format --check app/ tests/ scripts/ 46 files already formatted
uv run alembic heads exactly one — c5e19a7d3f60 (head)
Full chain 001→005 on scratch PostgreSQL 17 replays clean from empty
uv run pytest tests/ -q 180 passed (158 dependency-free + 22 against scratch PostgreSQL)
alembic check on the new objects no drift on commitment / commitment_event — see callouts
App boots without HASKOS_API_URL / HASKOS_API_KEY verified by import
CLI driven end-to-end on scratch PG spawn → re-run → wind back → repair, all as designed

The database tests (tests/test_recurrence_db.py) skip unless HASKOS_TEST_DATABASE_URL is set. They prove the things stubs cannot: that a double run bills September once, that a wound-back next_due cannot 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 that advance_due lands exactly where date + interval would for ten cases including month-end clamping and a leap day.

Spec Drift Callouts

  1. POST /workstreams/{slug-or-id}/tasks takes a UUID, not a slug. create_task in smeos is async 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.

  2. HASKOS_API_URL / HASKOS_API_KEY needed an explicit alias. FinanceSettings carries env_prefix="FINANCE_", so plain fields would have read FINANCE_HASKOS_API_URL. They are declared with AliasChoices("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 is HASKOS_*.

  3. emit_obligation also gained event_date. Beyond the specified period. 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.

  4. next_due / recur_interval are accepted by POST /commitments and returned by the API, and period is 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.

  5. 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, so CAST(: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 ::uuid hazard from PR #15. advance_due mirrors PostgreSQL exactly, including month-end clamping, and the scratch-DB test asserts the two agree.

  6. 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.

  7. seed_recurrence.py arms only unarmed commitments. Specified as "idempotent"; unconditional SET is idempotent but would let a rerun wind back a date the engine advanced or a human corrected. This follows seed_commitments.py's stated philosophy — a rerun never overwrites hand-edits.

  8. The seed script was not run, per instruction. Its ARM statement and commitment_id() are exercised — tests/test_recurrence_db.py drives 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 match seed_commitments.py's. main() was never called and no real database was touched.

  9. CI does not run pytest. ci.yml is detect-changeslint (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.

  10. alembic check fails on two pre-existing index drifts, neither related to this change: billing_profile_invoice_prefix_key (from f64a7ec6edcf) and ix_invoice_status (from 001) exist in the database but not on the models. Nothing on commitment or commitment_event is flagged — the new index/column parity is clean. Left alone: env.py's docstring wants alembic check as 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.

  11. 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 proves exec "$@" + /app/.venv/bin/python on the real image, and it must be done before 1 September.

  12. No .gitignore in this repo. __pycache__/ shows as untracked in every checkout. Staged explicitly here rather than with git add -A. Worth a one-line fix in a housekeeping PR.

Not merged

Merging auto-deploys. Left for a human.

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: ```sql CREATE UNIQUE INDEX uq_commitment_event_period ON commitment_event (commitment_id, period) WHERE period IS NOT NULL ``` A cycle is identified by its period label — `2026-09` for a monthly, `2027` for 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-safe `emit_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: | Failure | What happens | |---|---| | Dokku's scheduler fires twice | Second run finds nothing due (`next_due` already advanced) | | Container retried mid-run | Committed cycles are skipped as present; uncommitted ones spawn | | Human runs the script by hand | Same — skipped as present | | Human winds `next_due` back to re-bill a month | Skipped as present, and `next_due` is **left** wound back for them to fix rather than silently swallowed | | Two runs genuinely concurrent | The loser's transaction aborts on the index, is caught per-commitment, logged, and the sweep continues | The index is partial so it constrains only events that *are* cycles. Every lifecycle event and every hand-made emission carries `period IS NULL` and is untouched — verified by test. Two further deliberate properties: - **Catch-up is one cycle per run.** A commitment three months behind spawns June tonight, July tomorrow, August the night after. A mis-set `next_due` cannot fire a burst of invoices before anyone notices. - **Money and tasks are separated by the commit.** The emission event, the obligation and the `next_due` advance 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 stamped `task_failed` on the emission event, the money stands, and `--repair-tasks` files 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.workstream` is set. A commitment can produce both, either or neither — the emission event is the only thing every cycle writes. | Commitment | Direction | Obligation | Task | Terms | |---|---|---|---|---| | `rainbow-monthly-ms` | `we_owe` | — | `rainbow-centre` | Net 45 recorded, invoice raised by hand against PO POHQ-000528 | | `keizen-subscription` | `they_owe` SGD 2,000 | receivable, due `next_due` + 5 | — | Net 5 | | `flexi-tethys-maintenance` | `they_owe` SGD 2,600 | receivable, due `next_due` + 14 | — | Net 14 (default) | | `tianlong-corpsec-renewal` | `we_owe` | **not armed** | **not armed** | Annual, no amount, no workstream — stays manual, `due_date` already on the dashboard | Rainbow 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: ``` [spawn ] Tethys maintenance retainer | period 2026-09 | obligation SGD 2600.00 due 2026-09-15 | no task | next_due 2026-09-01 -> 2026-10-01 [spawn ] Monthly invoice + analytics report — Rainbow Centre M&S | period 2026-09 | no obligation | task -> rainbow-centre | next_due 2026-09-01 -> 2026-10-01 [spawn ] Keizen subscription | period 2026-09 | obligation SGD 2000.00 due 2026-09-06 | no task | next_due 2026-09-01 -> 2026-10-01 ``` Second run, same day: `No commitment is due`. `next_due` wound 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. Without `period` the 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_cycle` returns 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 a `next_due` the 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 through `exec "$@"`; 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. 1. **Merge to `dev`.** CI green first. Merging auto-deploys — nothing below happens until it does. 2. **Deploy applies migration 005 and installs the cron.** The entrypoint runs `alembic upgrade head` at boot; Dokku reads `app.json` on the same deploy. Verify both: ```bash ssh contabo-sg dokku cron:list haskos-finance # expect: 0 1 * * * /app/.venv/bin/python scripts/spawn_due_cycles.py ssh contabo-sg dokku run haskos-finance /app/.venv/bin/alembic heads # expect exactly: c5e19a7d3f60 (head) ``` If `cron:list` is empty, the deploy did not pick up `app.json` — stop and investigate before step 5. 3. **Set the kernel API config** (values from Vaultwarden; the key needs `contributor` or a `*` scope so `require_membership` passes for `rainbow-centre`): ```bash ssh contabo-sg dokku config:set haskos-finance HASKOS_API_URL=https://haskos.haskytech.com HASKOS_API_KEY=... ``` This restarts the app. Until it is done, cycles spawn correctly and their tasks are stamped `task_failed`. 4. **Arm the three commitments:** ```bash ssh contabo-sg dokku run haskos-finance /app/.venv/bin/python scripts/seed_recurrence.py ``` Expect `3 commitment(s) armed`. A rerun reports them already armed and changes nothing. 5. **Verify without writing:** ```bash ssh contabo-sg dokku run haskos-finance /app/.venv/bin/python scripts/spawn_due_cycles.py --dry-run ``` Before 2026-09-01 this correctly prints `No commitment is due`. To rehearse the real thing: ```bash ... scripts/spawn_due_cycles.py --dry-run --as-of 2026-09-01 ``` which must print the three-row matrix above and write nothing. 6. **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_due` starts 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: ```sql UPDATE commitment SET next_due = DATE '2026-08-01' WHERE id = '<keizen or tethys id>'; ``` The engine will spawn August on the next run and September on the one after — catch-up is one cycle per run. 7. **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 | Check | Result | |---|---| | `uv sync` | clean | | `ruff check app/ tests/ scripts/` | All checks passed | | `ruff format --check app/ tests/ scripts/` | 46 files already formatted | | `uv run alembic heads` | exactly one — `c5e19a7d3f60 (head)` | | Full chain 001→005 on scratch PostgreSQL 17 | replays clean from empty | | `uv run pytest tests/ -q` | **180 passed** (158 dependency-free + 22 against scratch PostgreSQL) | | `alembic check` on the new objects | no drift on `commitment` / `commitment_event` — see callouts | | App boots without `HASKOS_API_URL` / `HASKOS_API_KEY` | verified by import | | CLI driven end-to-end on scratch PG | spawn → re-run → wind back → repair, all as designed | The database tests (`tests/test_recurrence_db.py`) skip unless `HASKOS_TEST_DATABASE_URL` is set. They prove the things stubs cannot: that a double run bills September once, that a wound-back `next_due` cannot 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 that `advance_due` lands exactly where `date + interval` would for ten cases including month-end clamping and a leap day. ## Spec Drift Callouts 1. **`POST /workstreams/{slug-or-id}/tasks` takes a UUID, not a slug.** `create_task` in smeos is `async 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. 2. **`HASKOS_API_URL` / `HASKOS_API_KEY` needed an explicit alias.** `FinanceSettings` carries `env_prefix="FINANCE_"`, so plain fields would have read `FINANCE_HASKOS_API_URL`. They are declared with `AliasChoices("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 is `HASKOS_*`. 3. **`emit_obligation` also gained `event_date`.** Beyond the specified `period`. 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. 4. **`next_due` / `recur_interval` are accepted by `POST /commitments` and returned by the API**, and `period` is 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. 5. **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, so `CAST(: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 `::uuid` hazard from PR #15. `advance_due` mirrors PostgreSQL exactly, including month-end clamping, and the scratch-DB test asserts the two agree. 6. **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. 7. **`seed_recurrence.py` arms only unarmed commitments.** Specified as "idempotent"; unconditional `SET` is idempotent but would let a rerun wind back a date the engine advanced or a human corrected. This follows `seed_commitments.py`'s stated philosophy — a rerun never overwrites hand-edits. 8. **The seed script was not run, per instruction.** Its `ARM` statement and `commitment_id()` *are* exercised — `tests/test_recurrence_db.py` drives 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 match `seed_commitments.py`'s. `main()` was never called and no real database was touched. 9. **CI does not run pytest.** `ci.yml` is `detect-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. 10. **`alembic check` fails on two pre-existing index drifts**, neither related to this change: `billing_profile_invoice_prefix_key` (from `f64a7ec6edcf`) and `ix_invoice_status` (from `001`) exist in the database but not on the models. Nothing on `commitment` or `commitment_event` is flagged — the new index/column parity is clean. Left alone: `env.py`'s docstring wants `alembic check` as 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. 11. **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 proves `exec "$@"` + `/app/.venv/bin/python` on the real image, and it must be done before 1 September. 12. **No `.gitignore` in this repo.** `__pycache__/` shows as untracked in every checkout. Staged explicitly here rather than with `git add -A`. Worth a one-line fix in a housekeeping PR. ## Not merged Merging auto-deploys. Left for a human.
feat: recurrence engine — recurring commitments spawn their cycles
All checks were successful
CI / Backend (pull_request) Successful in 7s
CI / Docker Build (pull_request) Successful in 17s
CI / Detect Changes (pull_request) Successful in 5s
CI / Deploy (pull_request) Has been skipped
420162c0d3
A recurring commitment is a standing promise. This is what turns it into
the month's actual work: an emission event, an obligation when the cycle
is money owed to us, and an adm task when it needs a human.

The correctness anchor is migration 005's unique partial index on
commitment_event (commitment_id, period). A cycle is identified by its
label — '2026-09' for a monthly, '2027' for an annual — so September can
be emitted exactly once per commitment, and the database is what says so.
Double-fired crons, retried containers, manual re-runs and a next_due
wound backwards all collide there and lose.

- migration 005: commitment.next_due, commitment.recur_interval,
  commitment_event.period, uq_commitment_event_period (partial, unique).
  Hand-written guarded SQL, additive, models kept in parity.
- CommitmentService.emit_obligation gains period + event_date: naming the
  cycle makes the call replay-safe, returning the original event and
  obligation instead of raising. Unnamed emissions behave exactly as before.
- scripts/spawn_due_cycles.py: the engine. One transaction per commitment
  for the money, task creation after the commit, --dry-run, --repair-tasks.
- scripts/seed_recurrence.py: arms Rainbow, Keizen and Tethys from
  2026-09-01. Not run by anything automatically.
- app.json: Dokku scheduled cron, 01:00 UTC daily. Reaches the script
  through entrypoint.sh's exec "$@", which is now commented as load-bearing.
- HASKOS_API_URL / HASKOS_API_KEY are optional settings — the app must boot
  on the deploy that introduces them, before they are configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KR2UqnKv56zQ1Xffv2QrVt
john merged commit f30497dfee into dev 2026-08-03 22:37:55 +00:00
john deleted branch feat/recurrence-engine 2026-08-03 22:37:55 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
haskytech/haskos-finance!20
No description provided.