feat: register commitments in kernel entity registry #18
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/commitment-entity-registration"
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?
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
amountis nullable and the substance lives interms; timing is equally plural (due_datefor a one-off,schedulefor a standing service,trigger_conditionfor a conditional promise).The obligation's
source_idis the emission event id, not the commitment id. This is the one non-obvious decision in the PR.uq_obligation_sourceis 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 inobligation.metadata['commitment_id'], and the event carriesmetadata['obligation_id']back.Obligation and Settlement are otherwise untouched: the only change to existing code is one new
ObligationSourceTypeenum value (the column is Text, so no migration is needed for it) plus the router registration.CommitmentEventis immutable in the same waySettlementis — noSoftDeleteMixin, corrections are new events.Lifecycle
proposed → active → suspended, closing into one of four terminal states. The whole table lives in one module-levelTRANSITIONSdict that the service consults, so the guard and its tests read the same source. Illegal moves are409; a closed commitment accepts nothing.fulfilled_at+closed_at)supersedes_idon the replacement)closed_atis set on any terminal state, so "still open" is one predicate rather than a status enumeration.Endpoints
All under
/api/v1/commitments, scopedfinance:read/finance:write, same envelope and commit-after-service-call shape asobligations.py./commitments— filters:entity_id,status,kind,direction,source_type,limit,offset/commitments/{id}(includes events)/commitments/{id}/events/commitments/commitments/{id}/activate/commitments/{id}/suspend/commitments/{id}/resume/commitments/{id}/fulfil/commitments/{id}/waive/commitments/{id}/void/commitments/{id}/supersede/commitments/{id}/emit→{commitment, event, obligation}Validation
ruff check+ruff format --checkoverapp/ tests/ scripts/— clean.uv run alembic heads— exactly one head before (a3f7c2d91b45) and after (b7c4e0d38a12).uv run alembic upgrade headon 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_idwiring (against a stub session, no database).CommitmentServicedriven 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 outactivated, emission, emission, suspended, resumed, fulfilled.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.Deploy Runbook
Human-approved steps, in order:
dev. (Do not merge until the runbook below is acceptable — merging auto-deploys to production Dokku.)entrypoint.shrunsalembic upgrade headbefore uvicorn starts. Migration 004 is purely additive — two new tables (commitment,commitment_event) and their indexes, every statement guarded withIF NOT EXISTS. Nothing existing is altered or dropped.GET /api/v1/healthon the deployed container, and check the boot log showsRunning upgrade a3f7c2d91b45 -> b7c4e0d38a12.uuid5(NAMESPACE_URL, "haskos-finance/commitment/<slug>")withON CONFLICT (id) DO NOTHING, so a rerun is a no-op and will never overwrite hand-edits made after the first seed.GET /api/v1/commitmentsreturns 19 (16 active, 2 proposed, 1 suspended), andGET /api/v1/commitments?entity_id=e0c45b5d-2d7e-4f87-8c76-a5e8506b9828returns the five EduPact commitments.Rollback:
alembic downgrade a3f7c2d91b45drops 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:
DATABASE_URL;scripts/seed_billing_profiles.pyreadsHASKOS_DATABASE_URL. Followed the existing script — the runbook above usesHASKOS_DATABASE_URL.app/api/commitments.py; the repo keeps them inapp/schemas/(obligations.pyimports fromapp/schemas/obligation.py). Followed the repo — schemas are inapp/schemas/commitment.py.emit_obligationsignature. The spec listed(commitment_id, amount, due_date, description)but also required rejectingmutual"unless an explicit direction override is passed" — which needs a parameter that isn't in the listed signature. Addeddirection(aCommitmentDirectionvalue, mapped through the same table) andreference.pyproject.tomldeclared[tool.pytest.ini_options]withasyncio_mode = "auto"but pytest was not a dependency in any form, andtests/held only an empty__init__.py. Added a[dependency-groups] devwithpytestandpytest-asyncio(the latter makes the already-declaredasyncio_modereal rather than dead config). The image builds withuv sync --no-dev, so none of it ships.uv.lockis regenerated anduv lock --checkpasses.scripts/.ci.ymlruns ruff overapp/ tests/only, soscripts/seed_commitments.pyis 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.schedule: monthly; invoice dated on send; Net 45; PO POHQ-000528was split —schedule = "monthly", the rest intoterms, since the design definesscheduleas recurrence prose.due_dateis NULL and the condition is interms.due_datewith the approximation flagged intermsandnotes.source_refand the prose tonotes.create()will only acceptproposedoractive. 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 writessuspended(MarkSpace M4) directly..gitignorein this repo.__pycache__/directories appear as untracked noise ingit statusafter any local run. Left alone as out of scope; worth a follow-up.Deliberately not in this PR
POST /{id}/emitis an explicit call. Who advances the clock (cron here vs. an orchestrator event) is open question 1 in the design note.commitment —part_of→ deal|project,milestone —fulfils→ commitmentare Phase 1 in the design note's sketch but need the kernel entity-link surface and haskos-pm; they are not modelled here.SoftDeleteMixinis on the model and every read filtersdeleted_at IS NULL, but no route exposes it — matchingobligations.py, which also has none.🤖 Generated with Claude Code
https://claude.ai/code/session_01KR2UqnKv56zQ1Xffv2QrVt