feat: add commitment primitive — promises layer above obligations #16

Merged
haskos-bot merged 1 commit from feat/commitment-primitive into dev 2026-08-03 20:12:10 +00:00
Owner

Phase 1 of the commitment design: the promises layer that sits between the sales pipeline and the finance obligation model.

The layering

A Commitment is a dated, directed promise between us and a counterparty entity. An obligation is money already owed; a commitment is the promise that money — or a deliverable, or a service level — will be owed. Not every commitment is monetary, so amount is nullable and the substance lives in terms; timing is equally plural (due_date for a one-off, schedule for a standing service, trigger_condition for a conditional promise).

commitment (active)
    └── emit  →  commitment_event (event_type='emission')
                     └──  obligation (source_type='commitment', source_id=<event.id>)
                              └── settlement …unchanged

The obligation's source_id is the emission event id, not the commitment id. This is the one non-obvious decision in the PR. uq_obligation_source is unique per live source, and a recurring commitment emits one obligation per cycle — Rainbow's monthly M&S would collide with itself in month two if the commitment were the source. One emission event per cycle gives each obligation a distinct, auditable source row. The commitment id stays reachable in obligation.metadata['commitment_id'], and the event carries metadata['obligation_id'] back.

Obligation and Settlement are otherwise untouched: the only change to existing code is one new ObligationSourceType enum value (the column is Text, so no migration is needed for it) plus the router registration.

CommitmentEvent is immutable in the same way Settlement is — no SoftDeleteMixin, corrections are new events.

Lifecycle

proposed → active → suspended, closing into one of four terminal states. The whole table lives in one module-level TRANSITIONS dict that the service consults, so the guard and its tests read the same source. Illegal moves are 409; a closed commitment accepts nothing.

Action From To
activate proposed active
suspend active suspended
resume suspended active
fulfil active fulfilled (sets fulfilled_at + closed_at)
waive proposed, active, suspended waived
void proposed, active, suspended void
supersede proposed, active, suspended superseded (sets supersedes_id on the replacement)

closed_at is set on any terminal state, so "still open" is one predicate rather than a status enumeration.

Endpoints

All under /api/v1/commitments, scoped finance:read / finance:write, same envelope and commit-after-service-call shape as obligations.py.

Method Path Role
GET /commitments — filters: entity_id, status, kind, direction, source_type, limit, offset viewer
GET /commitments/{id} (includes events) viewer
GET /commitments/{id}/events viewer
POST /commitments contributor
POST /commitments/{id}/activate contributor
POST /commitments/{id}/suspend contributor
POST /commitments/{id}/resume contributor
POST /commitments/{id}/fulfil contributor
POST /commitments/{id}/waive operator
POST /commitments/{id}/void operator
POST /commitments/{id}/supersede operator
POST /commitments/{id}/emit{commitment, event, obligation} contributor

Validation

  • ruff check + ruff format --check over app/ tests/ scripts/ — clean.
  • uv run alembic heads — exactly one head before (a3f7c2d91b45) and after (b7c4e0d38a12).
  • uv run alembic upgrade head on a scratch local PostgreSQL — the full 001→004 chain applies and the resulting schema matches the models.
  • uv run pytest tests/ -q — 63 passed. Covers the transition table, every legal and a representative set of illegal moves, the direction mapping, the amount default/required rule, and the event-as-source_id wiring (against a stub session, no database).
  • The real CommitmentService driven end-to-end against the scratch database, then rolled back: a recurring commitment emitted two obligations with distinct source ids (the collision this design exists to avoid), suspend blocked emission, the supersession chain wired both ends, and the event trail came out activated, emission, emission, suspended, resumed, fulfilled.
  • The seed SQL was dry-run inside a transaction that was rolled back: all 19 fixtures insert, a second pass is fully absorbed by ON CONFLICT (id) DO NOTHING, and the rows round-trip through the ORM. Nothing was persisted, and the seed script itself was not run against any database.
  • Not run: local Docker build. The Dockerfile is unchanged and CI's smoke test boots the container against a fresh PostgreSQL, which exercises migration 004 at entrypoint.

Deploy Runbook

Human-approved steps, in order:

  1. Merge this PR into dev. (Do not merge until the runbook below is acceptable — merging auto-deploys to production Dokku.)
  2. Auto-deploy runs migration 004 at boot. entrypoint.sh runs alembic upgrade head before uvicorn starts. Migration 004 is purely additive — two new tables (commitment, commitment_event) and their indexes, every statement guarded with IF NOT EXISTS. Nothing existing is altered or dropped.
  3. Confirm the app is servingGET /api/v1/health on the deployed container, and check the boot log shows Running upgrade a3f7c2d91b45 -> b7c4e0d38a12.
  4. Seed the fixtures — human runs this by hand, it is not automated:
    cd haskos-finance
    HASKOS_DATABASE_URL=<prod url> uv run python scripts/seed_commitments.py
    
    The script writes 19 real client commitments including live AR positions. Ids are uuid5(NAMESPACE_URL, "haskos-finance/commitment/<slug>") with ON CONFLICT (id) DO NOTHING, so a rerun is a no-op and will never overwrite hand-edits made after the first seed.
  5. VerifyGET /api/v1/commitments returns 19 (16 active, 2 proposed, 1 suspended), and GET /api/v1/commitments?entity_id=e0c45b5d-2d7e-4f87-8c76-a5e8506b9828 returns the five EduPact commitments.

Rollback: alembic downgrade a3f7c2d91b45 drops both new tables. Existing finance data is untouched by this migration either way.

Spec Drift Callouts

Where the task spec and the repo disagreed, the code won:

  1. Seed env var. The spec said DATABASE_URL; scripts/seed_billing_profiles.py reads HASKOS_DATABASE_URL. Followed the existing script — the runbook above uses HASKOS_DATABASE_URL.
  2. Where Pydantic schemas live. The spec put them in app/api/commitments.py; the repo keeps them in app/schemas/ (obligations.py imports from app/schemas/obligation.py). Followed the repo — schemas are in app/schemas/commitment.py.
  3. emit_obligation signature. The spec listed (commitment_id, amount, due_date, description) but also required rejecting mutual "unless an explicit direction override is passed" — which needs a parameter that isn't in the listed signature. Added direction (a CommitmentDirection value, mapped through the same table) and reference.
  4. There is no pytest in this repo. pyproject.toml declared [tool.pytest.ini_options] with asyncio_mode = "auto" but pytest was not a dependency in any form, and tests/ held only an empty __init__.py. Added a [dependency-groups] dev with pytest and pytest-asyncio (the latter makes the already-declared asyncio_mode real rather than dead config). The image builds with uv sync --no-dev, so none of it ships. uv.lock is regenerated and uv lock --check passes.
  5. CI does not lint scripts/. ci.yml runs ruff over app/ tests/ only, so scripts/seed_commitments.py is outside the gate. It was linted and formatted locally, but the gate gap is real — the billing-profile seed bug in PR #15 was in exactly this blind spot.
  6. Fixture-table ambiguities, resolved as follows:
    • Rainbow's schedule: monthly; invoice dated on send; Net 45; PO POHQ-000528 was split — schedule = "monthly", the rest into terms, since the design defines schedule as recurrence prose.
    • Tenet's "due after July month-end" is not a date, so due_date is NULL and the condition is in terms.
    • Tianlong's "due ~2026-08-24" is recorded as an exact due_date with the approximation flagged in terms and notes.
    • Where the last column held both a reference and prose, the reference went to source_ref and the prose to notes.
  7. create() will only accept proposed or active. Every later state is reached through a transition, so the event log never has a gap. That is why the backfill is a SQL script rather than a sequence of API calls — the seed writes suspended (MarkSpace M4) directly.
  8. No .gitignore in this repo. __pycache__/ directories appear as untracked noise in git status after any local run. Left alone as out of scope; worth a follow-up.

Deliberately not in this PR

  • No recurrence engine. A recurring commitment does not spawn its own cycles; POST /{id}/emit is an explicit call. Who advances the clock (cron here vs. an orchestrator event) is open question 1 in the design note.
  • No graph edgescommitment —part_of→ deal|project, milestone —fulfils→ commitment are Phase 1 in the design note's sketch but need the kernel entity-link surface and haskos-pm; they are not modelled here.
  • No Phase 2 work — no deal-as-proposed-bundle view, no operator dashboard panels, no signal reconciliation.
  • No SLA evidence hooks — service-level commitments are recorded, not measured (open question 2).
  • No delete endpoint. SoftDeleteMixin is on the model and every read filters deleted_at IS NULL, but no route exposes it — matching obligations.py, which also has none.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KR2UqnKv56zQ1Xffv2QrVt

Phase 1 of the commitment design: the promises layer that sits between the sales pipeline and the finance obligation model. ## The layering A **Commitment** is a dated, directed promise between us and a counterparty entity. An obligation is money *already owed*; a commitment is the promise that money — or a deliverable, or a service level — *will be* owed. Not every commitment is monetary, so `amount` is nullable and the substance lives in `terms`; timing is equally plural (`due_date` for a one-off, `schedule` for a standing service, `trigger_condition` for a conditional promise). ``` commitment (active) └── emit → commitment_event (event_type='emission') └── obligation (source_type='commitment', source_id=<event.id>) └── settlement …unchanged ``` **The obligation's `source_id` is the emission event id, not the commitment id.** This is the one non-obvious decision in the PR. `uq_obligation_source` is unique per live source, and a recurring commitment emits one obligation per cycle — Rainbow's monthly M&S would collide with itself in month two if the commitment were the source. One emission event per cycle gives each obligation a distinct, auditable source row. The commitment id stays reachable in `obligation.metadata['commitment_id']`, and the event carries `metadata['obligation_id']` back. Obligation and Settlement are otherwise untouched: the only change to existing code is one new `ObligationSourceType` enum value (the column is Text, so no migration is needed for it) plus the router registration. `CommitmentEvent` is immutable in the same way `Settlement` is — no `SoftDeleteMixin`, corrections are new events. ## Lifecycle `proposed → active → suspended`, closing into one of four terminal states. The whole table lives in one module-level `TRANSITIONS` dict that the service consults, so the guard and its tests read the same source. Illegal moves are `409`; a closed commitment accepts nothing. | Action | From | To | |---|---|---| | activate | proposed | active | | suspend | active | suspended | | resume | suspended | active | | fulfil | active | fulfilled (sets `fulfilled_at` + `closed_at`) | | waive | proposed, active, suspended | waived | | void | proposed, active, suspended | void | | supersede | proposed, active, suspended | superseded (sets `supersedes_id` on the replacement) | `closed_at` is set on any terminal state, so "still open" is one predicate rather than a status enumeration. ## Endpoints All under `/api/v1/commitments`, scoped `finance:read` / `finance:write`, same envelope and commit-after-service-call shape as `obligations.py`. | Method | Path | Role | |---|---|---| | GET | `/commitments` — filters: `entity_id`, `status`, `kind`, `direction`, `source_type`, `limit`, `offset` | viewer | | GET | `/commitments/{id}` (includes events) | viewer | | GET | `/commitments/{id}/events` | viewer | | POST | `/commitments` | contributor | | POST | `/commitments/{id}/activate` | contributor | | POST | `/commitments/{id}/suspend` | contributor | | POST | `/commitments/{id}/resume` | contributor | | POST | `/commitments/{id}/fulfil` | contributor | | POST | `/commitments/{id}/waive` | operator | | POST | `/commitments/{id}/void` | operator | | POST | `/commitments/{id}/supersede` | operator | | POST | `/commitments/{id}/emit` → `{commitment, event, obligation}` | contributor | ## Validation - `ruff check` + `ruff format --check` over `app/ tests/ scripts/` — clean. - `uv run alembic heads` — exactly one head before (`a3f7c2d91b45`) and after (`b7c4e0d38a12`). - `uv run alembic upgrade head` on a scratch local PostgreSQL — the full 001→004 chain applies and the resulting schema matches the models. - `uv run pytest tests/ -q` — 63 passed. Covers the transition table, every legal and a representative set of illegal moves, the direction mapping, the amount default/required rule, and the event-as-`source_id` wiring (against a stub session, no database). - The real `CommitmentService` driven end-to-end against the scratch database, then rolled back: a recurring commitment emitted **two** obligations with distinct source ids (the collision this design exists to avoid), suspend blocked emission, the supersession chain wired both ends, and the event trail came out `activated, emission, emission, suspended, resumed, fulfilled`. - The seed SQL was dry-run inside a transaction that was rolled back: all 19 fixtures insert, a second pass is fully absorbed by `ON CONFLICT (id) DO NOTHING`, and the rows round-trip through the ORM. **Nothing was persisted, and the seed script itself was not run against any database.** - Not run: local Docker build. The Dockerfile is unchanged and CI's smoke test boots the container against a fresh PostgreSQL, which exercises migration 004 at entrypoint. ## Deploy Runbook Human-approved steps, in order: 1. **Merge this PR into `dev`.** (Do not merge until the runbook below is acceptable — merging auto-deploys to production Dokku.) 2. **Auto-deploy runs migration 004 at boot.** `entrypoint.sh` runs `alembic upgrade head` before uvicorn starts. Migration 004 is purely additive — two new tables (`commitment`, `commitment_event`) and their indexes, every statement guarded with `IF NOT EXISTS`. Nothing existing is altered or dropped. 3. **Confirm the app is serving** — `GET /api/v1/health` on the deployed container, and check the boot log shows `Running upgrade a3f7c2d91b45 -> b7c4e0d38a12`. 4. **Seed the fixtures — human runs this by hand, it is not automated:** ``` cd haskos-finance HASKOS_DATABASE_URL=<prod url> uv run python scripts/seed_commitments.py ``` The script writes 19 real client commitments including live AR positions. Ids are `uuid5(NAMESPACE_URL, "haskos-finance/commitment/<slug>")` with `ON CONFLICT (id) DO NOTHING`, so a rerun is a no-op and will never overwrite hand-edits made after the first seed. 5. **Verify** — `GET /api/v1/commitments` returns 19 (16 active, 2 proposed, 1 suspended), and `GET /api/v1/commitments?entity_id=e0c45b5d-2d7e-4f87-8c76-a5e8506b9828` returns the five EduPact commitments. Rollback: `alembic downgrade a3f7c2d91b45` drops both new tables. Existing finance data is untouched by this migration either way. ## Spec Drift Callouts Where the task spec and the repo disagreed, the code won: 1. **Seed env var.** The spec said `DATABASE_URL`; `scripts/seed_billing_profiles.py` reads **`HASKOS_DATABASE_URL`**. Followed the existing script — the runbook above uses `HASKOS_DATABASE_URL`. 2. **Where Pydantic schemas live.** The spec put them in `app/api/commitments.py`; the repo keeps them in `app/schemas/` (`obligations.py` imports from `app/schemas/obligation.py`). Followed the repo — schemas are in `app/schemas/commitment.py`. 3. **`emit_obligation` signature.** The spec listed `(commitment_id, amount, due_date, description)` but also required rejecting `mutual` "unless an explicit direction override is passed" — which needs a parameter that isn't in the listed signature. Added `direction` (a `CommitmentDirection` value, mapped through the same table) and `reference`. 4. **There is no pytest in this repo.** `pyproject.toml` declared `[tool.pytest.ini_options]` with `asyncio_mode = "auto"` but pytest was not a dependency in any form, and `tests/` held only an empty `__init__.py`. Added a `[dependency-groups] dev` with `pytest` and `pytest-asyncio` (the latter makes the already-declared `asyncio_mode` real rather than dead config). The image builds with `uv sync --no-dev`, so none of it ships. `uv.lock` is regenerated and `uv lock --check` passes. 5. **CI does not lint `scripts/`.** `ci.yml` runs ruff over `app/ tests/` only, so `scripts/seed_commitments.py` is outside the gate. It was linted and formatted locally, but the gate gap is real — the billing-profile seed bug in PR #15 was in exactly this blind spot. 6. **Fixture-table ambiguities**, resolved as follows: - Rainbow's `schedule: monthly; invoice dated on send; Net 45; PO POHQ-000528` was split — `schedule = "monthly"`, the rest into `terms`, since the design defines `schedule` as recurrence prose. - Tenet's "due after July month-end" is not a date, so `due_date` is NULL and the condition is in `terms`. - Tianlong's "due ~2026-08-24" is recorded as an exact `due_date` with the approximation flagged in `terms` and `notes`. - Where the last column held both a reference and prose, the reference went to `source_ref` and the prose to `notes`. 7. **`create()` will only accept `proposed` or `active`.** Every later state is reached through a transition, so the event log never has a gap. That is why the backfill is a SQL script rather than a sequence of API calls — the seed writes `suspended` (MarkSpace M4) directly. 8. **No `.gitignore` in this repo.** `__pycache__/` directories appear as untracked noise in `git status` after any local run. Left alone as out of scope; worth a follow-up. ## Deliberately not in this PR - **No recurrence engine.** A recurring commitment does not spawn its own cycles; `POST /{id}/emit` is an explicit call. Who advances the clock (cron here vs. an orchestrator event) is open question 1 in the design note. - **No graph edges** — `commitment —part_of→ deal|project`, `milestone —fulfils→ commitment` are Phase 1 in the design note's sketch but need the kernel entity-link surface and haskos-pm; they are not modelled here. - **No Phase 2 work** — no deal-as-proposed-bundle view, no operator dashboard panels, no signal reconciliation. - **No SLA evidence hooks** — service-level commitments are recorded, not measured (open question 2). - **No delete endpoint.** `SoftDeleteMixin` is on the model and every read filters `deleted_at IS NULL`, but no route exposes it — matching `obligations.py`, which also has none. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KR2UqnKv56zQ1Xffv2QrVt
feat: add commitment primitive — promises layer above obligations
All checks were successful
CI / Backend (pull_request) Successful in 7s
CI / Detect Changes (pull_request) Successful in 5s
CI / Deploy (pull_request) Has been skipped
CI / Docker Build (pull_request) Successful in 22s
2d45c3c09a
A commitment is a dated, directed promise between us and a counterparty
entity. It sits one layer above the obligation: an obligation is money
already owed, a commitment is the promise that money — or a deliverable,
or a service level — will be owed.

When money crystallises, an active commitment emits an obligation with
source_type='commitment', which is the extension hook the obligation
primitive was designed around. Obligation and Settlement are untouched
apart from one new enum value.

The obligation's source_id is the emission event id, not the commitment
id: uq_obligation_source is unique per live source, and a recurring
commitment emits one obligation per cycle. The commitment id stays
reachable in obligation.metadata['commitment_id'].

- app/models/commitment.py — Commitment + CommitmentEvent (immutable)
- alembic/versions/004_add_commitment_layer.py — additive, hand-written
- app/services/commitment_service.py — lifecycle table + emission hook
- app/api/commitments.py — list/get/create, 7 transitions, emit, events
- scripts/seed_commitments.py — 19 fixtures, deploy-time human step
- tests/test_commitment_service.py — 63 tests, no database needed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KR2UqnKv56zQ1Xffv2QrVt
haskos-bot deleted branch feat/commitment-primitive 2026-08-03 20:12:10 +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!16
No description provided.