From d5ca8ddbc3e70820f118851a5c5e76dce7fad413 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:20:25 +0300 Subject: [PATCH 1/3] Add the budget BC and the Allocation aggregate: the R1/R2 envelope The eighteenth bounded context and the paper's last big component made domain code. An Allocation is the envelope a beamline receives and spends: a USD ceiling, an optional campaign reference that will bind the award window, and a lifecycle whose shape IS the paper's temporal argument, Granted (the award's dormant phase) -> Active (spending) -> Sealed (books closed with a final-spend snapshot), with Voided from the non-terminal states for a mistaken grant. Balance is never stored: it will fold from the same inference ledger every enforcement tier already sums. Five slices ship with routes and MCP tools: grant (genesis, idempotency-wrapped), activate, amend-ceiling (PUT semantics, the cost-overrun tightening lever), seal, and void (reason required). The seal handler takes a total-spend reader at bind time and threads the allocation's own [activated_at, now) window into it; this stage binds a documented zero reader, and the gate stage swaps in the real instance-total query, so the slice's shape never changes. Attribution halves (granted_by / activated_by / sealed_by) ride every fact event per the fold-symmetry convention. AllocationGranted takes the genesis-deviation allowlist's fifth slot: granted is the paper's and HPC accounting's exact verb for an award, and it names the FSM's initial state. Count pins and BC enumerations updated honestly (18 BCs, 43 aggregates; budget graduates from the planned rows to the resource group in the architecture pages). Co-Authored-By: Claude Fable 5 --- apps/api/openapi.json | 600 ++++++++++++++++++ apps/api/src/cora/api/main.py | 13 + apps/api/src/cora/budget/__init__.py | 44 ++ .../cora/budget/_allocation_update_handler.py | 212 +++++++ .../src/cora/budget/aggregates/__init__.py | 11 + .../budget/aggregates/allocation/__init__.py | 74 +++ .../budget/aggregates/allocation/events.py | 305 +++++++++ .../budget/aggregates/allocation/evolver.py | 121 ++++ .../cora/budget/aggregates/allocation/read.py | 24 + .../budget/aggregates/allocation/state.py | 302 +++++++++ apps/api/src/cora/budget/errors.py | 22 + apps/api/src/cora/budget/features/__init__.py | 26 + .../features/activate_allocation/__init__.py | 26 + .../features/activate_allocation/command.py | 25 + .../features/activate_allocation/decider.py | 54 ++ .../features/activate_allocation/handler.py | 43 ++ .../features/activate_allocation/route.py | 67 ++ .../features/activate_allocation/tool.py | 49 ++ .../amend_allocation_ceiling/__init__.py | 28 + .../amend_allocation_ceiling/command.py | 24 + .../amend_allocation_ceiling/decider.py | 68 ++ .../amend_allocation_ceiling/handler.py | 43 ++ .../amend_allocation_ceiling/route.py | 90 +++ .../features/amend_allocation_ceiling/tool.py | 53 ++ .../features/grant_allocation/__init__.py | 34 + .../features/grant_allocation/command.py | 32 + .../features/grant_allocation/decider.py | 74 +++ .../features/grant_allocation/handler.py | 180 ++++++ .../budget/features/grant_allocation/route.py | 132 ++++ .../budget/features/grant_allocation/tool.py | 91 +++ .../features/seal_allocation/__init__.py | 37 ++ .../features/seal_allocation/command.py | 27 + .../features/seal_allocation/decider.py | 68 ++ .../features/seal_allocation/handler.py | 192 ++++++ .../budget/features/seal_allocation/route.py | 94 +++ .../budget/features/seal_allocation/tool.py | 57 ++ .../features/void_allocation/__init__.py | 28 + .../features/void_allocation/command.py | 22 + .../features/void_allocation/decider.py | 60 ++ .../features/void_allocation/handler.py | 45 ++ .../budget/features/void_allocation/route.py | 91 +++ .../budget/features/void_allocation/tool.py | 56 ++ apps/api/src/cora/budget/routes.py | 134 ++++ apps/api/src/cora/budget/tools.py | 47 ++ apps/api/src/cora/budget/wire.py | 101 +++ apps/api/tach.toml | 17 + apps/api/tests/architecture/conftest.py | 1 + .../test_event_class_defined_vs_registered.py | 9 + .../architecture/test_slice_test_coverage.py | 22 + .../test_slice_verb_names_subject.py | 1 + apps/api/tests/unit/budget/__init__.py | 0 apps/api/tests/unit/budget/_helpers.py | 106 ++++ .../test_activate_allocation_decider.py | 69 ++ ..._activate_allocation_decider_properties.py | 148 +++++ .../test_activate_allocation_handler.py | 143 +++++ .../unit/budget/test_allocation_evolver.py | 219 +++++++ .../test_amend_allocation_ceiling_decider.py | 101 +++ ...d_allocation_ceiling_decider_properties.py | 184 ++++++ .../test_amend_allocation_ceiling_handler.py | 131 ++++ .../budget/test_grant_allocation_decider.py | 135 ++++ ...est_grant_allocation_decider_properties.py | 165 +++++ .../budget/test_grant_allocation_handler.py | 209 ++++++ .../budget/test_seal_allocation_decider.py | 122 ++++ ...test_seal_allocation_decider_properties.py | 182 ++++++ .../budget/test_seal_allocation_handler.py | 194 ++++++ .../budget/test_void_allocation_decider.py | 86 +++ ...test_void_allocation_decider_properties.py | 166 +++++ .../budget/test_void_allocation_handler.py | 126 ++++ .../test_architecture_introspect.py | 23 +- scripts/architecture_pages.py | 4 +- scripts/scenarios_meta.py | 3 +- 71 files changed, 6480 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/cora/budget/__init__.py create mode 100644 apps/api/src/cora/budget/_allocation_update_handler.py create mode 100644 apps/api/src/cora/budget/aggregates/__init__.py create mode 100644 apps/api/src/cora/budget/aggregates/allocation/__init__.py create mode 100644 apps/api/src/cora/budget/aggregates/allocation/events.py create mode 100644 apps/api/src/cora/budget/aggregates/allocation/evolver.py create mode 100644 apps/api/src/cora/budget/aggregates/allocation/read.py create mode 100644 apps/api/src/cora/budget/aggregates/allocation/state.py create mode 100644 apps/api/src/cora/budget/errors.py create mode 100644 apps/api/src/cora/budget/features/__init__.py create mode 100644 apps/api/src/cora/budget/features/activate_allocation/__init__.py create mode 100644 apps/api/src/cora/budget/features/activate_allocation/command.py create mode 100644 apps/api/src/cora/budget/features/activate_allocation/decider.py create mode 100644 apps/api/src/cora/budget/features/activate_allocation/handler.py create mode 100644 apps/api/src/cora/budget/features/activate_allocation/route.py create mode 100644 apps/api/src/cora/budget/features/activate_allocation/tool.py create mode 100644 apps/api/src/cora/budget/features/amend_allocation_ceiling/__init__.py create mode 100644 apps/api/src/cora/budget/features/amend_allocation_ceiling/command.py create mode 100644 apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py create mode 100644 apps/api/src/cora/budget/features/amend_allocation_ceiling/handler.py create mode 100644 apps/api/src/cora/budget/features/amend_allocation_ceiling/route.py create mode 100644 apps/api/src/cora/budget/features/amend_allocation_ceiling/tool.py create mode 100644 apps/api/src/cora/budget/features/grant_allocation/__init__.py create mode 100644 apps/api/src/cora/budget/features/grant_allocation/command.py create mode 100644 apps/api/src/cora/budget/features/grant_allocation/decider.py create mode 100644 apps/api/src/cora/budget/features/grant_allocation/handler.py create mode 100644 apps/api/src/cora/budget/features/grant_allocation/route.py create mode 100644 apps/api/src/cora/budget/features/grant_allocation/tool.py create mode 100644 apps/api/src/cora/budget/features/seal_allocation/__init__.py create mode 100644 apps/api/src/cora/budget/features/seal_allocation/command.py create mode 100644 apps/api/src/cora/budget/features/seal_allocation/decider.py create mode 100644 apps/api/src/cora/budget/features/seal_allocation/handler.py create mode 100644 apps/api/src/cora/budget/features/seal_allocation/route.py create mode 100644 apps/api/src/cora/budget/features/seal_allocation/tool.py create mode 100644 apps/api/src/cora/budget/features/void_allocation/__init__.py create mode 100644 apps/api/src/cora/budget/features/void_allocation/command.py create mode 100644 apps/api/src/cora/budget/features/void_allocation/decider.py create mode 100644 apps/api/src/cora/budget/features/void_allocation/handler.py create mode 100644 apps/api/src/cora/budget/features/void_allocation/route.py create mode 100644 apps/api/src/cora/budget/features/void_allocation/tool.py create mode 100644 apps/api/src/cora/budget/routes.py create mode 100644 apps/api/src/cora/budget/tools.py create mode 100644 apps/api/src/cora/budget/wire.py create mode 100644 apps/api/tests/unit/budget/__init__.py create mode 100644 apps/api/tests/unit/budget/_helpers.py create mode 100644 apps/api/tests/unit/budget/test_activate_allocation_decider.py create mode 100644 apps/api/tests/unit/budget/test_activate_allocation_decider_properties.py create mode 100644 apps/api/tests/unit/budget/test_activate_allocation_handler.py create mode 100644 apps/api/tests/unit/budget/test_allocation_evolver.py create mode 100644 apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py create mode 100644 apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py create mode 100644 apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py create mode 100644 apps/api/tests/unit/budget/test_grant_allocation_decider.py create mode 100644 apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py create mode 100644 apps/api/tests/unit/budget/test_grant_allocation_handler.py create mode 100644 apps/api/tests/unit/budget/test_seal_allocation_decider.py create mode 100644 apps/api/tests/unit/budget/test_seal_allocation_decider_properties.py create mode 100644 apps/api/tests/unit/budget/test_seal_allocation_handler.py create mode 100644 apps/api/tests/unit/budget/test_void_allocation_decider.py create mode 100644 apps/api/tests/unit/budget/test_void_allocation_decider_properties.py create mode 100644 apps/api/tests/unit/budget/test_void_allocation_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index bf1170fc155..052f6896adb 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -756,6 +756,22 @@ "title": "AlternateIdentifierKind", "type": "string" }, + "AmendAllocationCeilingRequest": { + "description": "Body for `POST /allocations/{allocation_id}/ceiling`.", + "properties": { + "ceiling_usd": { + "description": "The post-amend USD ceiling (PUT semantics, not a delta). Finite and greater than 0.", + "exclusiveMinimum": 0.0, + "title": "Ceiling Usd", + "type": "number" + } + }, + "required": [ + "ceiling_usd" + ], + "title": "AmendAllocationCeilingRequest", + "type": "object" + }, "AmendClearanceRequest": { "description": "Body for `POST /clearances/{parent_id}/amend`.\n\nMirrors `RegisterClearanceRequest`'s child-fields exactly. The\n`parent_id` comes from the URL path, not the body.", "properties": { @@ -7775,6 +7791,71 @@ "title": "GhsDto", "type": "object" }, + "GrantAllocationRequest": { + "description": "Body for `POST /allocations`.", + "properties": { + "allocation_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional caller-supplied id for configuration-seeded envelopes needing stable ids across environments. Omit (or pass null) to let the server mint a UUIDv7.", + "title": "Allocation Id" + }, + "campaign_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional Campaign binding for the award window. When set, the CampaignClosed subscriber seals this envelope beside the campaign's own books. Pass null for an unbound envelope.", + "title": "Campaign Id" + }, + "ceiling_usd": { + "description": "USD spending ceiling for the envelope. Finite and greater than 0.", + "exclusiveMinimum": 0.0, + "title": "Ceiling Usd", + "type": "number" + }, + "holder_note": { + "description": "Operator-facing name for the envelope (award cycle, proposal block). The holder itself is implicitly this deployment's beamline.", + "maxLength": 200, + "minLength": 1, + "title": "Holder Note", + "type": "string" + } + }, + "required": [ + "ceiling_usd", + "holder_note" + ], + "title": "GrantAllocationRequest", + "type": "object" + }, + "GrantAllocationResponse": { + "description": "Response body for `POST /allocations`.", + "properties": { + "allocation_id": { + "format": "uuid", + "title": "Allocation Id", + "type": "string" + } + }, + "required": [ + "allocation_id" + ], + "title": "GrantAllocationResponse", + "type": "object" + }, "GrantToolToAgentRequest": { "description": "Body for `POST /agents/{agent_id}/tools/grant`.", "properties": { @@ -14228,6 +14309,27 @@ "title": "SchemeDTO", "type": "object" }, + "SealAllocationRequest": { + "description": "Body for `POST /allocations/{allocation_id}/seal`.", + "properties": { + "reason": { + "anyOf": [ + { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional closing note (an early close usually carries context; a routine end-of-window seal needs none). Pass null to omit.", + "title": "Reason" + } + }, + "title": "SealAllocationRequest", + "type": "object" + }, "SealEditionRequest": { "additionalProperties": false, "description": "Optional overrides for `POST /editions/{edition_id}/seal`.", @@ -16362,6 +16464,23 @@ "title": "VisitType", "type": "string" }, + "VoidAllocationRequest": { + "description": "Body for `POST /allocations/{allocation_id}/void`.", + "properties": { + "reason": { + "description": "Why the grant is withdrawn. Required: voiding an award is a governance act the audit log must always carry context for.", + "maxLength": 500, + "minLength": 1, + "title": "Reason", + "type": "string" + } + }, + "required": [ + "reason" + ], + "title": "VoidAllocationRequest", + "type": "object" + }, "VoidVisitRequest": { "description": "Body for `POST /visits/{visit_id}/void`.", "properties": { @@ -19089,6 +19208,487 @@ ] } }, + "/allocations": { + "post": { + "operationId": "post_allocations_allocations_post", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantAllocationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantAllocationResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (non-finite ceiling, whitespace-only holder note)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The target Allocation stream already has events. Reachable in practice only with a caller-supplied allocation_id; essentially impossible for server-minted UUIDv7 ids." + }, + "422": { + "description": "Request body failed schema validation (missing field, length out of bounds, non-positive ceiling, invalid UUID), OR Idempotency-Key was reused with a different request body." + } + }, + "summary": "Grant a new spending envelope (lands in Granted, dormant)", + "tags": [ + "budget" + ] + } + }, + "/allocations/{allocation_id}/activate": { + "post": { + "operationId": "post_allocations_activate_allocations__allocation_id__activate_post", + "parameters": [ + { + "description": "Target Allocation's id.", + "in": "path", + "name": "allocation_id", + "required": true, + "schema": { + "description": "Target Allocation's id.", + "format": "uuid", + "title": "Allocation Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No Allocation exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Allocation is not in Granted status (activate_allocation is single-source from Granted only)." + }, + "422": { + "description": "Path parameter failed schema validation." + } + }, + "summary": "Activate a Granted Allocation (Granted -> Active)", + "tags": [ + "budget" + ] + } + }, + "/allocations/{allocation_id}/ceiling": { + "post": { + "operationId": "post_allocations_ceiling_allocations__allocation_id__ceiling_post", + "parameters": [ + { + "description": "Target Allocation's id.", + "in": "path", + "name": "allocation_id", + "required": true, + "schema": { + "description": "Target Allocation's id.", + "format": "uuid", + "title": "Allocation Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmendAllocationCeilingRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (non-finite ceiling)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No Allocation exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Allocation is not in Granted or Active status (amend_allocation_ceiling cannot rewrite a closed envelope)." + }, + "422": { + "description": "Request body failed schema validation (missing field, non-positive ceiling) or path parameter is not a UUID." + } + }, + "summary": "Amend an Allocation's USD ceiling (PUT semantics)", + "tags": [ + "budget" + ] + } + }, + "/allocations/{allocation_id}/seal": { + "post": { + "operationId": "post_allocations_seal_allocations__allocation_id__seal_post", + "parameters": [ + { + "description": "Target Allocation's id.", + "in": "path", + "name": "allocation_id", + "required": true, + "schema": { + "description": "Target Allocation's id.", + "format": "uuid", + "title": "Allocation Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SealAllocationRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (whitespace-only reason)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No Allocation exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Allocation is not in Active status (seal_allocation closes an open spend window; void a dormant grant instead)." + }, + "422": { + "description": "Request body failed schema validation (reason length out of bounds) or path parameter is not a UUID." + } + }, + "summary": "Seal an Active Allocation (Active -> Sealed, closing the books)", + "tags": [ + "budget" + ] + } + }, + "/allocations/{allocation_id}/void": { + "post": { + "operationId": "post_allocations_void_allocations__allocation_id__void_post", + "parameters": [ + { + "description": "Target Allocation's id.", + "in": "path", + "name": "allocation_id", + "required": true, + "schema": { + "description": "Target Allocation's id.", + "format": "uuid", + "title": "Allocation Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidAllocationRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (whitespace-only reason)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No Allocation exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Allocation is not in Granted or Active status (a sealed or voided envelope cannot be re-terminated)." + }, + "422": { + "description": "Request body failed schema validation (missing or over-length reason) or path parameter is not a UUID." + } + }, + "summary": "Void a Granted or Active Allocation (-> Voided)", + "tags": [ + "budget" + ] + } + }, "/assemblies": { "post": { "operationId": "post_assemblies_assemblies_post", diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 99dc6eb8017..2a7aa87cdb8 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -86,6 +86,12 @@ from cora.api._run_supervisor import run_supervisor_lifespan from cora.api.middleware import BodySizeLimitMiddleware from cora.api.protected_resource_metadata import register_protected_resource_metadata_route +from cora.budget import ( + BudgetHandlers, + register_budget_routes, + register_budget_tools, + wire_budget, +) from cora.calibration import ( CalibrationHandlers, register_calibration_projections, @@ -588,6 +594,10 @@ def _get_campaign_handlers() -> CampaignHandlers: handlers: CampaignHandlers = fastapi_app.state.campaign return handlers + def _get_budget_handlers() -> BudgetHandlers: + handlers: BudgetHandlers = fastapi_app.state.budget + return handlers + def _get_agent_handlers() -> AgentHandlers: handlers: AgentHandlers = fastapi_app.state.agent return handlers @@ -608,6 +618,7 @@ def _get_agent_handlers() -> AgentHandlers: register_caution_tools(mcp, get_handlers=_get_caution_handlers) register_calibration_tools(mcp, get_handlers=_get_calibration_handlers) register_campaign_tools(mcp, get_handlers=_get_campaign_handlers) + register_budget_tools(mcp, get_handlers=_get_budget_handlers) register_agent_tools(mcp, get_handlers=_get_agent_handlers) register_conduct_run_tools(mcp, get_runtime=_get_compute_run_driver, get_deps=_get_deps) mcp_app = mcp.streamable_http_app() @@ -733,6 +744,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.state.caution = wire_caution(deps) app.state.calibration = wire_calibration(deps) app.state.campaign = wire_campaign(deps) + app.state.budget = wire_budget(deps) app.state.agent = wire_agent(deps) app.state.compute_run_driver = ComputeRunDriver( @@ -1078,6 +1090,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: register_caution_routes(fastapi_app) register_calibration_routes(fastapi_app) register_campaign_routes(fastapi_app) + register_budget_routes(fastapi_app) register_agent_routes(fastapi_app) register_conduct_run_routes(fastapi_app) # RFC 9728 Protected Resource Metadata. Discoverable diff --git a/apps/api/src/cora/budget/__init__.py b/apps/api/src/cora/budget/__init__.py new file mode 100644 index 00000000000..eb248c4df04 --- /dev/null +++ b/apps/api/src/cora/budget/__init__.py @@ -0,0 +1,44 @@ +"""Budget bounded context. + +Owns the Allocation aggregate: the spending envelope the deployment's +beamline receives and spends, the system-of-record half of the +facility LLM-budget arc. Design lock: [[project_allocation_design]]. + + - `Allocation` aggregate: identity + USD ceiling + holder note + + optional Campaign binding + 4-state FSM + `Granted -> Active -> Sealed`, with `Voided` from Granted/Active. + +Balance is never stored: it folds from the inference ledger every +other spend tier sums. The allocation's own lifecycle IS the award +window (`activated_at` to `sealed_at`); the gate stack's envelope +check (stage C) reads the Active envelope's ceiling against the +instance-total ledger fold over that window. + +Lifecycle slices: `grant_allocation` (genesis -> Granted, dormant), +`activate_allocation` (Granted -> Active; opens the spend window), +`amend_allocation_ceiling` (Granted | Active; PUT semantics, the +cost-overrun tighten lever), `seal_allocation` (Active -> Sealed; +closing the books with a server-computed final-spend snapshot), +`void_allocation` (Granted | Active -> Voided; operator withdraws a +mistaken grant, REQUIRED reason). + +Layout: + aggregates// -- aggregate state, events union, evolver, read + features/_/ -- vertical slice: command + decider + handler + route + tool + wire.py -- BudgetHandlers bundle + wire_budget(deps) + routes.py -- register_budget_routes(app) + tools.py -- register_budget_tools(mcp, get_handlers=...) +""" + +from cora.budget.errors import UnauthorizedError +from cora.budget.routes import register_budget_routes +from cora.budget.tools import register_budget_tools +from cora.budget.wire import BudgetHandlers, wire_budget + +__all__ = [ + "BudgetHandlers", + "UnauthorizedError", + "register_budget_routes", + "register_budget_tools", + "wire_budget", +] diff --git a/apps/api/src/cora/budget/_allocation_update_handler.py b/apps/api/src/cora/budget/_allocation_update_handler.py new file mode 100644 index 00000000000..e57c2dbeb23 --- /dev/null +++ b/apps/api/src/cora/budget/_allocation_update_handler.py @@ -0,0 +1,212 @@ +"""Allocation's update-handler factory (thin wrapper + actor-stamping variant). + +Mirrors `cora.agent._language_model_update_handler` per the +per-aggregate factory convention. Two transition slices bind through +the thin wrapper (`amend_allocation_ceiling`, `void_allocation`); +`activate_allocation` binds through the actor-stamping variant +because the fold records `(activated_at, activated_by)` per +[[project_fold_symmetry_design]]. `seal_allocation` stays longhand: +it awaits the injected TotalSpendReader between load and decide, +which no factory expresses. + +## Allocation-side knobs closed over + + - `stream_type = "Allocation"`. + - `target_id_attr = "allocation_id"` -- every Allocation transition + command exposes `allocation_id: UUID`. + - `unauthorized_error = UnauthorizedError` from the budget BC (the + BC's application-error namespace, so a budget 403 stays one class + in log search). + - The four codec functions imported from + `cora.budget.aggregates.allocation`. + +`extra_log_fields` is a per-slice optional extractor for command- +specific fields the structured log should emit, same contract as the +Agent factory. + +The actor variant mirrors `make_agent_actor_update_handler` +byte-for-byte modulo the Allocation-specific defaults; the body +duplicates `make_update_handler`'s flow because `principal_id` only +enters scope at handler-call time, not at factory-build time. +""" + +from collections.abc import Callable, Sequence +from datetime import datetime # noqa: TC003 (runtime-imported for clarity) +from typing import Any, Protocol +from uuid import UUID + +from cora.budget.aggregates.allocation import ( + AllocationEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.budget.errors import UnauthorizedError +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.infrastructure.update_handler import make_update_handler +from cora.shared.identity import ActorId + +_STREAM_TYPE = "Allocation" +_TARGET_ID_ATTR = "allocation_id" + + +def make_allocation_update_handler( + deps: Kernel, + *, + command_name: str, + log_prefix: str, + decide_fn: Callable[..., Sequence[AllocationEvent]], + extra_log_fields: Callable[[Any], dict[str, Any]] | None = None, +): + """Build an update-style handler for one Allocation slice (fold-NEITHER posture).""" + return make_update_handler( + deps, + stream_type=_STREAM_TYPE, + target_id_attr=_TARGET_ID_ATTR, + from_stored=from_stored, + to_payload=to_payload, + event_type_name=event_type_name, + fold=fold, + unauthorized_error=UnauthorizedError, + command_name=command_name, + log_prefix=log_prefix, + decide_fn=decide_fn, + extra_log_fields=extra_log_fields, + ) + + +class _ActorUpdateHandler(Protocol): + """Callable shape returned by `make_allocation_actor_update_handler`. + + Mirrors the cross-BC factory's `_UpdateHandler` shape so per-slice + `Handler` Protocols (which are narrower in `command`) keep + assigning without explicit casts. + """ + + async def __call__( + self, + command: Any, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def make_allocation_actor_update_handler( + deps: Kernel, + *, + command_name: str, + log_prefix: str, + decide_fn: Callable[..., Sequence[AllocationEvent]], + actor_kwarg: str, + extra_log_fields: Callable[[Any], dict[str, Any]] | None = None, +) -> _ActorUpdateHandler: + """Build an actor-stamping update handler for one Allocation slice. + + `actor_kwarg` is the decider's `_by` parameter name; the + handler passes the envelope's `principal_id` (wrapped in `ActorId`) + under that name on every call. Used by fold-symmetry slices + (`activate_allocation`) whose events carry a folded attribution + half on the payload. + """ + log = get_logger(log_prefix) + + async def handler( + command: Any, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + target_id: UUID = getattr(command, _TARGET_ID_ATTR) + extras: dict[str, Any] = extra_log_fields(command) if extra_log_fields is not None else {} + + log.info( + f"{log_prefix}.start", + command_name=command_name, + **{_TARGET_ID_ATTR: str(target_id)}, + **extras, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=command_name, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + log.info( + f"{log_prefix}.denied", + command_name=command_name, + **{_TARGET_ID_ATTR: str(target_id)}, + **extras, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now: datetime = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=target_id, + ) + history: list[AllocationEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide_fn( + state=state, + command=command, + now=now, + **{actor_kwarg: ActorId(principal_id)}, + ) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=command_name, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=target_id, + expected_version=current_version, + events=new_events, + ) + + log.info( + f"{log_prefix}.success", + command_name=command_name, + **{_TARGET_ID_ATTR: str(target_id)}, + **extras, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler + + +__all__ = ["make_allocation_actor_update_handler", "make_allocation_update_handler"] diff --git a/apps/api/src/cora/budget/aggregates/__init__.py b/apps/api/src/cora/budget/aggregates/__init__.py new file mode 100644 index 00000000000..cbbd4438c67 --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/__init__.py @@ -0,0 +1,11 @@ +"""Aggregate kernels owned by the budget BC. + +Budget is a single-aggregate BC today (Allocation). The `aggregates` +sub-package exists so other BCs can target the aggregate kernel +specifically via `cora.budget.aggregates` in `tach.toml`, while +`cora.budget.features` stays implicitly off-limits to sibling BCs +per the cross-BC dependency contract. + +This module is intentionally empty of re-exports: each aggregate +exposes its own surface via `cora.budget.aggregates.`. +""" diff --git a/apps/api/src/cora/budget/aggregates/allocation/__init__.py b/apps/api/src/cora/budget/aggregates/allocation/__init__.py new file mode 100644 index 00000000000..b464d71bd94 --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/allocation/__init__.py @@ -0,0 +1,74 @@ +"""Allocation aggregate: status FSM, holder-note and reason VOs, +ceiling validation, errors, events, evolver, read repo. + +The spending envelope the deployment's beamline receives and spends, +homed in the budget BC. Design lock: [[project_allocation_design]]. + +Vertical slices that operate on this aggregate live under +`cora.budget.features._allocation*/` and import from here for +state and event types. + +Public surface: enum + VOs + ceiling validator + errors + events + +evolver + load_allocation. +""" + +from cora.budget.aggregates.allocation.events import ( + AllocationActivated, + AllocationCeilingAmended, + AllocationEvent, + AllocationGranted, + AllocationSealed, + AllocationVoided, + event_type_name, + from_stored, + to_payload, +) +from cora.budget.aggregates.allocation.evolver import evolve, fold +from cora.budget.aggregates.allocation.read import load_allocation +from cora.budget.aggregates.allocation.state import ( + ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + Allocation, + AllocationAlreadyExistsError, + AllocationCannotActivateError, + AllocationCannotAmendError, + AllocationCannotSealError, + AllocationCannotVoidError, + AllocationHolderNote, + AllocationNotFoundError, + AllocationReason, + AllocationStatus, + InvalidAllocationCeilingError, + InvalidAllocationHolderNoteError, + InvalidAllocationReasonError, + validate_allocation_ceiling, +) + +__all__ = [ + "ALLOCATION_HOLDER_NOTE_MAX_LENGTH", + "Allocation", + "AllocationActivated", + "AllocationAlreadyExistsError", + "AllocationCannotActivateError", + "AllocationCannotAmendError", + "AllocationCannotSealError", + "AllocationCannotVoidError", + "AllocationCeilingAmended", + "AllocationEvent", + "AllocationGranted", + "AllocationHolderNote", + "AllocationNotFoundError", + "AllocationReason", + "AllocationSealed", + "AllocationStatus", + "AllocationVoided", + "InvalidAllocationCeilingError", + "InvalidAllocationHolderNoteError", + "InvalidAllocationReasonError", + "event_type_name", + "evolve", + "fold", + "from_stored", + "load_allocation", + "to_payload", + "validate_allocation_ceiling", +] diff --git a/apps/api/src/cora/budget/aggregates/allocation/events.py b/apps/api/src/cora/budget/aggregates/allocation/events.py new file mode 100644 index 00000000000..c9fa910eb8b --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/allocation/events.py @@ -0,0 +1,305 @@ +"""Domain events emitted by the Allocation aggregate, plus the discriminated union. + +Mirrors the locked event-module shape: event classes, discriminated +union, `event_type_name`, `to_payload`, `from_stored`. + +Five genesis-and-lifecycle events: + + - `AllocationGranted` -- genesis (Granted); the award verb of + the paper and of HPC accounting, an + allowlisted deviation from the + Defined/Registered genesis convention + - `AllocationActivated` -- transition (Granted -> Active); opens + the spend window + - `AllocationCeilingAmended` -- non-transition amendment (Granted | + Active); PUT semantics, the supplied + ceiling IS the post-amend ceiling + - `AllocationSealed` -- transition (Active -> Sealed); + terminal, carries the final-spend + snapshot + - `AllocationVoided` -- transition (Granted | Active -> + Voided); terminal, REQUIRED reason + +Events carry primitives (str / float), not the state VOs, matching how +`LanguageModelDefined` carries plain strings: the evolver wraps +primitives back into VOs at fold time, so payload bytes stay decoupled +from VO validation churn. + +`granted_by` / `activated_by` / `sealed_by` carry the acting +principal on the PAYLOAD (not only the envelope) because the folded +state records those fact-acts as `_at` / `_by` pairs per +[[project_fold_symmetry_design]]; `AllocationCeilingAmended` and +`AllocationVoided` fold no timestamp, so their actor lives only on +the envelope (`StoredEvent.principal_id`). +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, assert_never +from uuid import UUID + +from cora.infrastructure.event_payload import deserialize_or_raise +from cora.infrastructure.ports.event_store import StoredEvent +from cora.shared.identity import ActorId + +# --------------------------------------------------------------------------- +# Event classes +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class AllocationGranted: + """A new allocation envelope was granted (genesis -> Granted). + + Initial status implicitly `Granted` (event type IS the state-change + indicator; the genesis evolver hardcodes the mapping). A Granted + envelope is dormant: the spend window opens only at activation, so + granting never affects a running experiment. + + `campaign_id` is a bare cross-BC reference (eventual-consistency + stance per the Caution / Calibration precedent); when set, the + stage-C CampaignClosed subscriber seals this envelope beside the + campaign's own books. + """ + + allocation_id: UUID + ceiling_usd: float + campaign_id: UUID | None + holder_note: str + granted_by: ActorId + occurred_at: datetime + + +@dataclass(frozen=True) +class AllocationActivated: + """The spend window opened (Granted -> Active). + + From this moment the envelope check arms: `occurred_at` becomes + the `activated_at` window start every total-spend fold uses. + """ + + allocation_id: UUID + activated_by: ActorId + occurred_at: datetime + + +@dataclass(frozen=True) +class AllocationCeilingAmended: + """The ceiling was amended (Granted | Active; PUT semantics). + + The supplied ceiling IS the post-amend ceiling, not a delta: the + cost-overrun tighten lever must land at an exact number the + operator chose, and a delta would compound across retries. + No status change; amendment is not a lifecycle step. + """ + + allocation_id: UUID + ceiling_usd: float + occurred_at: datetime + + +@dataclass(frozen=True) +class AllocationSealed: + """The books closed with a final-spend snapshot (Active -> Sealed). Terminal. + + `spent_usd` is computed by the caller at seal time (folded from + the inference ledger over [activated_at, sealed_at)); storing the + snapshot here is the closing-the-books claim: the sealed envelope + carries its own final figure even as the ledger keeps growing for + successor envelopes. `reason` is optional: a routine seal needs no + justification, an early one usually carries context. + """ + + allocation_id: UUID + spent_usd: float + reason: str | None + sealed_by: ActorId + occurred_at: datetime + + +@dataclass(frozen=True) +class AllocationVoided: + """The grant was withdrawn (Granted | Active -> Voided). Terminal. + + `reason` is REQUIRED: withdrawing an award is a governance act the + audit log must always carry context for, distinct from the seal + (which closes books with a spend snapshot; the void records that + the award never stood). + """ + + allocation_id: UUID + reason: str + occurred_at: datetime + + +# Discriminated union of every event the Allocation aggregate emits. +AllocationEvent = ( + AllocationGranted + | AllocationActivated + | AllocationCeilingAmended + | AllocationSealed + | AllocationVoided +) + + +def event_type_name(event: AllocationEvent) -> str: + """Discriminator string written into StoredEvent.event_type.""" + return type(event).__name__ + + +def to_payload(event: AllocationEvent) -> dict[str, Any]: + """Serialise an Allocation event to a JSON-friendly dict for jsonb storage. + + Primitives only: UUIDs (including ActorId) become strings, + datetimes become ISO-8601 strings. + """ + match event: + case AllocationGranted( + allocation_id=allocation_id, + ceiling_usd=ceiling_usd, + campaign_id=campaign_id, + holder_note=holder_note, + granted_by=granted_by, + occurred_at=occurred_at, + ): + return { + "allocation_id": str(allocation_id), + "ceiling_usd": ceiling_usd, + "campaign_id": str(campaign_id) if campaign_id is not None else None, + "holder_note": holder_note, + "granted_by": str(granted_by), + "occurred_at": occurred_at.isoformat(), + } + case AllocationActivated( + allocation_id=allocation_id, + activated_by=activated_by, + occurred_at=occurred_at, + ): + return { + "allocation_id": str(allocation_id), + "activated_by": str(activated_by), + "occurred_at": occurred_at.isoformat(), + } + case AllocationCeilingAmended( + allocation_id=allocation_id, + ceiling_usd=ceiling_usd, + occurred_at=occurred_at, + ): + return { + "allocation_id": str(allocation_id), + "ceiling_usd": ceiling_usd, + "occurred_at": occurred_at.isoformat(), + } + case AllocationSealed( + allocation_id=allocation_id, + spent_usd=spent_usd, + reason=reason, + sealed_by=sealed_by, + occurred_at=occurred_at, + ): + return { + "allocation_id": str(allocation_id), + "spent_usd": spent_usd, + "reason": reason, + "sealed_by": str(sealed_by), + "occurred_at": occurred_at.isoformat(), + } + case AllocationVoided( + allocation_id=allocation_id, + reason=reason, + occurred_at=occurred_at, + ): + return { + "allocation_id": str(allocation_id), + "reason": reason, + "occurred_at": occurred_at.isoformat(), + } + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def from_stored(stored: StoredEvent) -> AllocationEvent: + """Rebuild an Allocation event from a StoredEvent loaded from the event store. + + Dispatches on `stored.event_type`; raises ValueError on unknown + discriminators so a stream contaminated with foreign event types + fails loud rather than silently being dropped by the evolver. + + Each arm delegates to `deserialize_or_raise`, which catches + KeyError / TypeError / AttributeError and re-raises as ValueError + tagged with the event-type name. + + Nullable fields (`campaign_id`, the Sealed arm's `reason`) use + `payload.get(...)` so future migrations that add new nullable + fields remain forward-compat at replay time. + """ + payload = stored.payload + match stored.event_type: + case "AllocationGranted": + + def _build_granted() -> AllocationGranted: + campaign_id_raw = payload.get("campaign_id") + return AllocationGranted( + allocation_id=UUID(payload["allocation_id"]), + ceiling_usd=payload["ceiling_usd"], + campaign_id=(UUID(campaign_id_raw) if campaign_id_raw is not None else None), + holder_note=payload["holder_note"], + granted_by=ActorId(UUID(payload["granted_by"])), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + return deserialize_or_raise("AllocationGranted", _build_granted) + case "AllocationActivated": + return deserialize_or_raise( + "AllocationActivated", + lambda: AllocationActivated( + allocation_id=UUID(payload["allocation_id"]), + activated_by=ActorId(UUID(payload["activated_by"])), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "AllocationCeilingAmended": + return deserialize_or_raise( + "AllocationCeilingAmended", + lambda: AllocationCeilingAmended( + allocation_id=UUID(payload["allocation_id"]), + ceiling_usd=payload["ceiling_usd"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "AllocationSealed": + return deserialize_or_raise( + "AllocationSealed", + lambda: AllocationSealed( + allocation_id=UUID(payload["allocation_id"]), + spent_usd=payload["spent_usd"], + reason=payload.get("reason"), + sealed_by=ActorId(UUID(payload["sealed_by"])), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "AllocationVoided": + return deserialize_or_raise( + "AllocationVoided", + lambda: AllocationVoided( + allocation_id=UUID(payload["allocation_id"]), + reason=payload["reason"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case _: + msg = f"Unknown AllocationEvent event_type: {stored.event_type!r}" + raise ValueError(msg) + + +__all__ = [ + "AllocationActivated", + "AllocationCeilingAmended", + "AllocationEvent", + "AllocationGranted", + "AllocationSealed", + "AllocationVoided", + "event_type_name", + "from_stored", + "to_payload", +] diff --git a/apps/api/src/cora/budget/aggregates/allocation/evolver.py b/apps/api/src/cora/budget/aggregates/allocation/evolver.py new file mode 100644 index 00000000000..10e31c50328 --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/allocation/evolver.py @@ -0,0 +1,121 @@ +"""Evolver: replay events to reconstruct Allocation state. + +Mirror of the other aggregate evolvers. The terminal `assert_never` +case forces pyright (and the runtime) to error if a new event type +is added to `AllocationEvent` without a matching match arm here. + +Status mapping per event type: + + - `AllocationGranted` -> GRANTED (genesis; sets + `granted_at` + `granted_by`) + - `AllocationActivated` -> ACTIVE (single-source: Granted; + sets `activated_at` + + `activated_by`, the spend-window + start) + - `AllocationCeilingAmended` -> no status change (source: Granted | + Active; overwrites `ceiling_usd`, + PUT semantics) + - `AllocationSealed` -> SEALED (single-source: Active; sets + `sealed_at` + `sealed_by` + + `spent_usd_at_seal` + optional + `end_reason`) + - `AllocationVoided` -> VOIDED (source: Granted | Active; + sets `end_reason`) + +Source-state guards live at the deciders, NOT here; the evolver +trusts the event log (folded events have already passed their +decider). + +Transition arms use `dataclasses.replace`, which carries every +untouched field forward by construction, so the silent-wipe bug +class the Agent evolver guards against field-by-field cannot occur +here. + +Transition events applied to empty state raise `ValueError` via the +shared `require_state` helper at `cora.infrastructure.evolver`. +""" + +from collections.abc import Sequence +from dataclasses import replace +from typing import assert_never + +from cora.budget.aggregates.allocation.events import ( + AllocationActivated, + AllocationCeilingAmended, + AllocationEvent, + AllocationGranted, + AllocationSealed, + AllocationVoided, +) +from cora.budget.aggregates.allocation.state import ( + Allocation, + AllocationHolderNote, + AllocationStatus, +) +from cora.infrastructure.evolver import require_state + + +def evolve(state: Allocation | None, event: AllocationEvent) -> Allocation: + """Apply one event to the current state.""" + match event: + case AllocationGranted( + allocation_id=allocation_id, + ceiling_usd=ceiling_usd, + campaign_id=campaign_id, + holder_note=holder_note, + granted_by=granted_by, + occurred_at=occurred_at, + ): + _ = state # AllocationGranted is the genesis event; prior state ignored + return Allocation( + id=allocation_id, + ceiling_usd=ceiling_usd, + holder_note=AllocationHolderNote(holder_note), + campaign_id=campaign_id, + granted_at=occurred_at, + granted_by=granted_by, + status=AllocationStatus.GRANTED, + ) + case AllocationActivated(activated_by=activated_by, occurred_at=occurred_at): + prior = require_state(state, "AllocationActivated") + return replace( + prior, + status=AllocationStatus.ACTIVE, + activated_at=occurred_at, + activated_by=activated_by, + ) + case AllocationCeilingAmended(ceiling_usd=ceiling_usd): + prior = require_state(state, "AllocationCeilingAmended") + return replace(prior, ceiling_usd=ceiling_usd) + case AllocationSealed( + spent_usd=spent_usd, + reason=reason, + sealed_by=sealed_by, + occurred_at=occurred_at, + ): + prior = require_state(state, "AllocationSealed") + return replace( + prior, + status=AllocationStatus.SEALED, + sealed_at=occurred_at, + sealed_by=sealed_by, + spent_usd_at_seal=spent_usd, + end_reason=reason, + ) + case AllocationVoided(reason=reason): + prior = require_state(state, "AllocationVoided") + return replace( + prior, + status=AllocationStatus.VOIDED, + end_reason=reason, + ) + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def fold(events: Sequence[AllocationEvent]) -> Allocation | None: + """Replay a stream of events from the empty initial state.""" + state: Allocation | None = None + for event in events: + state = evolve(state, event) + return state diff --git a/apps/api/src/cora/budget/aggregates/allocation/read.py b/apps/api/src/cora/budget/aggregates/allocation/read.py new file mode 100644 index 00000000000..3e0715ac003 --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/allocation/read.py @@ -0,0 +1,24 @@ +"""Read repository for the Allocation aggregate. + +`load_allocation(event_store, allocation_id) -> Allocation | None` +mirrors `load_language_model` / `load_campaign`. Used by the +update-style handlers (activate / amend / seal / void load the target +envelope before the decider) and by the grant handler's +caller-supplied-id collision check. +""" + +from uuid import UUID + +from cora.budget.aggregates.allocation.events import from_stored +from cora.budget.aggregates.allocation.evolver import fold +from cora.budget.aggregates.allocation.state import Allocation +from cora.infrastructure.ports import EventStore + +_STREAM_TYPE = "Allocation" + + +async def load_allocation(event_store: EventStore, allocation_id: UUID) -> Allocation | None: + """Load and fold an Allocation's event stream into current state.""" + stored, _version = await event_store.load(_STREAM_TYPE, allocation_id) + events = [from_stored(s) for s in stored] + return fold(events) diff --git a/apps/api/src/cora/budget/aggregates/allocation/state.py b/apps/api/src/cora/budget/aggregates/allocation/state.py new file mode 100644 index 00000000000..6894ff1f187 --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/allocation/state.py @@ -0,0 +1,302 @@ +"""Allocation aggregate state, VOs, enum, and domain errors. + +`Allocation` is the spending envelope a holder receives and spends: +v1's holder is the deployment's own beamline (one CORA instance, one +beamline at the pilot), with an optional `campaign_id` reference that +binds the award window to a Campaign's lifecycle. Design lock: +[[project_allocation_design]]. + +Balance is never stored: it folds from the same inference ledger every +other spend tier sums, so this aggregate carries only the envelope's +declared shape (ceiling, holder note, window timestamps) and its FSM. + +Per [[project_lifecycle_status_naming]] the FSM is a single axis, +`AllocationStatus`: + + Granted -> Active -> Sealed + {Granted, Active} -> Voided + +`Granted` is the award's dormant phase (granted at approval, dormant +until work starts); `Active` opens the spend window (`activated_at`); +`Sealed` closes the books with a final-spend snapshot +(`spent_usd_at_seal`), the normal terminal; `Voided` is the operator +withdrawing a mistaken grant, terminal without a spend snapshot. The +two terminals stay distinct because they answer different audit +questions (did this envelope's books close, or did the award never +stand?). + +## Window + +The allocation's own lifecycle IS the award window: `activated_at` +opens it and `sealed_at` closes it. No calendar arithmetic lives here; +the calendar case stays the per-agent caps' territory. + +## Ceiling + +`ceiling_usd` is USD-only at v1 (facility-unit accounting waits for +the first in-house GPU pool). It must be finite and strictly positive: +a zero or negative ceiling would make every envelope check refuse +unconditionally, and a non-finite one would never refuse, so both are +construction-time errors rather than representable states. +""" + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from math import isfinite +from uuid import UUID + +from cora.shared.bounded_text import bounded_name, validate_bounded_text +from cora.shared.identity import ActorId +from cora.shared.text_bounds import REASON_MAX_LENGTH + +ALLOCATION_HOLDER_NOTE_MAX_LENGTH = 200 + + +class AllocationStatus(StrEnum): + """The allocation envelope's lifecycle state. + + - `Granted` -- awarded but dormant; the spend window has not + opened and the envelope check does not arm. + - `Active` -- the spend window is open (`activated_at`); the + gate stack refuses spend that would breach + `ceiling_usd`. + - `Sealed` -- books closed with a final-spend snapshot + (`spent_usd_at_seal`). Terminal. + - `Voided` -- the operator withdrew a mistaken grant; no spend + snapshot because the award never stood. Terminal. + """ + + GRANTED = "Granted" + ACTIVE = "Active" + SEALED = "Sealed" + VOIDED = "Voided" + + +# --------------------------------------------------------------------------- +# Domain validation errors (raised by VO __post_init__ + deciders) +# --------------------------------------------------------------------------- + + +class InvalidAllocationCeilingError(ValueError): + """The supplied ceiling is not a finite, strictly positive USD amount. + + Zero and negative ceilings would refuse all spend unconditionally; + NaN and infinity would never refuse. Both poison the envelope + check, so they fail at the decider rather than folding into state. + """ + + def __init__(self, value: float) -> None: + super().__init__( + f"Allocation ceiling must be a finite USD amount greater than 0 (got {value!r})" + ) + self.value = value + + +class InvalidAllocationHolderNoteError(ValueError): + """The supplied holder note is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Allocation holder note must be 1-{ALLOCATION_HOLDER_NOTE_MAX_LENGTH} chars " + f"after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidAllocationReasonError(ValueError): + """A supplied seal or void reason is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Allocation reason must be 1-{REASON_MAX_LENGTH} chars after trimming (got: {value!r})" + ) + self.value = value + + +# --------------------------------------------------------------------------- +# Aggregate-level guard errors (genesis collision / not-found / cannot-transition) +# --------------------------------------------------------------------------- + + +class AllocationAlreadyExistsError(Exception): + """Attempted to grant an allocation whose stream already has events. + + Per [[project_genesis_error_classes]] this stays un-hoisted. + """ + + def __init__(self, allocation_id: UUID) -> None: + super().__init__(f"Allocation {allocation_id} already exists") + self.allocation_id = allocation_id + + +class AllocationNotFoundError(Exception): + """Attempted an operation on an allocation whose stream has no events.""" + + def __init__(self, allocation_id: UUID) -> None: + super().__init__(f"Allocation {allocation_id} not found") + self.allocation_id = allocation_id + + +class AllocationCannotActivateError(Exception): + """Attempted `activate_allocation` from a disqualifying status. + + Source set is `{Granted}` only: activation opens the spend window + exactly once, and re-activating an Active, Sealed, or Voided + envelope would silently reset or resurrect the window the seal's + spend snapshot depends on. + """ + + def __init__(self, allocation_id: UUID, current_status: "AllocationStatus") -> None: + super().__init__( + f"Allocation {allocation_id} cannot be activated: currently in status " + f"{current_status.value}, activate_allocation requires " + f"{AllocationStatus.GRANTED.value}" + ) + self.allocation_id = allocation_id + self.current_status = current_status + + +class AllocationCannotAmendError(Exception): + """Attempted `amend_allocation_ceiling` from a disqualifying status. + + Source set is `{Granted, Active}`: the ceiling is the cost-overrun + tighten lever and stays amendable while the envelope can still + spend, but a terminal envelope's books are closed and amending + them would rewrite audit history. + """ + + def __init__(self, allocation_id: UUID, current_status: "AllocationStatus") -> None: + super().__init__( + f"Allocation {allocation_id} cannot amend its ceiling: currently in status " + f"{current_status.value}, amend_allocation_ceiling requires " + f"{AllocationStatus.GRANTED.value} or {AllocationStatus.ACTIVE.value}" + ) + self.allocation_id = allocation_id + self.current_status = current_status + + +class AllocationCannotSealError(Exception): + """Attempted `seal_allocation` from a disqualifying status. + + Source set is `{Active}` only: the seal closes an OPEN spend + window with a final-spend snapshot. A Granted envelope has no + window to close (void it instead), and a terminal envelope is + already closed. + """ + + def __init__(self, allocation_id: UUID, current_status: "AllocationStatus") -> None: + super().__init__( + f"Allocation {allocation_id} cannot be sealed: currently in status " + f"{current_status.value}, seal_allocation requires {AllocationStatus.ACTIVE.value}" + ) + self.allocation_id = allocation_id + self.current_status = current_status + + +class AllocationCannotVoidError(Exception): + """Attempted `void_allocation` from a disqualifying status. + + Source set is `{Granted, Active}`: voiding withdraws a mistaken + grant while it still stands. A Sealed envelope's books are closed + (the seal is the honest record) and a Voided one is already gone; + re-terminating either would blur which end the audit trail + records. + """ + + def __init__(self, allocation_id: UUID, current_status: "AllocationStatus") -> None: + super().__init__( + f"Allocation {allocation_id} cannot be voided: currently in status " + f"{current_status.value}, void_allocation requires " + f"{AllocationStatus.GRANTED.value} or {AllocationStatus.ACTIVE.value}" + ) + self.allocation_id = allocation_id + self.current_status = current_status + + +# --------------------------------------------------------------------------- +# Value objects + ceiling validation +# --------------------------------------------------------------------------- + + +@bounded_name( + max_length=ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + error_class=InvalidAllocationHolderNoteError, +) +@dataclass(frozen=True) +class AllocationHolderNote: + """Operator-facing name for the envelope. Trimmed; 1-200 chars. + + A note, not a holder reference: v1's holder is implicitly the + deployment's own beamline, so the note names the envelope for + operators (award cycle, proposal block) rather than pointing at a + holder aggregate that does not exist yet. + """ + + value: str + + +@dataclass(frozen=True) +class AllocationReason: + """Operator-sourced reason text. Trimmed; 1-500 chars. + + One VO for both the seal's optional closing note and the void's + required withdrawal reason, sharing the fleet-wide + REASON_MAX_LENGTH bound per [[project_reason_max_length]]. + """ + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=REASON_MAX_LENGTH, + error_class=InvalidAllocationReasonError, + ) + object.__setattr__(self, "value", trimmed) + + +def validate_allocation_ceiling(value: float) -> None: + """Reject a ceiling that is not finite and strictly positive. + + Shared by the grant and amend deciders so both entry points to + `ceiling_usd` enforce one rule; a bare float (not a VO) because + the ceiling participates in arithmetic at the gate and a wrapper + would be unwrapped at every comparison site. + """ + if not isfinite(value) or value <= 0.0: + raise InvalidAllocationCeilingError(value) + + +# --------------------------------------------------------------------------- +# Allocation aggregate state +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Allocation: + """Aggregate root: one spending envelope for the deployment's beamline. + + Slim per [[project_fold_cost_principles]]: identity + declared + shape + status + window timestamps + closing metadata. The + `(granted_at, granted_by)` / `(activated_at, activated_by)` / + `(sealed_at, sealed_by)` pairs are the fold-symmetric fact-act + records per [[project_fold_symmetry_design]]. `spent_usd_at_seal` + is the closing-the-books snapshot (None until sealed); + `end_reason` carries whichever reason ended the envelope (the + seal's optional note or the void's required reason). + """ + + id: UUID + ceiling_usd: float + holder_note: AllocationHolderNote + campaign_id: UUID | None + granted_at: datetime + granted_by: ActorId + status: AllocationStatus = AllocationStatus.GRANTED + activated_at: datetime | None = None + activated_by: ActorId | None = None + sealed_at: datetime | None = None + sealed_by: ActorId | None = None + spent_usd_at_seal: float | None = None + end_reason: str | None = None diff --git a/apps/api/src/cora/budget/errors.py b/apps/api/src/cora/budget/errors.py new file mode 100644 index 00000000000..b9c4374a53a --- /dev/null +++ b/apps/api/src/cora/budget/errors.py @@ -0,0 +1,22 @@ +"""BC-application-layer errors for the budget BC. + +These errors are raised by application handlers (not domain logic) +and mapped to HTTP / MCP responses by the BC's exception handlers in +`cora/budget/routes.py`. + +Domain errors (raised by aggregates / deciders) live with their +aggregate, for example `aggregates/allocation/state.py`. + +Distinct class from each other BC's `UnauthorizedError`: each BC +owns its own application-error namespace so a budget 403 is +distinguishable from other BCs' 403s in logs / aggregator filters +(documented in CONTRIBUTING.md "BC-application-layer errors"). +""" + + +class UnauthorizedError(Exception): + """The Authorize port denied the command.""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason diff --git a/apps/api/src/cora/budget/features/__init__.py b/apps/api/src/cora/budget/features/__init__.py new file mode 100644 index 00000000000..48863370b8d --- /dev/null +++ b/apps/api/src/cora/budget/features/__init__.py @@ -0,0 +1,26 @@ +"""Vertical slices owned by the budget BC. + +Each subdirectory is one slice with the standard six-file shape: +__init__, command, decider, handler, route, tool. See `cora.budget` +package docstring for the module-as-namespace surface. + +Slices: `grant_allocation` (genesis), the window lifecycle +(`activate_allocation`, `seal_allocation`), the cost-overrun lever +(`amend_allocation_ceiling`), and the withdrawal (`void_allocation`). +""" + +from cora.budget.features import ( + activate_allocation, + amend_allocation_ceiling, + grant_allocation, + seal_allocation, + void_allocation, +) + +__all__ = [ + "activate_allocation", + "amend_allocation_ceiling", + "grant_allocation", + "seal_allocation", + "void_allocation", +] diff --git a/apps/api/src/cora/budget/features/activate_allocation/__init__.py b/apps/api/src/cora/budget/features/activate_allocation/__init__.py new file mode 100644 index 00000000000..74bb29bbf70 --- /dev/null +++ b/apps/api/src/cora/budget/features/activate_allocation/__init__.py @@ -0,0 +1,26 @@ +"""Vertical slice for the `ActivateAllocation` command. + +Module-as-namespace surface, symmetric with the other transition +command slices: + + from cora.budget.features import activate_allocation + + cmd = activate_allocation.ActivateAllocation(allocation_id=UUID("...")) + handler = activate_allocation.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.budget.features.activate_allocation import tool +from cora.budget.features.activate_allocation.command import ActivateAllocation +from cora.budget.features.activate_allocation.decider import decide +from cora.budget.features.activate_allocation.handler import Handler, bind +from cora.budget.features.activate_allocation.route import router + +__all__ = [ + "ActivateAllocation", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/budget/features/activate_allocation/command.py b/apps/api/src/cora/budget/features/activate_allocation/command.py new file mode 100644 index 00000000000..3f88a060585 --- /dev/null +++ b/apps/api/src/cora/budget/features/activate_allocation/command.py @@ -0,0 +1,25 @@ +"""The `ActivateAllocation` command -- intent dataclass for this slice. + +Opens a Granted envelope's spend window: `Granted -> Active`. From +this moment the envelope check arms and the event's `occurred_at` +becomes the `activated_at` window start every total-spend fold uses. +Source set is `{Granted}` only; re-activating any other status raises +`AllocationCannotActivateError` (silently resetting the window would +corrupt the seal's spend snapshot). + +Activation stays an operator ceremony at v1 (the bootstrap-then- +promote shape); auto-activate on CampaignStarted is deferred until +the manual step demonstrably chafes. The activating actor's identity +is injected by the handler from the envelope's `principal_id` (the +decider's `activated_by` kwarg); no actor field on the command. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class ActivateAllocation: + """Activate a Granted Allocation (`Granted -> Active`).""" + + allocation_id: UUID diff --git a/apps/api/src/cora/budget/features/activate_allocation/decider.py b/apps/api/src/cora/budget/features/activate_allocation/decider.py new file mode 100644 index 00000000000..7f17c41c192 --- /dev/null +++ b/apps/api/src/cora/budget/features/activate_allocation/decider.py @@ -0,0 +1,54 @@ +"""Pure decider for the `ActivateAllocation` command. + +Single-source transition: `Granted -> Active`. Strict-not-idempotent. + +`activated_by` is the envelope's acting principal, injected by the +actor-stamping handler so the `(activated_at, activated_by)` fact-act +folds with its attribution half. + +## Validation + + - State must not be None -> `AllocationNotFoundError` + - Current status must be `Granted` -> `AllocationCannotActivateError` +""" + +from datetime import datetime + +from cora.budget.aggregates.allocation import ( + Allocation, + AllocationActivated, + AllocationCannotActivateError, + AllocationNotFoundError, + AllocationStatus, +) +from cora.budget.features.activate_allocation.command import ActivateAllocation +from cora.shared.identity import ActorId + +_ACTIVATABLE_STATUSES: tuple[AllocationStatus, ...] = (AllocationStatus.GRANTED,) + + +def decide( + state: Allocation | None, + command: ActivateAllocation, + *, + now: datetime, + activated_by: ActorId, +) -> list[AllocationActivated]: + """Decide the events produced by activating a Granted Allocation. + + Invariants: + - State must not be None -> AllocationNotFoundError + - Current status must be Granted -> AllocationCannotActivateError + """ + if state is None: + raise AllocationNotFoundError(command.allocation_id) + if state.status not in _ACTIVATABLE_STATUSES: + raise AllocationCannotActivateError(state.id, state.status) + + return [ + AllocationActivated( + allocation_id=state.id, + activated_by=activated_by, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/budget/features/activate_allocation/handler.py b/apps/api/src/cora/budget/features/activate_allocation/handler.py new file mode 100644 index 00000000000..5b2bae36405 --- /dev/null +++ b/apps/api/src/cora/budget/features/activate_allocation/handler.py @@ -0,0 +1,43 @@ +"""Application handler for the `activate_allocation` slice. + +Built on the actor-stamping `make_allocation_actor_update_handler` +factory: the fold records `(activated_at, activated_by)` per +[[project_fold_symmetry_design]], so the handler threads the +envelope's `principal_id` into the decider as `activated_by`. +Single-source from Granted; the decider's guard enforces this, the +factory is source-set-agnostic. +""" + +from typing import Protocol +from uuid import UUID + +from cora.budget._allocation_update_handler import make_allocation_actor_update_handler +from cora.budget.features.activate_allocation.command import ActivateAllocation +from cora.budget.features.activate_allocation.decider import decide +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.routing import NIL_SENTINEL_ID + + +class Handler(Protocol): + """Callable interface every activate_allocation handler implements.""" + + async def __call__( + self, + command: ActivateAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build an activate_allocation handler closed over the shared deps.""" + return make_allocation_actor_update_handler( + deps, + command_name="ActivateAllocation", + log_prefix="activate_allocation", + decide_fn=decide, + actor_kwarg="activated_by", + ) diff --git a/apps/api/src/cora/budget/features/activate_allocation/route.py b/apps/api/src/cora/budget/features/activate_allocation/route.py new file mode 100644 index 00000000000..a8d3fc7fa8b --- /dev/null +++ b/apps/api/src/cora/budget/features/activate_allocation/route.py @@ -0,0 +1,67 @@ +"""HTTP route for the `activate_allocation` slice. + +Action endpoint at `POST /allocations/{allocation_id}/activate`. +Empty body. 204 No Content on success. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status + +from cora.budget.features.activate_allocation.command import ActivateAllocation +from cora.budget.features.activate_allocation.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.budget.activate_allocation + return handler + + +router = APIRouter(tags=["budget"]) + + +@router.post( + "/allocations/{allocation_id}/activate", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No Allocation exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Allocation is not in Granted status (activate_allocation is " + "single-source from Granted only)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter failed schema validation.", + }, + }, + summary="Activate a Granted Allocation (Granted -> Active)", +) +async def post_allocations_activate( + allocation_id: Annotated[UUID, Path(description="Target Allocation's id.")], + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + ActivateAllocation(allocation_id=allocation_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/budget/features/activate_allocation/tool.py b/apps/api/src/cora/budget/features/activate_allocation/tool.py new file mode 100644 index 00000000000..57f19cc9ac7 --- /dev/null +++ b/apps/api/src/cora/budget/features/activate_allocation/tool.py @@ -0,0 +1,49 @@ +"""MCP tool for the `activate_allocation` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.budget.features.activate_allocation.command import ActivateAllocation +from cora.budget.features.activate_allocation.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +class ActivateAllocationOutput(BaseModel): + """Structured output of the `activate_allocation` MCP tool.""" + + allocation_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `activate_allocation` tool on the given MCP server.""" + + @mcp.tool( + name="activate_allocation", + description=( + "Activate a Granted Allocation (Granted -> Active). Opens the " + "spend window: from this moment the envelope check arms and " + "total spend folds from the activation timestamp. Source set is " + "{Granted} only; activating from any other status raises an " + "error." + ), + ) + async def activate_allocation_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + allocation_id: Annotated[ + UUID, Field(description="Identifier of the Allocation to activate.") + ], + ) -> ActivateAllocationOutput: + handler = get_handler() + await handler( + ActivateAllocation(allocation_id=allocation_id), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return ActivateAllocationOutput(allocation_id=allocation_id) diff --git a/apps/api/src/cora/budget/features/amend_allocation_ceiling/__init__.py b/apps/api/src/cora/budget/features/amend_allocation_ceiling/__init__.py new file mode 100644 index 00000000000..9eb4d60b86f --- /dev/null +++ b/apps/api/src/cora/budget/features/amend_allocation_ceiling/__init__.py @@ -0,0 +1,28 @@ +"""Vertical slice for the `AmendAllocationCeiling` command. + +Module-as-namespace surface, symmetric with the other transition +command slices: + + from cora.budget.features import amend_allocation_ceiling + + cmd = amend_allocation_ceiling.AmendAllocationCeiling( + allocation_id=UUID("..."), ceiling_usd=18000.0, + ) + handler = amend_allocation_ceiling.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.budget.features.amend_allocation_ceiling import tool +from cora.budget.features.amend_allocation_ceiling.command import AmendAllocationCeiling +from cora.budget.features.amend_allocation_ceiling.decider import decide +from cora.budget.features.amend_allocation_ceiling.handler import Handler, bind +from cora.budget.features.amend_allocation_ceiling.route import router + +__all__ = [ + "AmendAllocationCeiling", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/budget/features/amend_allocation_ceiling/command.py b/apps/api/src/cora/budget/features/amend_allocation_ceiling/command.py new file mode 100644 index 00000000000..f537f106232 --- /dev/null +++ b/apps/api/src/cora/budget/features/amend_allocation_ceiling/command.py @@ -0,0 +1,24 @@ +"""The `AmendAllocationCeiling` command -- intent dataclass for this slice. + +Carries the FULL desired post-amend ceiling (PUT semantics, not a +delta): the amended ceiling IS the supplied number. The cost-overrun +tighten lever must land at an exact figure the operator chose, and a +delta would compound across retries. + +Allowed from `{Granted, Active}`; a terminal envelope's books are +closed and amending them would rewrite audit history. + +The amending actor's identity lives on the event envelope +(`StoredEvent.principal_id`); no actor field on the command/event. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class AmendAllocationCeiling: + """Amend an Allocation's USD ceiling (PUT semantics).""" + + allocation_id: UUID + ceiling_usd: float diff --git a/apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py b/apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py new file mode 100644 index 00000000000..1b15ef91a35 --- /dev/null +++ b/apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py @@ -0,0 +1,68 @@ +"""Pure decider for the `AmendAllocationCeiling` command. + +PUT semantics: the supplied ceiling IS the post-amend ceiling. +Source set is `{Granted, Active}`. Idempotent: an amendment that +matches the current ceiling returns `[]` (the update_agent_budget +precedent; a retried PUT must not append a second identical fact). + +## Validation + + - State must not be None -> `AllocationNotFoundError` + - Current status must be Granted or Active + -> `AllocationCannotAmendError` + - `ceiling_usd` must be finite and strictly positive + -> `InvalidAllocationCeilingError` +""" + +from datetime import datetime + +from cora.budget.aggregates.allocation import ( + Allocation, + AllocationCannotAmendError, + AllocationCeilingAmended, + AllocationNotFoundError, + AllocationStatus, + validate_allocation_ceiling, +) +from cora.budget.features.amend_allocation_ceiling.command import AmendAllocationCeiling + +_AMENDABLE_STATUSES: tuple[AllocationStatus, ...] = ( + AllocationStatus.GRANTED, + AllocationStatus.ACTIVE, +) + + +def decide( + state: Allocation | None, + command: AmendAllocationCeiling, + *, + now: datetime, +) -> list[AllocationCeilingAmended]: + """Decide the events produced by amending an Allocation's ceiling. + + Invariants: + - State must not be None -> AllocationNotFoundError + - Current status must be Granted or Active + -> AllocationCannotAmendError + - Ceiling must be finite and strictly positive + -> InvalidAllocationCeilingError + """ + if state is None: + raise AllocationNotFoundError(command.allocation_id) + if state.status not in _AMENDABLE_STATUSES: + raise AllocationCannotAmendError(state.id, state.status) + + # Validate BEFORE the idempotency short-circuit so a bad ceiling + # fires even when it happens to equal the stored value shape-wise. + validate_allocation_ceiling(command.ceiling_usd) + + if command.ceiling_usd == state.ceiling_usd: + return [] + + return [ + AllocationCeilingAmended( + allocation_id=state.id, + ceiling_usd=command.ceiling_usd, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/budget/features/amend_allocation_ceiling/handler.py b/apps/api/src/cora/budget/features/amend_allocation_ceiling/handler.py new file mode 100644 index 00000000000..4ac4520fa80 --- /dev/null +++ b/apps/api/src/cora/budget/features/amend_allocation_ceiling/handler.py @@ -0,0 +1,43 @@ +"""Application handler for the `amend_allocation_ceiling` slice. + +Built on the hoisted `make_allocation_update_handler` factory along +with `void_allocation`. The amending actor's identity lives on the +event envelope only (no fold-symmetry pair for the amendment, which +records no timestamp on state), so the thin fold-NEITHER factory +applies. Source set `{Granted, Active}` is enforced by the decider's +guard; the factory is source-set-agnostic. +""" + +from typing import Protocol +from uuid import UUID + +from cora.budget._allocation_update_handler import make_allocation_update_handler +from cora.budget.features.amend_allocation_ceiling.command import AmendAllocationCeiling +from cora.budget.features.amend_allocation_ceiling.decider import decide +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.routing import NIL_SENTINEL_ID + + +class Handler(Protocol): + """Callable interface every amend_allocation_ceiling handler implements.""" + + async def __call__( + self, + command: AmendAllocationCeiling, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build an amend_allocation_ceiling handler closed over the shared deps.""" + return make_allocation_update_handler( + deps, + command_name="AmendAllocationCeiling", + log_prefix="amend_allocation_ceiling", + decide_fn=decide, + extra_log_fields=lambda command: {"ceiling_usd": command.ceiling_usd}, + ) diff --git a/apps/api/src/cora/budget/features/amend_allocation_ceiling/route.py b/apps/api/src/cora/budget/features/amend_allocation_ceiling/route.py new file mode 100644 index 00000000000..e794f1d2293 --- /dev/null +++ b/apps/api/src/cora/budget/features/amend_allocation_ceiling/route.py @@ -0,0 +1,90 @@ +"""HTTP route for the `amend_allocation_ceiling` slice. + +Action endpoint at `POST /allocations/{allocation_id}/ceiling`. Body +carries `ceiling_usd`. PUT semantics: the supplied ceiling IS the +post-amend ceiling. 204 No Content on success (including the +idempotent no-op case), the update_agent_budget shape. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.budget.features.amend_allocation_ceiling.command import AmendAllocationCeiling +from cora.budget.features.amend_allocation_ceiling.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class AmendAllocationCeilingRequest(BaseModel): + """Body for `POST /allocations/{allocation_id}/ceiling`.""" + + ceiling_usd: float = Field( + ..., + gt=0.0, + description=( + "The post-amend USD ceiling (PUT semantics, not a delta). Finite and greater than 0." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.budget.amend_allocation_ceiling + return handler + + +router = APIRouter(tags=["budget"]) + + +@router.post( + "/allocations/{allocation_id}/ceiling", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated (non-finite ceiling).", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No Allocation exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Allocation is not in Granted or Active status " + "(amend_allocation_ceiling cannot rewrite a closed envelope)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation (missing field, " + "non-positive ceiling) or path parameter is not a UUID." + ), + }, + }, + summary="Amend an Allocation's USD ceiling (PUT semantics)", +) +async def post_allocations_ceiling( + allocation_id: Annotated[UUID, Path(description="Target Allocation's id.")], + body: AmendAllocationCeilingRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + AmendAllocationCeiling(allocation_id=allocation_id, ceiling_usd=body.ceiling_usd), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/budget/features/amend_allocation_ceiling/tool.py b/apps/api/src/cora/budget/features/amend_allocation_ceiling/tool.py new file mode 100644 index 00000000000..239e3295241 --- /dev/null +++ b/apps/api/src/cora/budget/features/amend_allocation_ceiling/tool.py @@ -0,0 +1,53 @@ +"""MCP tool for the `amend_allocation_ceiling` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.budget.features.amend_allocation_ceiling.command import AmendAllocationCeiling +from cora.budget.features.amend_allocation_ceiling.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +class AmendAllocationCeilingOutput(BaseModel): + """Structured output of the `amend_allocation_ceiling` MCP tool.""" + + allocation_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `amend_allocation_ceiling` tool on the given MCP server.""" + + @mcp.tool( + name="amend_allocation_ceiling", + description=( + "Amend an Allocation's USD ceiling (PUT semantics: the supplied " + "ceiling IS the post-amend ceiling, not a delta). The " + "cost-overrun tighten lever. Allowed from Granted or Active " + "only; a sealed or voided envelope's books cannot be rewritten." + ), + ) + async def amend_allocation_ceiling_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + allocation_id: Annotated[UUID, Field(description="Identifier of the Allocation to amend.")], + ceiling_usd: Annotated[ + float, + Field( + gt=0.0, + description="The post-amend USD ceiling. Finite and greater than 0.", + ), + ], + ) -> AmendAllocationCeilingOutput: + handler = get_handler() + await handler( + AmendAllocationCeiling(allocation_id=allocation_id, ceiling_usd=ceiling_usd), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return AmendAllocationCeilingOutput(allocation_id=allocation_id) diff --git a/apps/api/src/cora/budget/features/grant_allocation/__init__.py b/apps/api/src/cora/budget/features/grant_allocation/__init__.py new file mode 100644 index 00000000000..95916f23624 --- /dev/null +++ b/apps/api/src/cora/budget/features/grant_allocation/__init__.py @@ -0,0 +1,34 @@ +"""Vertical slice for the `GrantAllocation` command. + +Module-as-namespace surface, symmetric with the other create-style +command slices: + + from cora.budget.features import grant_allocation + + cmd = grant_allocation.GrantAllocation( + ceiling_usd=25000.0, + holder_note="FY26 imaging award", + ) + handler = grant_allocation.bind(deps) + allocation_id = await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.budget.features.grant_allocation import tool +from cora.budget.features.grant_allocation.command import GrantAllocation +from cora.budget.features.grant_allocation.decider import decide +from cora.budget.features.grant_allocation.handler import ( + Handler, + IdempotentHandler, + bind, +) +from cora.budget.features.grant_allocation.route import router + +__all__ = [ + "GrantAllocation", + "Handler", + "IdempotentHandler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/budget/features/grant_allocation/command.py b/apps/api/src/cora/budget/features/grant_allocation/command.py new file mode 100644 index 00000000000..5c44fda03e7 --- /dev/null +++ b/apps/api/src/cora/budget/features/grant_allocation/command.py @@ -0,0 +1,32 @@ +"""The `GrantAllocation` command -- intent dataclass for this slice. + +Carries the caller-controlled fields for one spending envelope: the +USD ceiling, the operator-facing holder note, and an optional +`campaign_id` that binds the award window to a Campaign's lifecycle +(the stage-C CampaignClosed subscriber seals a campaign-bound +envelope beside the campaign's own books). + +`allocation_id` is optional. None (the default) keeps the +`define_language_model` posture: the handler mints a UUIDv7 via the +injected IdGenerator port. A caller-supplied id serves deployments +that stand envelopes up from configuration and need stable ids across +environments; the handler loads that stream before deciding so the +genesis guard can reject a collision. + +The granting actor's identity is injected by the handler from the +envelope's `principal_id` (the decider's `granted_by` kwarg, per the +fold-symmetry attribution rule); no actor field on the command. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class GrantAllocation: + """Grant a new spending envelope (lands in Granted, dormant).""" + + ceiling_usd: float + holder_note: str + campaign_id: UUID | None = None + allocation_id: UUID | None = None diff --git a/apps/api/src/cora/budget/features/grant_allocation/decider.py b/apps/api/src/cora/budget/features/grant_allocation/decider.py new file mode 100644 index 00000000000..aabbbfeccda --- /dev/null +++ b/apps/api/src/cora/budget/features/grant_allocation/decider.py @@ -0,0 +1,74 @@ +"""Pure decider for the `GrantAllocation` command. + +Pure function: given the current Allocation state (None for a fresh +stream) and a `GrantAllocation` command, returns the events to append +on the Allocation stream. No I/O, no awaits, no side effects. + +`now` and `new_id` are injected by the application handler from the +Clock and IdGenerator ports; `granted_by` is the envelope's acting +principal, injected so the genesis fact-act folds with its +attribution half (the non-determinism principle: capture, don't +recompute). + +## Validation + + - State must be None (genesis-only) -> `AllocationAlreadyExistsError` + - `ceiling_usd` must be finite and strictly positive + -> `InvalidAllocationCeilingError` + - `holder_note` wrapped via `AllocationHolderNote(...)`; 1-200 chars + after trim -> `InvalidAllocationHolderNoteError` + - `campaign_id` is a bare cross-BC reference; NOT resolved here + (eventual-consistency stance per the Caution / Calibration + precedent), so no validation beyond the boundary's UUID typing. + +Initial status is implicit `Granted` (event type IS the state-change +indicator; the genesis evolver hardcodes the mapping). +""" + +from datetime import datetime +from uuid import UUID + +from cora.budget.aggregates.allocation import ( + Allocation, + AllocationAlreadyExistsError, + AllocationGranted, + AllocationHolderNote, + validate_allocation_ceiling, +) +from cora.budget.features.grant_allocation.command import GrantAllocation +from cora.shared.identity import ActorId + + +def decide( + state: Allocation | None, + command: GrantAllocation, + *, + now: datetime, + new_id: UUID, + granted_by: ActorId, +) -> list[AllocationGranted]: + """Decide the events produced by granting a new Allocation. + + Invariants: + - State must be None (genesis-only) -> AllocationAlreadyExistsError + - Ceiling must be finite and strictly positive + -> InvalidAllocationCeilingError + - Holder note must be valid -> InvalidAllocationHolderNoteError + (via AllocationHolderNote VO) + """ + if state is not None: + raise AllocationAlreadyExistsError(state.id) + + validate_allocation_ceiling(command.ceiling_usd) + holder_note = AllocationHolderNote(command.holder_note) + + return [ + AllocationGranted( + allocation_id=new_id, + ceiling_usd=command.ceiling_usd, + campaign_id=command.campaign_id, + holder_note=holder_note.value, + granted_by=granted_by, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/budget/features/grant_allocation/handler.py b/apps/api/src/cora/budget/features/grant_allocation/handler.py new file mode 100644 index 00000000000..d0aa4ec8ca1 --- /dev/null +++ b/apps/api/src/cora/budget/features/grant_allocation/handler.py @@ -0,0 +1,180 @@ +"""Application handler for the `grant_allocation` slice. + +Single-stream genesis on the budget BC's Allocation stream type. No +cross-BC co-write: `campaign_id` is a bare reference (nothing about +an envelope belongs on the Campaign stream), and the envelope's +holder is implicitly the deployment itself at v1. + +One deviation from the simple create-style template, mirroring +`define_language_model`: the command may carry a caller-supplied +`allocation_id` (deployments standing envelopes up from configuration +need stable ids across environments). The handler therefore loads the +target stream BEFORE deciding so the decider's genesis guard rejects +an id collision with `AllocationAlreadyExistsError`; the +`expected_version=0` append backstops the race where two callers +grant the same id concurrently. When the command carries None the +handler mints a UUIDv7 via the IdGenerator port. + +The handler threads `granted_by=ActorId(principal_id)` into the +decider so the genesis fact-act folds with its attribution half per +[[project_fold_symmetry_design]]. + +Idempotency-wrappable per the create-style convention; the +`with_idempotency` wrap is applied at `wire.py`, not here. + +`causation_id` is the id of the event/message that triggered this +command (None for HTTP / MCP root calls). +""" + +from typing import Protocol +from uuid import UUID + +from cora.budget.aggregates.allocation import ( + event_type_name, + load_allocation, + to_payload, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features.grant_allocation.command import GrantAllocation +from cora.budget.features.grant_allocation.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.shared.identity import ActorId + +_STREAM_TYPE = "Allocation" +_COMMAND_NAME = "GrantAllocation" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare grant_allocation handler -- what `bind()` returns. + + Returns the new envelope's UUID (caller-supplied when the command + carried one, handler-minted otherwise). Has no idempotency_key + kwarg; `with_idempotency` at wire.py adds it. + """ + + async def __call__( + self, + command: GrantAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +class IdempotentHandler(Protocol): + """grant_allocation handler with Idempotency-Key support.""" + + async def __call__( + self, + command: GrantAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + idempotency_key: str | None = None, + ) -> UUID: ... + + +def bind(deps: Kernel) -> Handler: + """Build a grant_allocation handler closed over the shared deps.""" + + async def handler( + command: GrantAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "grant_allocation.start", + command_name=_COMMAND_NAME, + ceiling_usd=command.ceiling_usd, + campaign_id=str(command.campaign_id) if command.campaign_id is not None else None, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "grant_allocation.denied", + command_name=_COMMAND_NAME, + ceiling_usd=command.ceiling_usd, + campaign_id=str(command.campaign_id) if command.campaign_id is not None else None, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + new_id = ( + command.allocation_id + if command.allocation_id is not None + else deps.id_generator.new_id() + ) + now = deps.clock.now() + + # Load the target stream so a caller-supplied id that already + # has events trips the decider's genesis guard (a handler-minted + # UUIDv7 folds to None here). + state = await load_allocation(deps.event_store, new_id) + + domain_events = decide( + state=state, + command=command, + now=now, + new_id=new_id, + granted_by=ActorId(principal_id), + ) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=new_id, + expected_version=0, + events=new_events, + ) + + _log.info( + "grant_allocation.success", + command_name=_COMMAND_NAME, + allocation_id=str(new_id), + ceiling_usd=command.ceiling_usd, + campaign_id=str(command.campaign_id) if command.campaign_id is not None else None, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + ) + return new_id + + return handler diff --git a/apps/api/src/cora/budget/features/grant_allocation/route.py b/apps/api/src/cora/budget/features/grant_allocation/route.py new file mode 100644 index 00000000000..2a8465bcf57 --- /dev/null +++ b/apps/api/src/cora/budget/features/grant_allocation/route.py @@ -0,0 +1,132 @@ +"""HTTP route for the `grant_allocation` slice. + +`POST /allocations` with body carrying ceiling_usd / holder_note + +optional campaign_id / allocation_id. Returns 201 + `{allocation_id}` +on success. + +`ceiling_usd` gets only a loose `gt=0` bound at the boundary; the +finite-and-positive rule is a domain concern the decider enforces, +so NaN / infinity map to 400 InvalidAllocationCeilingError rather +than 422. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request, status +from pydantic import BaseModel, Field + +from cora.budget.aggregates.allocation import ALLOCATION_HOLDER_NOTE_MAX_LENGTH +from cora.budget.features.grant_allocation.command import GrantAllocation +from cora.budget.features.grant_allocation.handler import IdempotentHandler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class GrantAllocationRequest(BaseModel): + """Body for `POST /allocations`.""" + + ceiling_usd: float = Field( + ..., + gt=0.0, + description="USD spending ceiling for the envelope. Finite and greater than 0.", + ) + holder_note: str = Field( + ..., + min_length=1, + max_length=ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + description=( + "Operator-facing name for the envelope (award cycle, proposal " + "block). The holder itself is implicitly this deployment's beamline." + ), + ) + campaign_id: UUID | None = Field( + default=None, + description=( + "Optional Campaign binding for the award window. When set, the " + "CampaignClosed subscriber seals this envelope beside the " + "campaign's own books. Pass null for an unbound envelope." + ), + ) + allocation_id: UUID | None = Field( + default=None, + description=( + "Optional caller-supplied id for configuration-seeded envelopes " + "needing stable ids across environments. Omit (or pass null) to " + "let the server mint a UUIDv7." + ), + ) + + +class GrantAllocationResponse(BaseModel): + """Response body for `POST /allocations`.""" + + allocation_id: UUID + + +def _get_handler(request: Request) -> IdempotentHandler: + handler: IdempotentHandler = request.app.state.budget.grant_allocation + return handler + + +router = APIRouter(tags=["budget"]) + + +@router.post( + "/allocations", + status_code=status.HTTP_201_CREATED, + response_model=GrantAllocationResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated (non-finite ceiling, whitespace-only holder note)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "The target Allocation stream already has events. Reachable " + "in practice only with a caller-supplied allocation_id; " + "essentially impossible for server-minted UUIDv7 ids." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation (missing field, length " + "out of bounds, non-positive ceiling, invalid UUID), OR " + "Idempotency-Key was reused with a different request body." + ), + }, + }, + summary="Grant a new spending envelope (lands in Granted, dormant)", +) +async def post_allocations( + body: GrantAllocationRequest, + handler: Annotated[IdempotentHandler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, +) -> GrantAllocationResponse: + allocation_id = await handler( + GrantAllocation( + ceiling_usd=body.ceiling_usd, + holder_note=body.holder_note, + campaign_id=body.campaign_id, + allocation_id=body.allocation_id, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + idempotency_key=idempotency_key, + ) + return GrantAllocationResponse(allocation_id=allocation_id) diff --git a/apps/api/src/cora/budget/features/grant_allocation/tool.py b/apps/api/src/cora/budget/features/grant_allocation/tool.py new file mode 100644 index 00000000000..9cf7b90d9e0 --- /dev/null +++ b/apps/api/src/cora/budget/features/grant_allocation/tool.py @@ -0,0 +1,91 @@ +"""MCP tool for the `grant_allocation` slice. + +Surfaces the same handler the REST route uses, exposed as a Model +Context Protocol tool. MCP tools currently bypass header extraction. +""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.budget.aggregates.allocation import ALLOCATION_HOLDER_NOTE_MAX_LENGTH +from cora.budget.features.grant_allocation.command import GrantAllocation +from cora.budget.features.grant_allocation.handler import IdempotentHandler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +class GrantAllocationOutput(BaseModel): + """Structured output of the `grant_allocation` MCP tool.""" + + allocation_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> None: + """Register the `grant_allocation` tool on the given MCP server.""" + + @mcp.tool( + name="grant_allocation", + description=( + "Grant a new spending envelope for this deployment's beamline " + "(lands in Granted, dormant; a separate activation opens the " + "spend window). Required: ceiling_usd, holder_note. Optional: " + "campaign_id (binds the award window to a Campaign), " + "allocation_id (omit to mint server-side)." + ), + ) + async def grant_allocation_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + ceiling_usd: Annotated[ + float, + Field( + gt=0.0, + description="USD spending ceiling. Finite and greater than 0.", + ), + ], + holder_note: Annotated[ + str, + Field( + min_length=1, + max_length=ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + description="Operator-facing name for the envelope.", + ), + ], + campaign_id: Annotated[ + UUID | None, + Field( + default=None, + description=( + "Optional Campaign binding for the award window. Null " + "grants an unbound envelope." + ), + ), + ] = None, + allocation_id: Annotated[ + UUID | None, + Field( + default=None, + description=( + "Optional caller-supplied id for configuration-seeded " + "envelopes. Null mints a server-side UUIDv7." + ), + ), + ] = None, + ) -> GrantAllocationOutput: + handler = get_handler() + new_id = await handler( + GrantAllocation( + ceiling_usd=ceiling_usd, + holder_note=holder_note, + campaign_id=campaign_id, + allocation_id=allocation_id, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return GrantAllocationOutput(allocation_id=new_id) diff --git a/apps/api/src/cora/budget/features/seal_allocation/__init__.py b/apps/api/src/cora/budget/features/seal_allocation/__init__.py new file mode 100644 index 00000000000..1839d2194df --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/__init__.py @@ -0,0 +1,37 @@ +"""Vertical slice for the `SealAllocation` command. + +Module-as-namespace surface, symmetric with the other transition +command slices, plus the slice-owned TotalSpendReader seam: + + from cora.budget.features import seal_allocation + + cmd = seal_allocation.SealAllocation(allocation_id=UUID("...")) + handler = seal_allocation.bind(deps, total_spend_reader=reader) + await handler(cmd, principal_id=..., correlation_id=...) + +`zero_total_spend` is the stage-A reader (`wire.py` binds it); stage +C replaces it with the SpendLookup-backed fold without touching this +slice. +""" + +from cora.budget.features.seal_allocation import tool +from cora.budget.features.seal_allocation.command import SealAllocation +from cora.budget.features.seal_allocation.decider import decide +from cora.budget.features.seal_allocation.handler import ( + Handler, + TotalSpendReader, + bind, + zero_total_spend, +) +from cora.budget.features.seal_allocation.route import router + +__all__ = [ + "Handler", + "SealAllocation", + "TotalSpendReader", + "bind", + "decide", + "router", + "tool", + "zero_total_spend", +] diff --git a/apps/api/src/cora/budget/features/seal_allocation/command.py b/apps/api/src/cora/budget/features/seal_allocation/command.py new file mode 100644 index 00000000000..f6cd043e6f3 --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/command.py @@ -0,0 +1,27 @@ +"""The `SealAllocation` command -- intent dataclass for this slice. + +Closes an Active envelope's books: `Active -> Sealed`, terminal. The +final-spend snapshot is NOT on the command: the handler computes it +at seal time by folding the inference ledger over the envelope's own +window (`activated_at` to the seal instant) via the injected +TotalSpendReader, so a caller can never assert a figure the ledger +does not support. The stage-C CampaignClosed subscriber and the REST +route both drive this one slice. + +`reason` is optional: a routine end-of-window seal needs no +justification, an early close usually carries context. The sealing +actor's identity is injected by the handler from the envelope's +`principal_id` (the decider's `sealed_by` kwarg); no actor field on +the command. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class SealAllocation: + """Seal an Active Allocation (`Active -> Sealed`, closing the books).""" + + allocation_id: UUID + reason: str | None = None diff --git a/apps/api/src/cora/budget/features/seal_allocation/decider.py b/apps/api/src/cora/budget/features/seal_allocation/decider.py new file mode 100644 index 00000000000..4d2f2aff525 --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/decider.py @@ -0,0 +1,68 @@ +"""Pure decider for the `SealAllocation` command. + +Single-source transition: `Active -> Sealed`. Strict-not-idempotent. + +`spent_usd` is the ledger fold the handler computed over the +envelope's window; `sealed_by` is the envelope's acting principal. +Both are injected (the non-determinism principle: the decider stays +pure, the handler owns the ledger read and the clock). + +## Validation + + - State must not be None -> `AllocationNotFoundError` + - Current status must be `Active` -> `AllocationCannotSealError` + - `reason` (when set) wrapped via `AllocationReason(...)`; 1-500 + chars after trim -> `InvalidAllocationReasonError`. None is + allowed (means no closing note). +""" + +from datetime import datetime + +from cora.budget.aggregates.allocation import ( + Allocation, + AllocationCannotSealError, + AllocationNotFoundError, + AllocationReason, + AllocationSealed, + AllocationStatus, +) +from cora.budget.features.seal_allocation.command import SealAllocation +from cora.shared.identity import ActorId + +_SEALABLE_STATUSES: tuple[AllocationStatus, ...] = (AllocationStatus.ACTIVE,) + + +def decide( + state: Allocation | None, + command: SealAllocation, + *, + now: datetime, + spent_usd: float, + sealed_by: ActorId, +) -> list[AllocationSealed]: + """Decide the events produced by sealing an Active Allocation. + + Invariants: + - State must not be None -> AllocationNotFoundError + - Current status must be Active -> AllocationCannotSealError + - Reason (when set) must be valid -> InvalidAllocationReasonError + (via AllocationReason VO) + """ + if state is None: + raise AllocationNotFoundError(command.allocation_id) + if state.status not in _SEALABLE_STATUSES: + raise AllocationCannotSealError(state.id, state.status) + + reason: AllocationReason | None = None + if command.reason is not None: + reason = AllocationReason(command.reason) + + return [ + AllocationSealed( + allocation_id=state.id, + spent_usd=spent_usd, + reason=reason.value if reason is not None else None, + sealed_by=sealed_by, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/budget/features/seal_allocation/handler.py b/apps/api/src/cora/budget/features/seal_allocation/handler.py new file mode 100644 index 00000000000..d6a096bbb39 --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/handler.py @@ -0,0 +1,192 @@ +"""Application handler for the `seal_allocation` slice. + +Longhand (not factory-built): between load and decide the handler +awaits the injected `TotalSpendReader` to fold the envelope's final +spend over its own window, `[state.activated_at, now)`, which the +single-stream update factory cannot express. The allocation's +lifecycle IS the window (no calendar arithmetic): `activated_at` +comes off the loaded aggregate, `now` off the Clock port, and the +snapshot lands in the `AllocationSealed` payload as the +closing-the-books figure. + +`total_spend_reader` is bound at wire time, NOT resolved from Kernel: +stage A has no instance-total spend query yet, so `wire.py` binds +`zero_total_spend` (every stage-A seal records 0.0) and stage C +replaces it with the SpendLookup-backed reader when +`find_total_spend` lands. Binding the seam now means the stage-C +subscriber and the route both flow through this one slice unchanged. + +The reader is only consulted when the loaded envelope has an open +window (`activated_at` set); on the guard paths (missing stream, +non-Active status) the decider raises before the snapshot matters, +so the handler skips the ledger read entirely rather than fold a +window that never opened. + +The handler threads `sealed_by=ActorId(principal_id)` into the +decider so the seal fact-act folds with its attribution half per +[[project_fold_symmetry_design]]. +""" + +from datetime import datetime +from typing import Protocol +from uuid import UUID + +from cora.budget.aggregates.allocation import ( + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features.seal_allocation.command import SealAllocation +from cora.budget.features.seal_allocation.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.shared.identity import ActorId + +_STREAM_TYPE = "Allocation" +_COMMAND_NAME = "SealAllocation" + +_log = get_logger(__name__) + + +class TotalSpendReader(Protocol): + """Folds the deployment's total recorded LLM spend over a window. + + The window is the envelope's own lifecycle: `[window_start, + window_end)` with `window_start = activated_at` and `window_end` + the seal instant. Stage C implements this over SpendLookup's + `find_total_spend`; stage A binds `zero_total_spend`. + """ + + async def __call__(self, *, window_start: datetime, window_end: datetime) -> float: ... + + +async def zero_total_spend(*, window_start: datetime, window_end: datetime) -> float: + """Stage-A placeholder reader: no instance-total spend query exists yet. + + Every stage-A seal records `spent_usd = 0.0`. Honest by + construction: the figure is the reader's answer, and until stage C + wires the SpendLookup-backed reader the ledger-backed answer is + not available, not silently approximated. + """ + _ = (window_start, window_end) + return 0.0 + + +class Handler(Protocol): + """Callable interface every seal_allocation handler implements.""" + + async def __call__( + self, + command: SealAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel, *, total_spend_reader: TotalSpendReader) -> Handler: + """Build a seal_allocation handler closed over the shared deps. + + `total_spend_reader` is an explicit keyword (no default) so every + wiring site states which ledger fold the seal snapshot records. + """ + + async def handler( + command: SealAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "seal_allocation.start", + command_name=_COMMAND_NAME, + allocation_id=str(command.allocation_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "seal_allocation.denied", + command_name=_COMMAND_NAME, + allocation_id=str(command.allocation_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.allocation_id, + ) + history = [from_stored(s) for s in stored] + state = fold(history) + + spent_usd = 0.0 + if state is not None and state.activated_at is not None: + spent_usd = await total_spend_reader( + window_start=state.activated_at, + window_end=now, + ) + + domain_events = decide( + state=state, + command=command, + now=now, + spent_usd=spent_usd, + sealed_by=ActorId(principal_id), + ) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.allocation_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "seal_allocation.success", + command_name=_COMMAND_NAME, + allocation_id=str(command.allocation_id), + spent_usd=spent_usd, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/budget/features/seal_allocation/route.py b/apps/api/src/cora/budget/features/seal_allocation/route.py new file mode 100644 index 00000000000..2594087e5cd --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/route.py @@ -0,0 +1,94 @@ +"""HTTP route for the `seal_allocation` slice. + +Action endpoint at `POST /allocations/{allocation_id}/seal`. Body +carries only the optional closing `reason`; the final-spend snapshot +is computed server-side by the handler's TotalSpendReader (a caller +can never assert a figure the ledger does not support, so spent_usd +is deliberately NOT on the wire). 204 No Content on success. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.budget.features.seal_allocation.command import SealAllocation +from cora.budget.features.seal_allocation.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.shared.text_bounds import REASON_MAX_LENGTH + + +class SealAllocationRequest(BaseModel): + """Body for `POST /allocations/{allocation_id}/seal`.""" + + reason: str | None = Field( + default=None, + min_length=1, + max_length=REASON_MAX_LENGTH, + description=( + "Optional closing note (an early close usually carries context; " + "a routine end-of-window seal needs none). Pass null to omit." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.budget.seal_allocation + return handler + + +router = APIRouter(tags=["budget"]) + + +@router.post( + "/allocations/{allocation_id}/seal", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated (whitespace-only reason).", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No Allocation exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Allocation is not in Active status (seal_allocation closes " + "an open spend window; void a dormant grant instead)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation (reason length out of " + "bounds) or path parameter is not a UUID." + ), + }, + }, + summary="Seal an Active Allocation (Active -> Sealed, closing the books)", +) +async def post_allocations_seal( + allocation_id: Annotated[UUID, Path(description="Target Allocation's id.")], + body: SealAllocationRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + SealAllocation(allocation_id=allocation_id, reason=body.reason), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/budget/features/seal_allocation/tool.py b/apps/api/src/cora/budget/features/seal_allocation/tool.py new file mode 100644 index 00000000000..c18f496efbd --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/tool.py @@ -0,0 +1,57 @@ +"""MCP tool for the `seal_allocation` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.budget.features.seal_allocation.command import SealAllocation +from cora.budget.features.seal_allocation.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id +from cora.shared.text_bounds import REASON_MAX_LENGTH + + +class SealAllocationOutput(BaseModel): + """Structured output of the `seal_allocation` MCP tool.""" + + allocation_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `seal_allocation` tool on the given MCP server.""" + + @mcp.tool( + name="seal_allocation", + description=( + "Seal an Active Allocation (Active -> Sealed, terminal): close " + "the envelope's books with a final-spend snapshot the server " + "computes from the inference ledger over the envelope's own " + "window. Optional reason for an early close. Source set is " + "{Active} only; void a dormant grant instead." + ), + ) + async def seal_allocation_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + allocation_id: Annotated[UUID, Field(description="Identifier of the Allocation to seal.")], + reason: Annotated[ + str | None, + Field( + default=None, + min_length=1, + max_length=REASON_MAX_LENGTH, + description="Optional closing note. Null to omit.", + ), + ] = None, + ) -> SealAllocationOutput: + handler = get_handler() + await handler( + SealAllocation(allocation_id=allocation_id, reason=reason), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return SealAllocationOutput(allocation_id=allocation_id) diff --git a/apps/api/src/cora/budget/features/void_allocation/__init__.py b/apps/api/src/cora/budget/features/void_allocation/__init__.py new file mode 100644 index 00000000000..cfd18f60f5b --- /dev/null +++ b/apps/api/src/cora/budget/features/void_allocation/__init__.py @@ -0,0 +1,28 @@ +"""Vertical slice for the `VoidAllocation` command. + +Module-as-namespace surface, symmetric with the other transition +command slices: + + from cora.budget.features import void_allocation + + cmd = void_allocation.VoidAllocation( + allocation_id=UUID("..."), reason="Granted against the wrong cycle", + ) + handler = void_allocation.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.budget.features.void_allocation import tool +from cora.budget.features.void_allocation.command import VoidAllocation +from cora.budget.features.void_allocation.decider import decide +from cora.budget.features.void_allocation.handler import Handler, bind +from cora.budget.features.void_allocation.route import router + +__all__ = [ + "Handler", + "VoidAllocation", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/budget/features/void_allocation/command.py b/apps/api/src/cora/budget/features/void_allocation/command.py new file mode 100644 index 00000000000..2b59aa028e2 --- /dev/null +++ b/apps/api/src/cora/budget/features/void_allocation/command.py @@ -0,0 +1,22 @@ +"""The `VoidAllocation` command -- intent dataclass for this slice. + +Withdraws a mistaken grant: `Granted | Active -> Voided`, terminal. +Distinct from the seal: the void records that the award never stood +(no spend snapshot), while the seal closes an open window's books. + +`reason` is REQUIRED: withdrawing an award is a governance act the +audit log must always carry context for. The voiding actor's +identity lives on the event envelope (`StoredEvent.principal_id`); +no actor field on the command/event. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class VoidAllocation: + """Void a Granted or Active Allocation (`-> Voided`, terminal).""" + + allocation_id: UUID + reason: str diff --git a/apps/api/src/cora/budget/features/void_allocation/decider.py b/apps/api/src/cora/budget/features/void_allocation/decider.py new file mode 100644 index 00000000000..43cfb7e7486 --- /dev/null +++ b/apps/api/src/cora/budget/features/void_allocation/decider.py @@ -0,0 +1,60 @@ +"""Pure decider for the `VoidAllocation` command. + +Transition: `Granted | Active -> Voided`. Strict-not-idempotent. + +## Validation + + - State must not be None -> `AllocationNotFoundError` + - Current status must be Granted or Active + -> `AllocationCannotVoidError` + - `reason` wrapped via `AllocationReason(...)`; REQUIRED, 1-500 + chars after trim -> `InvalidAllocationReasonError` +""" + +from datetime import datetime + +from cora.budget.aggregates.allocation import ( + Allocation, + AllocationCannotVoidError, + AllocationNotFoundError, + AllocationReason, + AllocationStatus, + AllocationVoided, +) +from cora.budget.features.void_allocation.command import VoidAllocation + +_VOIDABLE_STATUSES: tuple[AllocationStatus, ...] = ( + AllocationStatus.GRANTED, + AllocationStatus.ACTIVE, +) + + +def decide( + state: Allocation | None, + command: VoidAllocation, + *, + now: datetime, +) -> list[AllocationVoided]: + """Decide the events produced by voiding an Allocation. + + Invariants: + - State must not be None -> AllocationNotFoundError + - Current status must be Granted or Active + -> AllocationCannotVoidError + - Reason must be valid -> InvalidAllocationReasonError + (via AllocationReason VO) + """ + if state is None: + raise AllocationNotFoundError(command.allocation_id) + if state.status not in _VOIDABLE_STATUSES: + raise AllocationCannotVoidError(state.id, state.status) + + reason = AllocationReason(command.reason) + + return [ + AllocationVoided( + allocation_id=state.id, + reason=reason.value, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/budget/features/void_allocation/handler.py b/apps/api/src/cora/budget/features/void_allocation/handler.py new file mode 100644 index 00000000000..1a915934661 --- /dev/null +++ b/apps/api/src/cora/budget/features/void_allocation/handler.py @@ -0,0 +1,45 @@ +"""Application handler for the `void_allocation` slice. + +Built on the hoisted `make_allocation_update_handler` factory along +with `amend_allocation_ceiling`. The voiding actor's identity lives +on the event envelope only (the fold records no voided_at timestamp, +so there is no attribution half to stamp), keeping the thin +fold-NEITHER factory. Source set `{Granted, Active}` is enforced by +the decider's guard; the factory is source-set-agnostic. +""" + +from typing import Protocol +from uuid import UUID + +from cora.budget._allocation_update_handler import make_allocation_update_handler +from cora.budget.features.void_allocation.command import VoidAllocation +from cora.budget.features.void_allocation.decider import decide +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.routing import NIL_SENTINEL_ID + + +class Handler(Protocol): + """Callable interface every void_allocation handler implements.""" + + async def __call__( + self, + command: VoidAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a void_allocation handler closed over the shared deps.""" + return make_allocation_update_handler( + deps, + command_name="VoidAllocation", + log_prefix="void_allocation", + decide_fn=decide, + # Reason length only (not the text): operators searching the log + # can spot voided envelopes without the log leaking free text. + extra_log_fields=lambda command: {"reason_length": len(command.reason)}, + ) diff --git a/apps/api/src/cora/budget/features/void_allocation/route.py b/apps/api/src/cora/budget/features/void_allocation/route.py new file mode 100644 index 00000000000..b7e33ae8062 --- /dev/null +++ b/apps/api/src/cora/budget/features/void_allocation/route.py @@ -0,0 +1,91 @@ +"""HTTP route for the `void_allocation` slice. + +Action endpoint at `POST /allocations/{allocation_id}/void`. Body +carries the REQUIRED withdrawal `reason`. 204 No Content on success. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.budget.features.void_allocation.command import VoidAllocation +from cora.budget.features.void_allocation.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.shared.text_bounds import REASON_MAX_LENGTH + + +class VoidAllocationRequest(BaseModel): + """Body for `POST /allocations/{allocation_id}/void`.""" + + reason: str = Field( + ..., + min_length=1, + max_length=REASON_MAX_LENGTH, + description=( + "Why the grant is withdrawn. Required: voiding an award is a " + "governance act the audit log must always carry context for." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.budget.void_allocation + return handler + + +router = APIRouter(tags=["budget"]) + + +@router.post( + "/allocations/{allocation_id}/void", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated (whitespace-only reason).", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No Allocation exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Allocation is not in Granted or Active status (a sealed or " + "voided envelope cannot be re-terminated)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation (missing or " + "over-length reason) or path parameter is not a UUID." + ), + }, + }, + summary="Void a Granted or Active Allocation (-> Voided)", +) +async def post_allocations_void( + allocation_id: Annotated[UUID, Path(description="Target Allocation's id.")], + body: VoidAllocationRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + VoidAllocation(allocation_id=allocation_id, reason=body.reason), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/budget/features/void_allocation/tool.py b/apps/api/src/cora/budget/features/void_allocation/tool.py new file mode 100644 index 00000000000..6e84bd96f8f --- /dev/null +++ b/apps/api/src/cora/budget/features/void_allocation/tool.py @@ -0,0 +1,56 @@ +"""MCP tool for the `void_allocation` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.budget.features.void_allocation.command import VoidAllocation +from cora.budget.features.void_allocation.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id +from cora.shared.text_bounds import REASON_MAX_LENGTH + + +class VoidAllocationOutput(BaseModel): + """Structured output of the `void_allocation` MCP tool.""" + + allocation_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `void_allocation` tool on the given MCP server.""" + + @mcp.tool( + name="void_allocation", + description=( + "Void a Granted or Active Allocation (-> Voided, terminal): the " + "operator withdraws a mistaken grant. Required reason (a " + "governance act the audit log must carry context for). Distinct " + "from seal_allocation, which closes an open window's books with " + "a spend snapshot." + ), + ) + async def void_allocation_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + allocation_id: Annotated[UUID, Field(description="Identifier of the Allocation to void.")], + reason: Annotated[ + str, + Field( + min_length=1, + max_length=REASON_MAX_LENGTH, + description="Why the grant is withdrawn. Required.", + ), + ], + ) -> VoidAllocationOutput: + handler = get_handler() + await handler( + VoidAllocation(allocation_id=allocation_id, reason=reason), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return VoidAllocationOutput(allocation_id=allocation_id) diff --git a/apps/api/src/cora/budget/routes.py b/apps/api/src/cora/budget/routes.py new file mode 100644 index 00000000000..55e26199b17 --- /dev/null +++ b/apps/api/src/cora/budget/routes.py @@ -0,0 +1,134 @@ +"""HTTP setup for the budget BC. + +`register_budget_routes(app)` includes every slice's router and +registers exception handlers that translate the BC's domain / +application errors to HTTP status codes. Called once at app +construction. + +JSONResponse is used (not HTTPException) per FastAPI guidance to +avoid nested-exception pitfalls. + +`IdempotencyConflictError`, `IdempotencyClaimLostError`, +`CachedHandlerError`, and `ConcurrencyError` are infra-layer errors +registered by Access (the first BC that boots); budget does not +re-register them. + +## Loop-collapse pattern + +Budget owns one aggregate. Four error families share response shapes +and get collapsed via the Campaign / Trust / Equipment / Supply loop +pattern: + + - 400 (validation): InvalidAllocationCeiling, + InvalidAllocationHolderNote, InvalidAllocationReason + - 404 (load miss): AllocationNotFound + - 409 (defensive guard for AlreadyExists): AllocationAlreadyExists + - 409 (transition guards): AllocationCannotActivate, + AllocationCannotAmend, AllocationCannotSeal, AllocationCannotVoid +""" + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse + +from cora.budget.aggregates.allocation import ( + AllocationAlreadyExistsError, + AllocationCannotActivateError, + AllocationCannotAmendError, + AllocationCannotSealError, + AllocationCannotVoidError, + AllocationNotFoundError, + InvalidAllocationCeilingError, + InvalidAllocationHolderNoteError, + InvalidAllocationReasonError, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features import ( + activate_allocation, + amend_allocation_ceiling, + grant_allocation, + seal_allocation, + void_allocation, +) + + +async def _handle_validation_error(request: Request, exc: Exception) -> JSONResponse: + """Shared 400 handler for every domain validation error.""" + _ = request + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": str(exc)}, + ) + + +async def _handle_unauthorized(request: Request, exc: Exception) -> JSONResponse: + _ = request + reason = exc.reason if isinstance(exc, UnauthorizedError) else str(exc) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": reason}, + ) + + +async def _handle_not_found(request: Request, exc: Exception) -> JSONResponse: + """Shared 404 handler for the aggregate's NotFoundError.""" + _ = request + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": str(exc)}, + ) + + +async def _handle_already_exists(request: Request, exc: Exception) -> JSONResponse: + """Defensive 409 handler for the aggregate's AlreadyExistsError. + + The decider raises it when the target stream already has events. + In production with server-minted UUIDv7 ids this is essentially + impossible (caller-supplied ids make it reachable), but the + unmapped raise would surface as 500 instead of a clean 409. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={"detail": str(exc)}, + ) + + +async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONResponse: + """Shared 409 handler for state-transition guards. + + Covers the `AllocationCannotError` family: Activate, Amend, + Seal, Void. Same pattern as Campaign / Supply / Safety + `_handle_cannot_transition`. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={"detail": str(exc)}, + ) + + +def register_budget_routes(app: FastAPI) -> None: + """Attach budget slice routers and exception handlers to the FastAPI app.""" + app.include_router(grant_allocation.router) + app.include_router(activate_allocation.router) + app.include_router(amend_allocation_ceiling.router) + app.include_router(seal_allocation.router) + app.include_router(void_allocation.router) + for validation_cls in ( + InvalidAllocationCeilingError, + InvalidAllocationHolderNoteError, + InvalidAllocationReasonError, + ): + app.add_exception_handler(validation_cls, _handle_validation_error) + for not_found_cls in (AllocationNotFoundError,): + app.add_exception_handler(not_found_cls, _handle_not_found) + for already_exists_cls in (AllocationAlreadyExistsError,): + app.add_exception_handler(already_exists_cls, _handle_already_exists) + for cannot_transition_cls in ( + AllocationCannotActivateError, + AllocationCannotAmendError, + AllocationCannotSealError, + AllocationCannotVoidError, + ): + app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) + app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/budget/tools.py b/apps/api/src/cora/budget/tools.py new file mode 100644 index 00000000000..5cdc2ad84e4 --- /dev/null +++ b/apps/api/src/cora/budget/tools.py @@ -0,0 +1,47 @@ +"""MCP tool registration for the budget BC. + +`register_budget_tools(mcp, *, get_handlers)` registers each slice's +MCP tool on the shared FastMCP server. `get_handlers` is a callable +returning the `BudgetHandlers` bundle wired during the FastAPI +lifespan; it's invoked per tool call so the latest wiring is +always used. +""" + +from collections.abc import Callable + +from mcp.server.fastmcp import FastMCP + +from cora.budget.features.activate_allocation import tool as activate_allocation_tool +from cora.budget.features.amend_allocation_ceiling import tool as amend_allocation_ceiling_tool +from cora.budget.features.grant_allocation import tool as grant_allocation_tool +from cora.budget.features.seal_allocation import tool as seal_allocation_tool +from cora.budget.features.void_allocation import tool as void_allocation_tool +from cora.budget.wire import BudgetHandlers + + +def register_budget_tools( + mcp: FastMCP, + *, + get_handlers: Callable[[], BudgetHandlers], +) -> None: + """Register every budget slice's MCP tool on the FastMCP server.""" + grant_allocation_tool.register( + mcp, + get_handler=lambda: get_handlers().grant_allocation, + ) + activate_allocation_tool.register( + mcp, + get_handler=lambda: get_handlers().activate_allocation, + ) + amend_allocation_ceiling_tool.register( + mcp, + get_handler=lambda: get_handlers().amend_allocation_ceiling, + ) + seal_allocation_tool.register( + mcp, + get_handler=lambda: get_handlers().seal_allocation, + ) + void_allocation_tool.register( + mcp, + get_handler=lambda: get_handlers().void_allocation, + ) diff --git a/apps/api/src/cora/budget/wire.py b/apps/api/src/cora/budget/wire.py new file mode 100644 index 00000000000..cbd5750ecc7 --- /dev/null +++ b/apps/api/src/cora/budget/wire.py @@ -0,0 +1,101 @@ +"""Compose the budget BC's handlers from `Kernel`. + +`wire_budget(deps)` is invoked once from the FastAPI lifespan and +the returned `BudgetHandlers` bundle is stored on `app.state.budget`. +Routes and MCP tools pull their handler out of that bundle. New +slices add a new field on `BudgetHandlers` and a single line in this +factory. + +Cross-cutting decorators applied here mirror Campaign / Agent / +Supply / Safety / Caution: + + 1. `bind(deps)` -- bare handler. + 2. `with_idempotency` (create-style commands only) -- Idempotency- + Key support. Wrapped before tracing so cache-hits and cache- + misses both attribute to the tracing span. + 3. `with_tracing` -- OTel span around every handler call. + +## Wired handlers + + - `grant_allocation` (create-style; idempotency-wrapped) + - `activate_allocation` (transition; no idempotency wrap) + - `amend_allocation_ceiling` (amendment; no idempotency wrap) + - `seal_allocation` (transition; no idempotency wrap) + - `void_allocation` (transition; no idempotency wrap) + +## Stage-A TotalSpendReader + +`seal_allocation.bind` takes the reader as an explicit keyword. +Stage A binds `zero_total_spend` (no instance-total spend query +exists yet, so every stage-A seal honestly records 0.0); stage C +replaces this one argument with the SpendLookup-backed fold when +`find_total_spend` lands, leaving the slice untouched. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.budget.features import ( + activate_allocation, + amend_allocation_ceiling, + grant_allocation, + seal_allocation, + void_allocation, +) +from cora.budget.features.seal_allocation import zero_total_spend +from cora.infrastructure.idempotency import with_idempotency +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.observability import with_tracing + +_BC = "budget" + + +@dataclass(frozen=True) +class BudgetHandlers: + """The budget BC's handler bundle, each closed over Kernel.""" + + grant_allocation: grant_allocation.IdempotentHandler + activate_allocation: activate_allocation.Handler + amend_allocation_ceiling: amend_allocation_ceiling.Handler + seal_allocation: seal_allocation.Handler + void_allocation: void_allocation.Handler + + +def wire_budget(deps: Kernel) -> BudgetHandlers: + """Build the budget BC handlers from shared dependencies.""" + return BudgetHandlers( + grant_allocation=with_tracing( + with_idempotency( + grant_allocation.bind(deps), + deps.idempotency_store, + command_name="GrantAllocation", + # Handler returns UUID; cache as str (jsonb-friendly) and + # rebuild via UUID() on retrieval. + serialize_result=str, + deserialize_result=UUID, + lock_stale_seconds=deps.settings.idempotency_lock_stale_seconds, + ), + command_name="GrantAllocation", + bc=_BC, + ), + activate_allocation=with_tracing( + activate_allocation.bind(deps), + command_name="ActivateAllocation", + bc=_BC, + ), + amend_allocation_ceiling=with_tracing( + amend_allocation_ceiling.bind(deps), + command_name="AmendAllocationCeiling", + bc=_BC, + ), + seal_allocation=with_tracing( + seal_allocation.bind(deps, total_spend_reader=zero_total_spend), + command_name="SealAllocation", + bc=_BC, + ), + void_allocation=with_tracing( + void_allocation.bind(deps), + command_name="VoidAllocation", + bc=_BC, + ), + ) diff --git a/apps/api/tach.toml b/apps/api/tach.toml index bd4ce006f81..efc9136394f 100644 --- a/apps/api/tach.toml +++ b/apps/api/tach.toml @@ -57,6 +57,10 @@ depends_on = ["cora.infrastructure", "cora.shared"] path = "cora.agent.aggregates" depends_on = ["cora.infrastructure", "cora.shared"] +[[modules]] +path = "cora.budget.aggregates" +depends_on = ["cora.infrastructure", "cora.shared"] + [[modules]] path = "cora.campaign.aggregates" depends_on = ["cora.infrastructure", "cora.shared"] @@ -394,6 +398,18 @@ depends_on = [ "cora.trust.aggregates", ] +# Budget BC owns the Allocation aggregate (the beamline's spending +# envelope; 4-state FSM Granted -> Active -> Sealed | Voided). Day-1 +# lock: `campaign_id` is a bare UUID reference with NO cross-BC kernel +# load on the write path (eventual-consistency stance per the Caution / +# Calibration precedent), and the seal's TotalSpendReader is an +# injected callable (stage A binds a zero-reader; stage C binds the +# SpendLookup-backed fold), so the BC depends only on shared + +# infrastructure. +[[modules]] +path = "cora.budget" +depends_on = ["cora.infrastructure", "cora.shared", "cora.budget.aggregates"] + # API entrypoint composes every BC. [[modules]] path = "cora.api" @@ -404,6 +420,7 @@ depends_on = [ # RunSupervisor runtime gates on Actor.active (operator-revocation) via load_actor. "cora.access.aggregates", "cora.agent", + "cora.budget", "cora.calibration", "cora.campaign", "cora.caution", diff --git a/apps/api/tests/architecture/conftest.py b/apps/api/tests/architecture/conftest.py index a03f6f58d6e..30b316290b5 100644 --- a/apps/api/tests/architecture/conftest.py +++ b/apps/api/tests/architecture/conftest.py @@ -45,6 +45,7 @@ BCS: tuple[str, ...] = ( "access", "agent", + "budget", "calibration", "campaign", "caution", diff --git a/apps/api/tests/architecture/test_event_class_defined_vs_registered.py b/apps/api/tests/architecture/test_event_class_defined_vs_registered.py index f9ffff1fc9f..837e9c01a98 100644 --- a/apps/api/tests/architecture/test_event_class_defined_vs_registered.py +++ b/apps/api/tests/architecture/test_event_class_defined_vs_registered.py @@ -123,6 +123,15 @@ "AttestationRegistered would misframe a fact as an entity. Mirrors " "the Calibration-revision / run / seal fact-shaped precedent." ), + ("budget", "allocation"): ( + "AllocationGranted uses the exact verb of the paper and of HPC " + "accounting for an award, and it names the FSM's initial state " + "(AllocationStatus.GRANTED), the RatificationRequested shape: the " + "genesis event IS the transition into the first state. An " + "AllocationRegistered genesis would emit an event matching no " + "state it produces and erase the award semantic the whole " + "allocation arc is built on." + ), } diff --git a/apps/api/tests/architecture/test_slice_test_coverage.py b/apps/api/tests/architecture/test_slice_test_coverage.py index 1d8ad54500d..a12273ac91f 100644 --- a/apps/api/tests/architecture/test_slice_test_coverage.py +++ b/apps/api/tests/architecture/test_slice_test_coverage.py @@ -166,6 +166,19 @@ # status-code mapping stay uncovered until the contract suite # lands. "cora.agent.features.list_at_risk_results", + # Allocation slices (budget BC, allocation arc stage A): REST + # contract tests deferred so the family's REST + MCP suite can + # be authored together (the LanguageModel / Frame / Facility + # precedent). Decider + PBT + handler unit tests + # (tests/unit/budget/) pin behavior; the OpenAPI snapshot pins + # route paths and schemas only, so handler binding, MCP tool + # registration, and status-code mapping stay uncovered until + # the contract suite lands. Remove when it does. + "cora.budget.features.activate_allocation", + "cora.budget.features.amend_allocation_ceiling", + "cora.budget.features.grant_allocation", + "cora.budget.features.seal_allocation", + "cora.budget.features.void_allocation", } ) @@ -230,6 +243,15 @@ # list_at_risk_results: MCP contract test deferred alongside the # REST contract test (same rationale as the catalog slices above). "cora.agent.features.list_at_risk_results", + # Allocation slices (budget BC): MCP contract tests deferred + # alongside the REST contract tests (same rationale as the + # endpoint allowlist entries above). Remove when the family's + # contract suite lands. + "cora.budget.features.activate_allocation", + "cora.budget.features.amend_allocation_ceiling", + "cora.budget.features.grant_allocation", + "cora.budget.features.seal_allocation", + "cora.budget.features.void_allocation", } ) diff --git a/apps/api/tests/architecture/test_slice_verb_names_subject.py b/apps/api/tests/architecture/test_slice_verb_names_subject.py index 4d3889bbf2f..a9a1d1bd26b 100644 --- a/apps/api/tests/architecture/test_slice_verb_names_subject.py +++ b/apps/api/tests/architecture/test_slice_verb_names_subject.py @@ -41,6 +41,7 @@ "acquisition", "actor", "agent", + "allocation", "assembly", "asset", "attestation", diff --git a/apps/api/tests/unit/budget/__init__.py b/apps/api/tests/unit/budget/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/tests/unit/budget/_helpers.py b/apps/api/tests/unit/budget/_helpers.py new file mode 100644 index 00000000000..949a9b350e3 --- /dev/null +++ b/apps/api/tests/unit/budget/_helpers.py @@ -0,0 +1,106 @@ +"""Shared builders for budget unit tests. + +One `make_allocation` state factory plus one event-stream seeder (the +campaign `_helpers.py` precedent) so the five slice test trios do not +each re-declare the aggregate's full constructor as it grows fields. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from cora.budget.aggregates.allocation import ( + Allocation, + AllocationActivated, + AllocationEvent, + AllocationGranted, + AllocationHolderNote, + AllocationStatus, + event_type_name, + to_payload, +) +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.shared.identity import ActorId + +GRANTED_AT = datetime(2026, 7, 12, 9, 0, 0, tzinfo=UTC) +ACTIVATED_AT = datetime(2026, 7, 12, 10, 0, 0, tzinfo=UTC) + +GRANTED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000011")) +ACTIVATED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000022")) + + +def make_allocation( + status: AllocationStatus, + *, + allocation_id: UUID | None = None, + ceiling_usd: float = 25000.0, + campaign_id: UUID | None = None, +) -> Allocation: + """Build an Allocation in the given status with plausible window fields. + + Statuses past Granted carry `activated_at` / `activated_by` so + deciders and handlers that read the window (seal) see the shape a + real fold produces. + """ + activated = status is not AllocationStatus.GRANTED + return Allocation( + id=allocation_id or uuid4(), + ceiling_usd=ceiling_usd, + holder_note=AllocationHolderNote("FY26 imaging award"), + campaign_id=campaign_id, + granted_at=GRANTED_AT, + granted_by=GRANTED_BY, + status=status, + activated_at=ACTIVATED_AT if activated else None, + activated_by=ACTIVATED_BY if activated else None, + ) + + +def granted_event(allocation_id: UUID) -> AllocationGranted: + return AllocationGranted( + allocation_id=allocation_id, + ceiling_usd=25000.0, + campaign_id=None, + holder_note="FY26 imaging award", + granted_by=GRANTED_BY, + occurred_at=GRANTED_AT, + ) + + +def activated_event(allocation_id: UUID) -> AllocationActivated: + return AllocationActivated( + allocation_id=allocation_id, + activated_by=ACTIVATED_BY, + occurred_at=ACTIVATED_AT, + ) + + +async def seed_allocation_events( + store: InMemoryEventStore, + allocation_id: UUID, + *events: AllocationEvent, +) -> None: + """Append the given domain events to the Allocation stream in order. + + Wraps each through the real codecs (`event_type_name` / + `to_payload`) so handler tests replay exactly what production + appends would have stored. + """ + for version, event in enumerate(events): + await store.append( + stream_type="Allocation", + stream_id=allocation_id, + expected_version=version, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=uuid4(), + command_name="Seed", + correlation_id=uuid4(), + causation_id=None, + principal_id=GRANTED_BY, + ) + ], + ) diff --git a/apps/api/tests/unit/budget/test_activate_allocation_decider.py b/apps/api/tests/unit/budget/test_activate_allocation_decider.py new file mode 100644 index 00000000000..794e91b3af0 --- /dev/null +++ b/apps/api/tests/unit/budget/test_activate_allocation_decider.py @@ -0,0 +1,69 @@ +"""Pure-decider tests for the `activate_allocation` slice.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationActivated, + AllocationCannotActivateError, + AllocationNotFoundError, + AllocationStatus, +) +from cora.budget.features.activate_allocation.command import ActivateAllocation +from cora.budget.features.activate_allocation.decider import decide +from cora.shared.identity import ActorId +from tests.unit.budget._helpers import make_allocation + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_ACTIVATED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000099")) + + +@pytest.mark.unit +def test_activates_a_granted_allocation() -> None: + envelope = make_allocation(AllocationStatus.GRANTED) + events = decide( + state=envelope, + command=ActivateAllocation(allocation_id=envelope.id), + now=_NOW, + activated_by=_ACTIVATED_BY, + ) + assert events == [ + AllocationActivated( + allocation_id=envelope.id, + activated_by=_ACTIVATED_BY, + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_not_found_when_state_is_none() -> None: + with pytest.raises(AllocationNotFoundError): + decide( + state=None, + command=ActivateAllocation(allocation_id=uuid4()), + now=_NOW, + activated_by=_ACTIVATED_BY, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "status", + [ + AllocationStatus.ACTIVE, + AllocationStatus.SEALED, + AllocationStatus.VOIDED, + ], +) +def test_cannot_activate_from_non_granted(status: AllocationStatus) -> None: + envelope = make_allocation(status) + with pytest.raises(AllocationCannotActivateError): + decide( + state=envelope, + command=ActivateAllocation(allocation_id=envelope.id), + now=_NOW, + activated_by=_ACTIVATED_BY, + ) diff --git a/apps/api/tests/unit/budget/test_activate_allocation_decider_properties.py b/apps/api/tests/unit/budget/test_activate_allocation_decider_properties.py new file mode 100644 index 00000000000..14123bf63fa --- /dev/null +++ b/apps/api/tests/unit/budget/test_activate_allocation_decider_properties.py @@ -0,0 +1,148 @@ +"""Property-based tests for `activate_allocation.decide` (budget BC). + +Complements the example-based `test_activate_allocation_decider.py` +with universal claims across generated inputs. The decider is a pure +single-source FSM transition + + (state, command, *, now, activated_by) -> list[AllocationActivated] + +Load-bearing properties: + + - state=None always raises `AllocationNotFoundError` carrying + command.allocation_id. + - The source-state partition is total over `AllocationStatus`: + only `Granted` emits exactly one `AllocationActivated` + (allocation_id=state.id, activated_by, occurred_at=now); every + other status raises `AllocationCannotActivateError` carrying the + current status, so a future status value cannot silently fall + through. + - The emitted event's allocation_id is `state.id`, never + `command.allocation_id`. + - Pure: same (state, command, now, activated_by) returns equal events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.budget.aggregates.allocation import ( + AllocationActivated, + AllocationCannotActivateError, + AllocationNotFoundError, + AllocationStatus, +) +from cora.budget.features.activate_allocation.command import ActivateAllocation +from cora.budget.features.activate_allocation.decider import decide +from cora.shared.identity import ActorId +from tests._strategies import aware_datetimes +from tests.unit.budget._helpers import make_allocation + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +_ACTIVATABLE_SOURCES = (AllocationStatus.GRANTED,) +_DISALLOWED_SOURCES = tuple(s for s in AllocationStatus if s not in frozenset(_ACTIVATABLE_SOURCES)) + + +@pytest.mark.unit +@given(allocation_id=st.uuids(), activated_by=st.uuids(), now=aware_datetimes()) +def test_activate_with_none_state_always_raises_not_found( + allocation_id: UUID, + activated_by: UUID, + now: datetime, +) -> None: + """Empty stream always raises not-found carrying command.allocation_id.""" + with pytest.raises(AllocationNotFoundError) as exc: + decide( + state=None, + command=ActivateAllocation(allocation_id=allocation_id), + now=now, + activated_by=ActorId(activated_by), + ) + assert exc.value.allocation_id == allocation_id + + +@pytest.mark.unit +@given(allocation_id=st.uuids(), activated_by=st.uuids(), now=aware_datetimes()) +def test_activate_from_granted_emits_single_event( + allocation_id: UUID, + activated_by: UUID, + now: datetime, +) -> None: + """Granted is the only activatable source; emits one AllocationActivated.""" + events = decide( + state=make_allocation(AllocationStatus.GRANTED, allocation_id=allocation_id), + command=ActivateAllocation(allocation_id=allocation_id), + now=now, + activated_by=ActorId(activated_by), + ) + assert events == [ + AllocationActivated( + allocation_id=allocation_id, + activated_by=ActorId(activated_by), + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + allocation_id=st.uuids(), + source=st.sampled_from(_DISALLOWED_SOURCES), + activated_by=st.uuids(), + now=aware_datetimes(), +) +def test_activate_from_disallowed_source_always_raises_cannot_activate( + allocation_id: UUID, + source: AllocationStatus, + activated_by: UUID, + now: datetime, +) -> None: + """Any source other than Granted raises, carrying the current status.""" + with pytest.raises(AllocationCannotActivateError) as exc: + decide( + state=make_allocation(source, allocation_id=allocation_id), + command=ActivateAllocation(allocation_id=allocation_id), + now=now, + activated_by=ActorId(activated_by), + ) + assert exc.value.current_status is source + + +@pytest.mark.unit +@given(state_id=st.uuids(), command_id=st.uuids(), activated_by=st.uuids(), now=aware_datetimes()) +def test_activate_uses_state_id_not_command_allocation_id( + state_id: UUID, + command_id: UUID, + activated_by: UUID, + now: datetime, +) -> None: + """The emitted event's allocation_id is state.id, not command's.""" + assume(state_id != command_id) + events = decide( + state=make_allocation(AllocationStatus.GRANTED, allocation_id=state_id), + command=ActivateAllocation(allocation_id=command_id), + now=now, + activated_by=ActorId(activated_by), + ) + assert events[0].allocation_id == state_id + + +@pytest.mark.unit +@given(allocation_id=st.uuids(), activated_by=st.uuids(), now=aware_datetimes()) +def test_activate_is_pure_same_input_same_output( + allocation_id: UUID, + activated_by: UUID, + now: datetime, +) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + state = make_allocation(AllocationStatus.GRANTED, allocation_id=allocation_id) + command = ActivateAllocation(allocation_id=allocation_id) + first = decide(state=state, command=command, now=now, activated_by=ActorId(activated_by)) + second = decide(state=state, command=command, now=now, activated_by=ActorId(activated_by)) + assert first == second diff --git a/apps/api/tests/unit/budget/test_activate_allocation_handler.py b/apps/api/tests/unit/budget/test_activate_allocation_handler.py new file mode 100644 index 00000000000..0e4b5cf7600 --- /dev/null +++ b/apps/api/tests/unit/budget/test_activate_allocation_handler.py @@ -0,0 +1,143 @@ +"""Application-handler tests for the `activate_allocation` slice. + +Actor-stamping factory path: the handler threads the envelope's +`principal_id` into the decider as `activated_by`, so the payload +carries the fold-symmetric attribution half. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationCannotActivateError, + AllocationNotFoundError, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features import activate_allocation +from cora.budget.features.activate_allocation import ActivateAllocation +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared +from tests.unit.budget._helpers import ( + activated_event, + granted_event, + seed_allocation_events, +) + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_ALLOCATION_ID = UUID("01900000-0000-7000-8000-00000000d001") +_ACTIVATE_EVENT_ID = UUID("01900000-0000-7000-8000-00000000d002") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + return _build_deps_shared( + ids=[_ACTIVATE_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +@pytest.mark.unit +async def test_handler_activates_a_granted_allocation() -> None: + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store) + handler = activate_allocation.bind(deps) + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 2 + assert events[-1].event_type == "AllocationActivated" + assert events[-1].payload["allocation_id"] == str(_ALLOCATION_ID) + + +@pytest.mark.unit +async def test_handler_stamps_principal_as_activated_by() -> None: + """The actor-stamping factory passes the envelope's principal into + the decider, so the payload attribution matches the envelope.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store) + handler = activate_allocation.bind(deps) + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, _ = await store.load("Allocation", _ALLOCATION_ID) + assert events[-1].payload["activated_by"] == str(_PRINCIPAL_ID) + assert events[-1].principal_id == _PRINCIPAL_ID + + +@pytest.mark.unit +async def test_handler_raises_not_found_for_unknown_allocation() -> None: + deps = _build_deps() + handler = activate_allocation.bind(deps) + with pytest.raises(AllocationNotFoundError): + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_cannot_activate_when_already_active() -> None: + store = InMemoryEventStore() + await seed_allocation_events( + store, + _ALLOCATION_ID, + granted_event(_ALLOCATION_ID), + activated_event(_ALLOCATION_ID), + ) + deps = _build_deps(event_store=store) + handler = activate_allocation.bind(deps) + with pytest.raises(AllocationCannotActivateError): + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_denies_via_authorize_port() -> None: + deps = _build_deps(deny=True) + handler = activate_allocation.bind(deps) + with pytest.raises(UnauthorizedError): + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_denied_does_not_write_to_stream() -> None: + """Authorize-denial MUST NOT mutate the Allocation stream.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store, deny=True) + handler = activate_allocation.bind(deps) + with pytest.raises(UnauthorizedError): + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 1 + assert len(events) == 1 + assert events[0].event_type == "AllocationGranted" diff --git a/apps/api/tests/unit/budget/test_allocation_evolver.py b/apps/api/tests/unit/budget/test_allocation_evolver.py new file mode 100644 index 00000000000..4350eac0c64 --- /dev/null +++ b/apps/api/tests/unit/budget/test_allocation_evolver.py @@ -0,0 +1,219 @@ +"""Evolver tests for the Allocation aggregate.""" + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from cora.budget.aggregates.allocation.events import ( + AllocationActivated, + AllocationCeilingAmended, + AllocationGranted, + AllocationSealed, + AllocationVoided, +) +from cora.budget.aggregates.allocation.evolver import fold +from cora.budget.aggregates.allocation.state import AllocationStatus +from cora.shared.identity import ActorId + +_T0 = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_T1 = _T0 + timedelta(minutes=10) +_T2 = _T0 + timedelta(minutes=20) +_T3 = _T0 + timedelta(minutes=30) + +_GRANTED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000011")) +_ACTIVATED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000022")) +_SEALED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000033")) +_CAMPAIGN_ID = UUID("01900000-0000-7000-8000-000000000044") + + +def _genesis( + *, + allocation_id: UUID | None = None, + campaign_id: UUID | None = None, +) -> AllocationGranted: + return AllocationGranted( + allocation_id=allocation_id or uuid4(), + ceiling_usd=25000.0, + campaign_id=campaign_id, + holder_note="FY26 imaging award", + granted_by=_GRANTED_BY, + occurred_at=_T0, + ) + + +@pytest.mark.unit +def test_empty_stream_folds_to_none() -> None: + assert fold([]) is None + + +@pytest.mark.unit +def test_genesis_folds_to_granted_state() -> None: + e = _genesis(campaign_id=_CAMPAIGN_ID) + state = fold([e]) + assert state is not None + assert state.id == e.allocation_id + assert state.status is AllocationStatus.GRANTED + assert state.ceiling_usd == 25000.0 + assert state.holder_note.value == "FY26 imaging award" + assert state.campaign_id == _CAMPAIGN_ID + assert state.granted_at == _T0 + assert state.granted_by == _GRANTED_BY + assert state.activated_at is None + assert state.activated_by is None + assert state.sealed_at is None + assert state.sealed_by is None + assert state.spent_usd_at_seal is None + assert state.end_reason is None + + +@pytest.mark.unit +def test_genesis_without_campaign_folds_unbound_envelope() -> None: + state = fold([_genesis()]) + assert state is not None + assert state.campaign_id is None + + +@pytest.mark.unit +def test_activated_folds_to_active_with_window_start() -> None: + allocation_id = uuid4() + e1 = _genesis(allocation_id=allocation_id) + e2 = AllocationActivated( + allocation_id=allocation_id, activated_by=_ACTIVATED_BY, occurred_at=_T1 + ) + state = fold([e1, e2]) + assert state is not None + assert state.status is AllocationStatus.ACTIVE + assert state.activated_at == _T1 + assert state.activated_by == _ACTIVATED_BY + assert state.granted_at == _T0 + assert state.granted_by == _GRANTED_BY + + +@pytest.mark.unit +def test_ceiling_amended_overwrites_ceiling_and_keeps_status() -> None: + """PUT semantics fold: the amended ceiling replaces the prior one + while every other field (status included) carries forward.""" + allocation_id = uuid4() + e1 = _genesis(allocation_id=allocation_id) + e2 = AllocationActivated( + allocation_id=allocation_id, activated_by=_ACTIVATED_BY, occurred_at=_T1 + ) + e3 = AllocationCeilingAmended(allocation_id=allocation_id, ceiling_usd=18000.0, occurred_at=_T2) + state = fold([e1, e2, e3]) + assert state is not None + assert state.ceiling_usd == 18000.0 + assert state.status is AllocationStatus.ACTIVE + assert state.activated_at == _T1 + + +@pytest.mark.unit +def test_ceiling_amended_while_granted_keeps_dormant_status() -> None: + allocation_id = uuid4() + e1 = _genesis(allocation_id=allocation_id) + e2 = AllocationCeilingAmended(allocation_id=allocation_id, ceiling_usd=30000.0, occurred_at=_T1) + state = fold([e1, e2]) + assert state is not None + assert state.ceiling_usd == 30000.0 + assert state.status is AllocationStatus.GRANTED + + +@pytest.mark.unit +def test_sealed_folds_terminal_with_spend_snapshot_and_reason() -> None: + allocation_id = uuid4() + e1 = _genesis(allocation_id=allocation_id) + e2 = AllocationActivated( + allocation_id=allocation_id, activated_by=_ACTIVATED_BY, occurred_at=_T1 + ) + e3 = AllocationSealed( + allocation_id=allocation_id, + spent_usd=812.4, + reason="Campaign closed early", + sealed_by=_SEALED_BY, + occurred_at=_T2, + ) + state = fold([e1, e2, e3]) + assert state is not None + assert state.status is AllocationStatus.SEALED + assert state.sealed_at == _T2 + assert state.sealed_by == _SEALED_BY + assert state.spent_usd_at_seal == 812.4 + assert state.end_reason == "Campaign closed early" + assert state.activated_at == _T1 + + +@pytest.mark.unit +def test_sealed_without_reason_folds_with_none_end_reason() -> None: + """A routine end-of-window seal carries no note; the snapshot alone + closes the books.""" + allocation_id = uuid4() + e1 = _genesis(allocation_id=allocation_id) + e2 = AllocationActivated( + allocation_id=allocation_id, activated_by=_ACTIVATED_BY, occurred_at=_T1 + ) + e3 = AllocationSealed( + allocation_id=allocation_id, + spent_usd=0.0, + reason=None, + sealed_by=_SEALED_BY, + occurred_at=_T3, + ) + state = fold([e1, e2, e3]) + assert state is not None + assert state.status is AllocationStatus.SEALED + assert state.spent_usd_at_seal == 0.0 + assert state.end_reason is None + + +@pytest.mark.unit +def test_voided_folds_terminal_with_end_reason_and_no_snapshot() -> None: + allocation_id = uuid4() + e1 = _genesis(allocation_id=allocation_id) + e2 = AllocationVoided( + allocation_id=allocation_id, + reason="Granted against the wrong cycle", + occurred_at=_T1, + ) + state = fold([e1, e2]) + assert state is not None + assert state.status is AllocationStatus.VOIDED + assert state.end_reason == "Granted against the wrong cycle" + assert state.spent_usd_at_seal is None + assert state.sealed_at is None + + +@pytest.mark.unit +def test_voided_from_active_keeps_window_start() -> None: + """Voiding an already-opened window preserves activated_at: the + audit trail keeps the fact the window existed.""" + allocation_id = uuid4() + e1 = _genesis(allocation_id=allocation_id) + e2 = AllocationActivated( + allocation_id=allocation_id, activated_by=_ACTIVATED_BY, occurred_at=_T1 + ) + e3 = AllocationVoided(allocation_id=allocation_id, reason="Wrong beamline", occurred_at=_T2) + state = fold([e1, e2, e3]) + assert state is not None + assert state.status is AllocationStatus.VOIDED + assert state.activated_at == _T1 + + +@pytest.mark.unit +def test_activated_applied_to_empty_state_raises() -> None: + """The shared `require_state` helper raises on transition-before-genesis.""" + e = AllocationActivated(allocation_id=uuid4(), activated_by=_ACTIVATED_BY, occurred_at=_T0) + with pytest.raises(ValueError, match="AllocationActivated"): + fold([e]) + + +@pytest.mark.unit +def test_sealed_applied_to_empty_state_raises() -> None: + e = AllocationSealed( + allocation_id=uuid4(), + spent_usd=1.0, + reason=None, + sealed_by=_SEALED_BY, + occurred_at=_T0, + ) + with pytest.raises(ValueError, match="AllocationSealed"): + fold([e]) diff --git a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py new file mode 100644 index 00000000000..9a72340eb3d --- /dev/null +++ b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py @@ -0,0 +1,101 @@ +"""Pure-decider tests for the `amend_allocation_ceiling` slice.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationCannotAmendError, + AllocationCeilingAmended, + AllocationNotFoundError, + AllocationStatus, + InvalidAllocationCeilingError, +) +from cora.budget.features.amend_allocation_ceiling.command import AmendAllocationCeiling +from cora.budget.features.amend_allocation_ceiling.decider import decide +from tests.unit.budget._helpers import make_allocation + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) + + +@pytest.mark.unit +@pytest.mark.parametrize("status", [AllocationStatus.GRANTED, AllocationStatus.ACTIVE]) +def test_amends_ceiling_from_granted_and_active(status: AllocationStatus) -> None: + envelope = make_allocation(status, ceiling_usd=25000.0) + events = decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=18000.0), + now=_NOW, + ) + assert events == [ + AllocationCeilingAmended( + allocation_id=envelope.id, + ceiling_usd=18000.0, + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_same_ceiling_returns_no_events() -> None: + """PUT semantics are idempotent: a retried amend that matches the + stored ceiling appends nothing (the update_agent_budget precedent).""" + envelope = make_allocation(AllocationStatus.ACTIVE, ceiling_usd=25000.0) + events = decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=25000.0), + now=_NOW, + ) + assert events == [] + + +@pytest.mark.unit +def test_not_found_when_state_is_none() -> None: + with pytest.raises(AllocationNotFoundError): + decide( + state=None, + command=AmendAllocationCeiling(allocation_id=uuid4(), ceiling_usd=100.0), + now=_NOW, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("status", [AllocationStatus.SEALED, AllocationStatus.VOIDED]) +def test_cannot_amend_terminal_envelope(status: AllocationStatus) -> None: + envelope = make_allocation(status) + with pytest.raises(AllocationCannotAmendError): + decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=100.0), + now=_NOW, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "bad_ceiling", + [0.0, -1.0, float("nan"), float("inf"), float("-inf")], +) +def test_non_positive_or_non_finite_ceiling_raises(bad_ceiling: float) -> None: + envelope = make_allocation(AllocationStatus.GRANTED) + with pytest.raises(InvalidAllocationCeilingError): + decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=bad_ceiling), + now=_NOW, + ) + + +@pytest.mark.unit +def test_validation_fires_before_idempotency_short_circuit() -> None: + """A malformed ceiling raises even when comparison against the + stored value would have short-circuited: NaN never equals, but the + guard order is the load-bearing property being pinned.""" + envelope = make_allocation(AllocationStatus.GRANTED, ceiling_usd=25000.0) + with pytest.raises(InvalidAllocationCeilingError): + decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=float("nan")), + now=_NOW, + ) diff --git a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py new file mode 100644 index 00000000000..80a640ec08a --- /dev/null +++ b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py @@ -0,0 +1,184 @@ +"""Property-based tests for `amend_allocation_ceiling.decide` (budget BC). + +Complements the example-based `test_amend_allocation_ceiling_decider.py` +with universal claims across generated inputs. The decider is a pure +PUT-semantics amendment + + (state, command, *, now) -> list[AllocationCeilingAmended] + +Load-bearing properties: + + - state=None always raises `AllocationNotFoundError` carrying + command.allocation_id. + - The source-state partition is total over `AllocationStatus`: + Granted and Active accept; Sealed and Voided always raise + `AllocationCannotAmendError` carrying the current status. + - PUT idempotency: amending to the stored ceiling returns [] for + every valid ceiling; amending to a different valid ceiling emits + exactly one event carrying it verbatim with state.id. + - Invalid ceilings (non-positive or non-finite) always raise from + an amendable source, regardless of the stored value. + - Pure: same (state, command, now) returns equal events. +""" + +from __future__ import annotations + +from math import isfinite +from typing import TYPE_CHECKING + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.budget.aggregates.allocation import ( + AllocationCannotAmendError, + AllocationCeilingAmended, + AllocationNotFoundError, + AllocationStatus, + InvalidAllocationCeilingError, +) +from cora.budget.features.amend_allocation_ceiling.command import AmendAllocationCeiling +from cora.budget.features.amend_allocation_ceiling.decider import decide +from tests._strategies import aware_datetimes +from tests.unit.budget._helpers import make_allocation + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +_AMENDABLE_SOURCES = (AllocationStatus.GRANTED, AllocationStatus.ACTIVE) +_DISALLOWED_SOURCES = tuple(s for s in AllocationStatus if s not in frozenset(_AMENDABLE_SOURCES)) + +_valid_ceilings = st.floats( + min_value=0.01, + max_value=1e12, + allow_nan=False, + allow_infinity=False, +) +_invalid_ceilings = st.one_of( + st.floats(max_value=0.0, allow_nan=False), + st.just(float("nan")), + st.just(float("inf")), + st.just(float("-inf")), +) + + +@pytest.mark.unit +@given(allocation_id=st.uuids(), ceiling=_valid_ceilings, now=aware_datetimes()) +def test_amend_with_none_state_always_raises_not_found( + allocation_id: UUID, + ceiling: float, + now: datetime, +) -> None: + """Empty stream always raises not-found carrying command.allocation_id.""" + with pytest.raises(AllocationNotFoundError) as exc: + decide( + state=None, + command=AmendAllocationCeiling(allocation_id=allocation_id, ceiling_usd=ceiling), + now=now, + ) + assert exc.value.allocation_id == allocation_id + + +@pytest.mark.unit +@given( + allocation_id=st.uuids(), + source=st.sampled_from(_AMENDABLE_SOURCES), + stored=_valid_ceilings, + amended=_valid_ceilings, + now=aware_datetimes(), +) +def test_amend_to_different_ceiling_emits_single_event_with_state_id( + allocation_id: UUID, + source: AllocationStatus, + stored: float, + amended: float, + now: datetime, +) -> None: + """From an amendable source, a differing valid ceiling emits ONE event + carrying the amended value verbatim.""" + assume(stored != amended) + events = decide( + state=make_allocation(source, allocation_id=allocation_id, ceiling_usd=stored), + command=AmendAllocationCeiling(allocation_id=allocation_id, ceiling_usd=amended), + now=now, + ) + assert events == [ + AllocationCeilingAmended(allocation_id=allocation_id, ceiling_usd=amended, occurred_at=now) + ] + + +@pytest.mark.unit +@given( + source=st.sampled_from(_AMENDABLE_SOURCES), + ceiling=_valid_ceilings, + now=aware_datetimes(), +) +def test_amend_to_stored_ceiling_always_returns_no_events( + source: AllocationStatus, + ceiling: float, + now: datetime, +) -> None: + """PUT idempotency holds for every valid ceiling value.""" + envelope = make_allocation(source, ceiling_usd=ceiling) + events = decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=ceiling), + now=now, + ) + assert events == [] + + +@pytest.mark.unit +@given( + source=st.sampled_from(_DISALLOWED_SOURCES), + ceiling=_valid_ceilings, + now=aware_datetimes(), +) +def test_amend_from_terminal_source_always_raises_cannot_amend( + source: AllocationStatus, + ceiling: float, + now: datetime, +) -> None: + """Sealed and Voided books cannot be rewritten; carries the current status.""" + envelope = make_allocation(source) + with pytest.raises(AllocationCannotAmendError) as exc: + decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=ceiling), + now=now, + ) + assert exc.value.current_status is source + + +@pytest.mark.unit +@given( + source=st.sampled_from(_AMENDABLE_SOURCES), + ceiling=_invalid_ceilings, + now=aware_datetimes(), +) +def test_amend_invalid_ceiling_always_raises( + source: AllocationStatus, + ceiling: float, + now: datetime, +) -> None: + """The ceiling partition is total: everything outside finite-positive raises.""" + assert not (isfinite(ceiling) and ceiling > 0.0) + envelope = make_allocation(source) + with pytest.raises(InvalidAllocationCeilingError): + decide( + state=envelope, + command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=ceiling), + now=now, + ) + + +@pytest.mark.unit +@given(ceiling=_valid_ceilings, now=aware_datetimes()) +def test_amend_is_pure_same_input_same_output(ceiling: float, now: datetime) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + envelope = make_allocation(AllocationStatus.ACTIVE, ceiling_usd=123.45) + command = AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=ceiling) + first = decide(state=envelope, command=command, now=now) + second = decide(state=envelope, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py new file mode 100644 index 00000000000..8b6a83bd6f4 --- /dev/null +++ b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py @@ -0,0 +1,131 @@ +"""Application-handler tests for the `amend_allocation_ceiling` slice.""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationCannotAmendError, + AllocationNotFoundError, + AllocationVoided, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features import amend_allocation_ceiling +from cora.budget.features.amend_allocation_ceiling import AmendAllocationCeiling +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared +from tests.unit.budget._helpers import ( + ACTIVATED_AT, + granted_event, + seed_allocation_events, +) + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_ALLOCATION_ID = UUID("01900000-0000-7000-8000-00000000e001") +_AMEND_EVENT_ID = UUID("01900000-0000-7000-8000-00000000e002") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + return _build_deps_shared( + ids=[_AMEND_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +@pytest.mark.unit +async def test_handler_amends_ceiling_of_a_granted_allocation() -> None: + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store) + handler = amend_allocation_ceiling.bind(deps) + await handler( + AmendAllocationCeiling(allocation_id=_ALLOCATION_ID, ceiling_usd=18000.0), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 2 + assert events[-1].event_type == "AllocationCeilingAmended" + assert events[-1].payload["ceiling_usd"] == 18000.0 + + +@pytest.mark.unit +async def test_handler_same_ceiling_appends_nothing() -> None: + """The idempotent PUT no-op flows through the handler as a zero-event + append: the stream version stays put.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store) + handler = amend_allocation_ceiling.bind(deps) + await handler( + AmendAllocationCeiling(allocation_id=_ALLOCATION_ID, ceiling_usd=25000.0), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 1 + assert len(events) == 1 + + +@pytest.mark.unit +async def test_handler_raises_not_found_for_unknown_allocation() -> None: + deps = _build_deps() + handler = amend_allocation_ceiling.bind(deps) + with pytest.raises(AllocationNotFoundError): + await handler( + AmendAllocationCeiling(allocation_id=_ALLOCATION_ID, ceiling_usd=100.0), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_cannot_amend_after_void() -> None: + store = InMemoryEventStore() + await seed_allocation_events( + store, + _ALLOCATION_ID, + granted_event(_ALLOCATION_ID), + AllocationVoided( + allocation_id=_ALLOCATION_ID, + reason="Granted against the wrong cycle", + occurred_at=ACTIVATED_AT, + ), + ) + deps = _build_deps(event_store=store) + handler = amend_allocation_ceiling.bind(deps) + with pytest.raises(AllocationCannotAmendError): + await handler( + AmendAllocationCeiling(allocation_id=_ALLOCATION_ID, ceiling_usd=100.0), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_denied_does_not_write_to_stream() -> None: + """Authorize-denial MUST NOT mutate the Allocation stream.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store, deny=True) + handler = amend_allocation_ceiling.bind(deps) + with pytest.raises(UnauthorizedError): + await handler( + AmendAllocationCeiling(allocation_id=_ALLOCATION_ID, ceiling_usd=18000.0), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 1 + assert len(events) == 1 + assert events[0].event_type == "AllocationGranted" diff --git a/apps/api/tests/unit/budget/test_grant_allocation_decider.py b/apps/api/tests/unit/budget/test_grant_allocation_decider.py new file mode 100644 index 00000000000..b2c8f180746 --- /dev/null +++ b/apps/api/tests/unit/budget/test_grant_allocation_decider.py @@ -0,0 +1,135 @@ +"""Pure-decider tests for the `grant_allocation` slice.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.budget.aggregates.allocation import ( + ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + AllocationAlreadyExistsError, + AllocationGranted, + AllocationStatus, + InvalidAllocationCeilingError, + InvalidAllocationHolderNoteError, +) +from cora.budget.features.grant_allocation.command import GrantAllocation +from cora.budget.features.grant_allocation.decider import decide +from cora.shared.identity import ActorId +from tests.unit.budget._helpers import make_allocation + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_NEW_ID = uuid4() +_GRANTED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000099")) +_CAMPAIGN_ID = UUID("01900000-0000-7000-8000-000000000044") + + +def _command(**overrides: object) -> GrantAllocation: + base: dict[str, object] = { + "ceiling_usd": 25000.0, + "holder_note": "FY26 imaging award", + } + base.update(overrides) + return GrantAllocation(**base) # type: ignore[arg-type] + + +@pytest.mark.unit +def test_minimal_command_emits_single_allocation_granted() -> None: + events = decide( + state=None, command=_command(), now=_NOW, new_id=_NEW_ID, granted_by=_GRANTED_BY + ) + assert events == [ + AllocationGranted( + allocation_id=_NEW_ID, + ceiling_usd=25000.0, + campaign_id=None, + holder_note="FY26 imaging award", + granted_by=_GRANTED_BY, + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_campaign_bound_command_carries_campaign_id() -> None: + events = decide( + state=None, + command=_command(campaign_id=_CAMPAIGN_ID), + now=_NOW, + new_id=_NEW_ID, + granted_by=_GRANTED_BY, + ) + assert events[0].campaign_id == _CAMPAIGN_ID + + +@pytest.mark.unit +def test_genesis_collision_raises_already_exists() -> None: + existing = make_allocation(AllocationStatus.GRANTED, allocation_id=_NEW_ID) + with pytest.raises(AllocationAlreadyExistsError): + decide( + state=existing, + command=_command(), + now=_NOW, + new_id=_NEW_ID, + granted_by=_GRANTED_BY, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "bad_ceiling", + [0.0, -1.0, -25000.0, float("nan"), float("inf"), float("-inf")], +) +def test_non_positive_or_non_finite_ceiling_raises(bad_ceiling: float) -> None: + with pytest.raises(InvalidAllocationCeilingError): + decide( + state=None, + command=_command(ceiling_usd=bad_ceiling), + now=_NOW, + new_id=_NEW_ID, + granted_by=_GRANTED_BY, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("bad_note", ["", " ", "x" * (ALLOCATION_HOLDER_NOTE_MAX_LENGTH + 1)]) +def test_invalid_holder_note_raises(bad_note: str) -> None: + with pytest.raises(InvalidAllocationHolderNoteError): + decide( + state=None, + command=_command(holder_note=bad_note), + now=_NOW, + new_id=_NEW_ID, + granted_by=_GRANTED_BY, + ) + + +@pytest.mark.unit +def test_holder_note_trim_propagates() -> None: + """The VO trims; the decider passes the trimmed value into the event.""" + events = decide( + state=None, + command=_command(holder_note=" FY26 imaging award "), + now=_NOW, + new_id=_NEW_ID, + granted_by=_GRANTED_BY, + ) + assert events[0].holder_note == "FY26 imaging award" + + +@pytest.mark.unit +def test_event_uses_handler_supplied_now_new_id_and_granted_by() -> None: + """Non-determinism principle: decider takes now + new_id + actor as inputs.""" + custom_now = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + custom_id = uuid4() + custom_actor = ActorId(uuid4()) + events = decide( + state=None, + command=_command(), + now=custom_now, + new_id=custom_id, + granted_by=custom_actor, + ) + assert events[0].occurred_at == custom_now + assert events[0].allocation_id == custom_id + assert events[0].granted_by == custom_actor diff --git a/apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py b/apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py new file mode 100644 index 00000000000..74ec76d3e3c --- /dev/null +++ b/apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py @@ -0,0 +1,165 @@ +"""Property-based tests for `grant_allocation.decide` (budget BC). + +Complements the example-based `test_grant_allocation_decider.py` with +universal claims across generated inputs. The genesis decider is pure + + (state, command, *, now, new_id, granted_by) -> list[AllocationGranted] + +Load-bearing properties: + + - Any non-None state always raises `AllocationAlreadyExistsError` + carrying state.id, regardless of command. + - The ceiling partition is total over floats: finite positive + ceilings emit exactly one event carrying them verbatim; zero, + negative, and non-finite ceilings always raise + `InvalidAllocationCeilingError` (one bad ceiling would arm or + disarm the envelope check unconditionally). + - The emitted event carries the injected now / new_id / granted_by. + - Pure: same inputs return equal events. +""" + +from __future__ import annotations + +from math import isfinite +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.budget.aggregates.allocation import ( + AllocationAlreadyExistsError, + AllocationStatus, + InvalidAllocationCeilingError, +) +from cora.budget.features.grant_allocation.command import GrantAllocation +from cora.budget.features.grant_allocation.decider import decide +from cora.shared.identity import ActorId +from tests._strategies import aware_datetimes, printable_ascii_text +from tests.unit.budget._helpers import make_allocation + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +_valid_ceilings = st.floats( + min_value=0.01, + max_value=1e12, + allow_nan=False, + allow_infinity=False, +) +_invalid_ceilings = st.one_of( + st.floats(max_value=0.0, allow_nan=False), + st.just(float("nan")), + st.just(float("inf")), + st.just(float("-inf")), +) + + +@pytest.mark.unit +@given( + state_id=st.uuids(), + status=st.sampled_from(list(AllocationStatus)), + new_id=st.uuids(), + granted_by=st.uuids(), + now=aware_datetimes(), +) +def test_grant_with_any_existing_state_always_raises_already_exists( + state_id: UUID, + status: AllocationStatus, + new_id: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """Genesis-only: any prior state (any status) refuses, carrying state.id.""" + with pytest.raises(AllocationAlreadyExistsError) as exc: + decide( + state=make_allocation(status, allocation_id=state_id), + command=GrantAllocation(ceiling_usd=100.0, holder_note="Envelope"), + now=now, + new_id=new_id, + granted_by=ActorId(granted_by), + ) + assert exc.value.allocation_id == state_id + + +@pytest.mark.unit +@given( + ceiling=_valid_ceilings, + holder_note=printable_ascii_text(max_size=200), + new_id=st.uuids(), + granted_by=st.uuids(), + now=aware_datetimes(), +) +def test_grant_happy_path_emits_single_event_with_injected_fields( + ceiling: float, + holder_note: str, + new_id: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """Any finite positive ceiling grants; the event carries the inputs verbatim.""" + events = decide( + state=None, + command=GrantAllocation(ceiling_usd=ceiling, holder_note=holder_note), + now=now, + new_id=new_id, + granted_by=ActorId(granted_by), + ) + assert len(events) == 1 + event = events[0] + assert event.allocation_id == new_id + assert event.ceiling_usd == ceiling + assert event.holder_note == holder_note + assert event.granted_by == ActorId(granted_by) + assert event.occurred_at == now + assert event.campaign_id is None + + +@pytest.mark.unit +@given( + ceiling=_invalid_ceilings, + new_id=st.uuids(), + granted_by=st.uuids(), + now=aware_datetimes(), +) +def test_grant_non_positive_or_non_finite_ceiling_always_raises( + ceiling: float, + new_id: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """The ceiling partition is total: everything outside finite-positive raises.""" + assert not (isfinite(ceiling) and ceiling > 0.0) + with pytest.raises(InvalidAllocationCeilingError): + decide( + state=None, + command=GrantAllocation(ceiling_usd=ceiling, holder_note="Envelope"), + now=now, + new_id=new_id, + granted_by=ActorId(granted_by), + ) + + +@pytest.mark.unit +@given( + ceiling=_valid_ceilings, + new_id=st.uuids(), + granted_by=st.uuids(), + now=aware_datetimes(), +) +def test_grant_is_pure_same_input_same_output( + ceiling: float, + new_id: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + command = GrantAllocation(ceiling_usd=ceiling, holder_note="Envelope") + first = decide( + state=None, command=command, now=now, new_id=new_id, granted_by=ActorId(granted_by) + ) + second = decide( + state=None, command=command, now=now, new_id=new_id, granted_by=ActorId(granted_by) + ) + assert first == second diff --git a/apps/api/tests/unit/budget/test_grant_allocation_handler.py b/apps/api/tests/unit/budget/test_grant_allocation_handler.py new file mode 100644 index 00000000000..823eae13cbb --- /dev/null +++ b/apps/api/tests/unit/budget/test_grant_allocation_handler.py @@ -0,0 +1,209 @@ +"""Application-handler tests for the `grant_allocation` slice. + +Single-stream genesis: every successful call writes ONE +`AllocationGranted` event on the Allocation stream. The slice's +wrinkle over the plain create-style template is the optional +caller-supplied `allocation_id`: the handler loads the target stream +first so a collision trips the decider's genesis guard. The handler +also stamps `granted_by` from the envelope's principal (the +fold-symmetry attribution half). +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationAlreadyExistsError, + AllocationGranted, + event_type_name, + to_payload, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features import grant_allocation +from cora.budget.features.grant_allocation import GrantAllocation +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.shared.identity import ActorId +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-00000000c001") +_EVENT_ID = UUID("01900000-0000-7000-8000-00000000c002") +_SUPPLIED_ID = UUID("01900000-0000-7000-8000-00000000c003") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _build_deps( + *, + ids: list[UUID] | None = None, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + return _build_deps_shared( + # grant_allocation consumes 2 ids on the minted path: new + # allocation_id + 1 event_id. Caller-supplied-id tests pass + # ids=[_EVENT_ID] only. + ids=ids if ids is not None else [_NEW_ID, _EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _command(**overrides: object) -> GrantAllocation: + base: dict[str, object] = { + "ceiling_usd": 25000.0, + "holder_note": "FY26 imaging award", + } + base.update(overrides) + return GrantAllocation(**base) # type: ignore[arg-type] + + +async def _seed_allocation(store: InMemoryEventStore, allocation_id: UUID) -> None: + event = AllocationGranted( + allocation_id=allocation_id, + ceiling_usd=25000.0, + campaign_id=None, + holder_note="Seeded envelope", + granted_by=ActorId(_PRINCIPAL_ID), + occurred_at=_NOW, + ) + await store.append( + stream_type="Allocation", + stream_id=allocation_id, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=_NOW, + event_id=uuid4(), + command_name="GrantAllocation", + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + +@pytest.mark.unit +async def test_handler_returns_generated_allocation_id() -> None: + deps = _build_deps() + handler = grant_allocation.bind(deps) + result = await handler( + _command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result == _NEW_ID + + +@pytest.mark.unit +async def test_handler_uses_caller_supplied_allocation_id() -> None: + """A command-carried id is used verbatim; the IdGenerator only mints + the event envelope id.""" + store = InMemoryEventStore() + deps = _build_deps(ids=[_EVENT_ID], event_store=store) + handler = grant_allocation.bind(deps) + result = await handler( + _command(allocation_id=_SUPPLIED_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result == _SUPPLIED_ID + events, version = await store.load("Allocation", _SUPPLIED_ID) + assert version == 1 + assert len(events) == 1 + + +@pytest.mark.unit +async def test_handler_appends_single_granted_event_with_principal_as_granted_by() -> None: + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + handler = grant_allocation.bind(deps) + await handler( + _command(campaign_id=UUID("01900000-0000-7000-8000-000000000044")), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _NEW_ID) + assert version == 1 + assert len(events) == 1 + assert events[0].event_type == "AllocationGranted" + payload = events[0].payload + assert payload["allocation_id"] == str(_NEW_ID) + assert payload["ceiling_usd"] == 25000.0 + assert payload["campaign_id"] == "01900000-0000-7000-8000-000000000044" + assert payload["holder_note"] == "FY26 imaging award" + assert payload["granted_by"] == str(_PRINCIPAL_ID) + assert payload["occurred_at"] == _NOW.isoformat() + + +@pytest.mark.unit +async def test_handler_propagates_envelope_fields() -> None: + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + handler = grant_allocation.bind(deps) + await handler( + _command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, _ = await store.load("Allocation", _NEW_ID) + stored = events[0] + assert stored.correlation_id == _CORRELATION_ID + assert stored.causation_id is None + assert stored.principal_id == _PRINCIPAL_ID + + +@pytest.mark.unit +async def test_handler_supplied_id_collision_raises_already_exists() -> None: + """The handler loads the target stream, so re-granting a + caller-supplied id trips the decider's genesis guard instead of + surfacing a raw concurrency error.""" + store = InMemoryEventStore() + await _seed_allocation(store, _SUPPLIED_ID) + deps = _build_deps(ids=[_EVENT_ID], event_store=store) + handler = grant_allocation.bind(deps) + with pytest.raises(AllocationAlreadyExistsError): + await handler( + _command(allocation_id=_SUPPLIED_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _SUPPLIED_ID) + assert version == 1 + assert len(events) == 1 + + +@pytest.mark.unit +async def test_handler_denies_via_authorize_port() -> None: + deps = _build_deps(deny=True) + handler = grant_allocation.bind(deps) + with pytest.raises(UnauthorizedError): + await handler( + _command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_denied_does_not_write_stream() -> None: + """Authorize-denial MUST NOT leave events on the stream.""" + store = InMemoryEventStore() + deps = _build_deps(event_store=store, deny=True) + handler = grant_allocation.bind(deps) + with pytest.raises(UnauthorizedError): + await handler( + _command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _NEW_ID) + assert version == 0 + assert events == [] diff --git a/apps/api/tests/unit/budget/test_seal_allocation_decider.py b/apps/api/tests/unit/budget/test_seal_allocation_decider.py new file mode 100644 index 00000000000..a16ee244bb6 --- /dev/null +++ b/apps/api/tests/unit/budget/test_seal_allocation_decider.py @@ -0,0 +1,122 @@ +"""Pure-decider tests for the `seal_allocation` slice.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationCannotSealError, + AllocationNotFoundError, + AllocationSealed, + AllocationStatus, + InvalidAllocationReasonError, +) +from cora.budget.features.seal_allocation.command import SealAllocation +from cora.budget.features.seal_allocation.decider import decide +from cora.shared.identity import ActorId +from cora.shared.text_bounds import REASON_MAX_LENGTH +from tests.unit.budget._helpers import make_allocation + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_SEALED_BY = ActorId(UUID("01900000-0000-7000-8000-000000000099")) + + +@pytest.mark.unit +def test_seal_from_active_emits_sealed_event_with_reader_snapshot() -> None: + envelope = make_allocation(AllocationStatus.ACTIVE) + events = decide( + state=envelope, + command=SealAllocation(allocation_id=envelope.id), + now=_NOW, + spent_usd=812.4, + sealed_by=_SEALED_BY, + ) + assert events == [ + AllocationSealed( + allocation_id=envelope.id, + spent_usd=812.4, + reason=None, + sealed_by=_SEALED_BY, + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_seal_reason_trims_and_carries() -> None: + envelope = make_allocation(AllocationStatus.ACTIVE) + events = decide( + state=envelope, + command=SealAllocation(allocation_id=envelope.id, reason=" Campaign closed early "), + now=_NOW, + spent_usd=0.0, + sealed_by=_SEALED_BY, + ) + assert events[0].reason == "Campaign closed early" + + +@pytest.mark.unit +@pytest.mark.parametrize("bad_reason", ["", " ", "x" * (REASON_MAX_LENGTH + 1)]) +def test_invalid_reason_raises(bad_reason: str) -> None: + envelope = make_allocation(AllocationStatus.ACTIVE) + with pytest.raises(InvalidAllocationReasonError): + decide( + state=envelope, + command=SealAllocation(allocation_id=envelope.id, reason=bad_reason), + now=_NOW, + spent_usd=0.0, + sealed_by=_SEALED_BY, + ) + + +@pytest.mark.unit +def test_not_found_when_state_is_none() -> None: + with pytest.raises(AllocationNotFoundError): + decide( + state=None, + command=SealAllocation(allocation_id=uuid4()), + now=_NOW, + spent_usd=0.0, + sealed_by=_SEALED_BY, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "status", + [ + AllocationStatus.GRANTED, + AllocationStatus.SEALED, + AllocationStatus.VOIDED, + ], +) +def test_seal_from_non_active_raises_cannot_seal(status: AllocationStatus) -> None: + """A dormant grant has no open window to close (void it instead); + terminals are already closed.""" + envelope = make_allocation(status) + with pytest.raises(AllocationCannotSealError): + decide( + state=envelope, + command=SealAllocation(allocation_id=envelope.id), + now=_NOW, + spent_usd=0.0, + sealed_by=_SEALED_BY, + ) + + +@pytest.mark.unit +def test_event_uses_handler_supplied_snapshot_and_actor() -> None: + """Non-determinism principle: the ledger fold and the acting + principal are inputs, never recomputed here.""" + envelope = make_allocation(AllocationStatus.ACTIVE) + custom_actor = ActorId(uuid4()) + events = decide( + state=envelope, + command=SealAllocation(allocation_id=envelope.id), + now=_NOW, + spent_usd=12345.67, + sealed_by=custom_actor, + ) + assert events[0].spent_usd == 12345.67 + assert events[0].sealed_by == custom_actor diff --git a/apps/api/tests/unit/budget/test_seal_allocation_decider_properties.py b/apps/api/tests/unit/budget/test_seal_allocation_decider_properties.py new file mode 100644 index 00000000000..509f5640c24 --- /dev/null +++ b/apps/api/tests/unit/budget/test_seal_allocation_decider_properties.py @@ -0,0 +1,182 @@ +"""Property-based tests for `seal_allocation.decide` (budget BC). + +Complements the example-based `test_seal_allocation_decider.py` with +universal claims across generated inputs. The decider is a pure +single-source FSM transition + + (state, command, *, now, spent_usd, sealed_by) -> list[AllocationSealed] + +Load-bearing properties: + + - state=None always raises `AllocationNotFoundError` carrying + command.allocation_id. + - The source-state partition is total over `AllocationStatus`: + only `Active` emits exactly one `AllocationSealed`; every other + status raises `AllocationCannotSealError` carrying the current + status. + - The snapshot passes through verbatim: for any finite non-negative + `spent_usd` the emitted event carries exactly the reader's value + (the closing-the-books figure is never recomputed or rounded + here). + - The emitted event's allocation_id is `state.id`, never + `command.allocation_id`. + - Pure: same inputs return equal events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.budget.aggregates.allocation import ( + AllocationCannotSealError, + AllocationNotFoundError, + AllocationSealed, + AllocationStatus, +) +from cora.budget.features.seal_allocation.command import SealAllocation +from cora.budget.features.seal_allocation.decider import decide +from cora.shared.identity import ActorId +from tests._strategies import aware_datetimes +from tests.unit.budget._helpers import make_allocation + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +_SEALABLE_SOURCES = (AllocationStatus.ACTIVE,) +_DISALLOWED_SOURCES = tuple(s for s in AllocationStatus if s not in frozenset(_SEALABLE_SOURCES)) + +_spend_snapshots = st.floats( + min_value=0.0, + max_value=1e12, + allow_nan=False, + allow_infinity=False, +) + + +@pytest.mark.unit +@given( + allocation_id=st.uuids(), + spent_usd=_spend_snapshots, + sealed_by=st.uuids(), + now=aware_datetimes(), +) +def test_seal_with_none_state_always_raises_not_found( + allocation_id: UUID, + spent_usd: float, + sealed_by: UUID, + now: datetime, +) -> None: + """Empty stream always raises not-found carrying command.allocation_id.""" + with pytest.raises(AllocationNotFoundError) as exc: + decide( + state=None, + command=SealAllocation(allocation_id=allocation_id), + now=now, + spent_usd=spent_usd, + sealed_by=ActorId(sealed_by), + ) + assert exc.value.allocation_id == allocation_id + + +@pytest.mark.unit +@given( + allocation_id=st.uuids(), + spent_usd=_spend_snapshots, + sealed_by=st.uuids(), + now=aware_datetimes(), +) +def test_seal_from_active_records_readers_value_verbatim( + allocation_id: UUID, + spent_usd: float, + sealed_by: UUID, + now: datetime, +) -> None: + """Active is the only sealable source; the snapshot is the reader's + value, untouched.""" + events = decide( + state=make_allocation(AllocationStatus.ACTIVE, allocation_id=allocation_id), + command=SealAllocation(allocation_id=allocation_id), + now=now, + spent_usd=spent_usd, + sealed_by=ActorId(sealed_by), + ) + assert events == [ + AllocationSealed( + allocation_id=allocation_id, + spent_usd=spent_usd, + reason=None, + sealed_by=ActorId(sealed_by), + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + allocation_id=st.uuids(), + source=st.sampled_from(_DISALLOWED_SOURCES), + spent_usd=_spend_snapshots, + sealed_by=st.uuids(), + now=aware_datetimes(), +) +def test_seal_from_disallowed_source_always_raises_cannot_seal( + allocation_id: UUID, + source: AllocationStatus, + spent_usd: float, + sealed_by: UUID, + now: datetime, +) -> None: + """Any source other than Active raises, carrying the current status.""" + with pytest.raises(AllocationCannotSealError) as exc: + decide( + state=make_allocation(source, allocation_id=allocation_id), + command=SealAllocation(allocation_id=allocation_id), + now=now, + spent_usd=spent_usd, + sealed_by=ActorId(sealed_by), + ) + assert exc.value.current_status is source + + +@pytest.mark.unit +@given(state_id=st.uuids(), command_id=st.uuids(), sealed_by=st.uuids(), now=aware_datetimes()) +def test_seal_uses_state_id_not_command_allocation_id( + state_id: UUID, + command_id: UUID, + sealed_by: UUID, + now: datetime, +) -> None: + """The emitted event's allocation_id is state.id, not command's.""" + assume(state_id != command_id) + events = decide( + state=make_allocation(AllocationStatus.ACTIVE, allocation_id=state_id), + command=SealAllocation(allocation_id=command_id), + now=now, + spent_usd=0.0, + sealed_by=ActorId(sealed_by), + ) + assert events[0].allocation_id == state_id + + +@pytest.mark.unit +@given(spent_usd=_spend_snapshots, sealed_by=st.uuids(), now=aware_datetimes()) +def test_seal_is_pure_same_input_same_output( + spent_usd: float, + sealed_by: UUID, + now: datetime, +) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + envelope = make_allocation(AllocationStatus.ACTIVE) + command = SealAllocation(allocation_id=envelope.id) + first = decide( + state=envelope, command=command, now=now, spent_usd=spent_usd, sealed_by=ActorId(sealed_by) + ) + second = decide( + state=envelope, command=command, now=now, spent_usd=spent_usd, sealed_by=ActorId(sealed_by) + ) + assert first == second diff --git a/apps/api/tests/unit/budget/test_seal_allocation_handler.py b/apps/api/tests/unit/budget/test_seal_allocation_handler.py new file mode 100644 index 00000000000..d4c0990b75a --- /dev/null +++ b/apps/api/tests/unit/budget/test_seal_allocation_handler.py @@ -0,0 +1,194 @@ +"""Application-handler tests for the `seal_allocation` slice. + +The slice's load-bearing wiring is the TotalSpendReader seam: the +handler folds the envelope, threads its `activated_at` and the Clock's +`now` into the injected reader, and records the reader's answer as the +seal's spend snapshot. The tests pin the window threading, the +guard-path short-circuit (no ledger read for a window that never +opened), and the stage-A zero-reader posture. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationCannotSealError, + AllocationNotFoundError, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features import seal_allocation +from cora.budget.features.seal_allocation import SealAllocation, zero_total_spend +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared +from tests.unit.budget._helpers import ( + ACTIVATED_AT, + activated_event, + granted_event, + seed_allocation_events, +) + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_ALLOCATION_ID = UUID("01900000-0000-7000-8000-00000000f001") +_SEAL_EVENT_ID = UUID("01900000-0000-7000-8000-00000000f002") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +class _RecordingReader: + """TotalSpendReader stub that records its calls and returns a fixed fold.""" + + def __init__(self, total: float) -> None: + self.total = total + self.calls: list[tuple[datetime, datetime]] = [] + + async def __call__(self, *, window_start: datetime, window_end: datetime) -> float: + self.calls.append((window_start, window_end)) + return self.total + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + return _build_deps_shared( + ids=[_SEAL_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +async def _seed_active(store: InMemoryEventStore) -> None: + await seed_allocation_events( + store, + _ALLOCATION_ID, + granted_event(_ALLOCATION_ID), + activated_event(_ALLOCATION_ID), + ) + + +@pytest.mark.unit +async def test_handler_seals_active_allocation_recording_readers_value() -> None: + store = InMemoryEventStore() + await _seed_active(store) + deps = _build_deps(event_store=store) + reader = _RecordingReader(total=42.5) + handler = seal_allocation.bind(deps, total_spend_reader=reader) + await handler( + SealAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 3 + assert events[-1].event_type == "AllocationSealed" + payload = events[-1].payload + assert payload["spent_usd"] == 42.5 + assert payload["reason"] is None + assert payload["sealed_by"] == str(_PRINCIPAL_ID) + assert payload["occurred_at"] == _NOW.isoformat() + + +@pytest.mark.unit +async def test_handler_threads_activated_at_and_now_into_reader_window() -> None: + """The envelope's own lifecycle IS the window: window_start is the + folded activated_at, window_end is the Clock's now.""" + store = InMemoryEventStore() + await _seed_active(store) + deps = _build_deps(event_store=store) + reader = _RecordingReader(total=0.0) + handler = seal_allocation.bind(deps, total_spend_reader=reader) + await handler( + SealAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert reader.calls == [(ACTIVATED_AT, _NOW)] + + +@pytest.mark.unit +async def test_handler_carries_seal_reason_to_payload() -> None: + store = InMemoryEventStore() + await _seed_active(store) + deps = _build_deps(event_store=store) + handler = seal_allocation.bind(deps, total_spend_reader=_RecordingReader(total=0.0)) + await handler( + SealAllocation(allocation_id=_ALLOCATION_ID, reason="Campaign closed early"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, _ = await store.load("Allocation", _ALLOCATION_ID) + assert events[-1].payload["reason"] == "Campaign closed early" + + +@pytest.mark.unit +async def test_handler_with_zero_reader_records_zero_snapshot() -> None: + """Stage-A posture: the bound zero-reader makes every seal record 0.0 + honestly until stage C wires the ledger-backed fold.""" + store = InMemoryEventStore() + await _seed_active(store) + deps = _build_deps(event_store=store) + handler = seal_allocation.bind(deps, total_spend_reader=zero_total_spend) + await handler( + SealAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, _ = await store.load("Allocation", _ALLOCATION_ID) + assert events[-1].payload["spent_usd"] == 0.0 + + +@pytest.mark.unit +async def test_handler_raises_not_found_without_calling_reader() -> None: + deps = _build_deps() + reader = _RecordingReader(total=99.0) + handler = seal_allocation.bind(deps, total_spend_reader=reader) + with pytest.raises(AllocationNotFoundError): + await handler( + SealAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert reader.calls == [] + + +@pytest.mark.unit +async def test_handler_raises_cannot_seal_dormant_grant_without_calling_reader() -> None: + """A Granted envelope has no activated_at: there is no window to + fold, so the reader must not be consulted before the guard fires.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store) + reader = _RecordingReader(total=99.0) + handler = seal_allocation.bind(deps, total_spend_reader=reader) + with pytest.raises(AllocationCannotSealError): + await handler( + SealAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert reader.calls == [] + + +@pytest.mark.unit +async def test_handler_denied_does_not_write_or_read_ledger() -> None: + """Authorize-denial MUST NOT mutate the stream nor touch the ledger.""" + store = InMemoryEventStore() + await _seed_active(store) + deps = _build_deps(event_store=store, deny=True) + reader = _RecordingReader(total=99.0) + handler = seal_allocation.bind(deps, total_spend_reader=reader) + with pytest.raises(UnauthorizedError): + await handler( + SealAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 2 + assert len(events) == 2 + assert reader.calls == [] diff --git a/apps/api/tests/unit/budget/test_void_allocation_decider.py b/apps/api/tests/unit/budget/test_void_allocation_decider.py new file mode 100644 index 00000000000..6d8b5856f57 --- /dev/null +++ b/apps/api/tests/unit/budget/test_void_allocation_decider.py @@ -0,0 +1,86 @@ +"""Pure-decider tests for the `void_allocation` slice.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationCannotVoidError, + AllocationNotFoundError, + AllocationStatus, + AllocationVoided, + InvalidAllocationReasonError, +) +from cora.budget.features.void_allocation.command import VoidAllocation +from cora.budget.features.void_allocation.decider import decide +from cora.shared.text_bounds import REASON_MAX_LENGTH +from tests.unit.budget._helpers import make_allocation + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) + + +@pytest.mark.unit +@pytest.mark.parametrize("status", [AllocationStatus.GRANTED, AllocationStatus.ACTIVE]) +def test_voids_granted_and_active_allocations(status: AllocationStatus) -> None: + envelope = make_allocation(status) + events = decide( + state=envelope, + command=VoidAllocation(allocation_id=envelope.id, reason="Granted against the wrong cycle"), + now=_NOW, + ) + assert events == [ + AllocationVoided( + allocation_id=envelope.id, + reason="Granted against the wrong cycle", + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_void_reason_trims_and_carries() -> None: + envelope = make_allocation(AllocationStatus.GRANTED) + events = decide( + state=envelope, + command=VoidAllocation(allocation_id=envelope.id, reason=" Wrong beamline "), + now=_NOW, + ) + assert events[0].reason == "Wrong beamline" + + +@pytest.mark.unit +@pytest.mark.parametrize("bad_reason", ["", " ", "x" * (REASON_MAX_LENGTH + 1)]) +def test_invalid_reason_raises(bad_reason: str) -> None: + """The withdrawal reason is REQUIRED: empty, whitespace-only, and + over-length all refuse.""" + envelope = make_allocation(AllocationStatus.GRANTED) + with pytest.raises(InvalidAllocationReasonError): + decide( + state=envelope, + command=VoidAllocation(allocation_id=envelope.id, reason=bad_reason), + now=_NOW, + ) + + +@pytest.mark.unit +def test_not_found_when_state_is_none() -> None: + with pytest.raises(AllocationNotFoundError): + decide( + state=None, + command=VoidAllocation(allocation_id=uuid4(), reason="Wrong beamline"), + now=_NOW, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("status", [AllocationStatus.SEALED, AllocationStatus.VOIDED]) +def test_cannot_void_terminal_envelope(status: AllocationStatus) -> None: + """Re-terminating would blur which end the audit trail records.""" + envelope = make_allocation(status) + with pytest.raises(AllocationCannotVoidError): + decide( + state=envelope, + command=VoidAllocation(allocation_id=envelope.id, reason="Wrong beamline"), + now=_NOW, + ) diff --git a/apps/api/tests/unit/budget/test_void_allocation_decider_properties.py b/apps/api/tests/unit/budget/test_void_allocation_decider_properties.py new file mode 100644 index 00000000000..98f1be4480f --- /dev/null +++ b/apps/api/tests/unit/budget/test_void_allocation_decider_properties.py @@ -0,0 +1,166 @@ +"""Property-based tests for `void_allocation.decide` (budget BC). + +Complements the example-based `test_void_allocation_decider.py` with +universal claims across generated inputs. The decider is a pure FSM +transition + + (state, command, *, now) -> list[AllocationVoided] + +Load-bearing properties: + + - state=None always raises `AllocationNotFoundError` carrying + command.allocation_id. + - The source-state partition is total over `AllocationStatus`: + Granted and Active emit exactly one `AllocationVoided`; Sealed + and Voided always raise `AllocationCannotVoidError` carrying the + current status. + - The reason is REQUIRED: whitespace-only strings of any length + always raise `InvalidAllocationReasonError`; valid reasons carry + through trimmed. + - The emitted event's allocation_id is `state.id`, never + `command.allocation_id`. + - Pure: same (state, command, now) returns equal events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.budget.aggregates.allocation import ( + AllocationCannotVoidError, + AllocationNotFoundError, + AllocationStatus, + AllocationVoided, + InvalidAllocationReasonError, +) +from cora.budget.features.void_allocation.command import VoidAllocation +from cora.budget.features.void_allocation.decider import decide +from tests._strategies import aware_datetimes, printable_ascii_text +from tests.unit.budget._helpers import make_allocation + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +_VOIDABLE_SOURCES = (AllocationStatus.GRANTED, AllocationStatus.ACTIVE) +_DISALLOWED_SOURCES = tuple(s for s in AllocationStatus if s not in frozenset(_VOIDABLE_SOURCES)) + +_valid_reasons = printable_ascii_text(max_size=500) +_whitespace_only_reasons = st.text(alphabet=" \t\n", max_size=8) + + +@pytest.mark.unit +@given(allocation_id=st.uuids(), reason=_valid_reasons, now=aware_datetimes()) +def test_void_with_none_state_always_raises_not_found( + allocation_id: UUID, + reason: str, + now: datetime, +) -> None: + """Empty stream always raises not-found carrying command.allocation_id.""" + with pytest.raises(AllocationNotFoundError) as exc: + decide( + state=None, + command=VoidAllocation(allocation_id=allocation_id, reason=reason), + now=now, + ) + assert exc.value.allocation_id == allocation_id + + +@pytest.mark.unit +@given( + allocation_id=st.uuids(), + source=st.sampled_from(_VOIDABLE_SOURCES), + reason=_valid_reasons, + now=aware_datetimes(), +) +def test_void_from_voidable_source_emits_single_event( + allocation_id: UUID, + source: AllocationStatus, + reason: str, + now: datetime, +) -> None: + """Granted and Active both void; the event carries the trimmed reason.""" + events = decide( + state=make_allocation(source, allocation_id=allocation_id), + command=VoidAllocation(allocation_id=allocation_id, reason=reason), + now=now, + ) + assert events == [ + AllocationVoided(allocation_id=allocation_id, reason=reason.strip(), occurred_at=now) + ] + + +@pytest.mark.unit +@given( + allocation_id=st.uuids(), + source=st.sampled_from(_DISALLOWED_SOURCES), + reason=_valid_reasons, + now=aware_datetimes(), +) +def test_void_from_terminal_source_always_raises_cannot_void( + allocation_id: UUID, + source: AllocationStatus, + reason: str, + now: datetime, +) -> None: + """Any terminal source raises, carrying the current status.""" + with pytest.raises(AllocationCannotVoidError) as exc: + decide( + state=make_allocation(source, allocation_id=allocation_id), + command=VoidAllocation(allocation_id=allocation_id, reason=reason), + now=now, + ) + assert exc.value.current_status is source + + +@pytest.mark.unit +@given( + source=st.sampled_from(_VOIDABLE_SOURCES), + reason=_whitespace_only_reasons, + now=aware_datetimes(), +) +def test_void_whitespace_only_reason_always_raises( + source: AllocationStatus, + reason: str, + now: datetime, +) -> None: + """The reason is REQUIRED: anything that trims to empty refuses.""" + envelope = make_allocation(source) + with pytest.raises(InvalidAllocationReasonError): + decide( + state=envelope, + command=VoidAllocation(allocation_id=envelope.id, reason=reason), + now=now, + ) + + +@pytest.mark.unit +@given(state_id=st.uuids(), command_id=st.uuids(), now=aware_datetimes()) +def test_void_uses_state_id_not_command_allocation_id( + state_id: UUID, + command_id: UUID, + now: datetime, +) -> None: + """The emitted event's allocation_id is state.id, not command's.""" + assume(state_id != command_id) + events = decide( + state=make_allocation(AllocationStatus.GRANTED, allocation_id=state_id), + command=VoidAllocation(allocation_id=command_id, reason="Wrong beamline"), + now=now, + ) + assert events[0].allocation_id == state_id + + +@pytest.mark.unit +@given(reason=_valid_reasons, now=aware_datetimes()) +def test_void_is_pure_same_input_same_output(reason: str, now: datetime) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + envelope = make_allocation(AllocationStatus.ACTIVE) + command = VoidAllocation(allocation_id=envelope.id, reason=reason) + first = decide(state=envelope, command=command, now=now) + second = decide(state=envelope, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/budget/test_void_allocation_handler.py b/apps/api/tests/unit/budget/test_void_allocation_handler.py new file mode 100644 index 00000000000..a6339e7dbba --- /dev/null +++ b/apps/api/tests/unit/budget/test_void_allocation_handler.py @@ -0,0 +1,126 @@ +"""Application-handler tests for the `void_allocation` slice.""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.budget.aggregates.allocation import ( + AllocationCannotVoidError, + AllocationNotFoundError, + AllocationVoided, +) +from cora.budget.errors import UnauthorizedError +from cora.budget.features import void_allocation +from cora.budget.features.void_allocation import VoidAllocation +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared +from tests.unit.budget._helpers import ( + ACTIVATED_AT, + granted_event, + seed_allocation_events, +) + +_NOW = datetime(2026, 7, 12, 12, 0, 0, tzinfo=UTC) +_ALLOCATION_ID = UUID("01900000-0000-7000-8000-00000000a001") +_VOID_EVENT_ID = UUID("01900000-0000-7000-8000-00000000a002") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + return _build_deps_shared( + ids=[_VOID_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +@pytest.mark.unit +async def test_handler_voids_a_granted_allocation() -> None: + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store) + handler = void_allocation.bind(deps) + await handler( + VoidAllocation(allocation_id=_ALLOCATION_ID, reason="Granted against the wrong cycle"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 2 + assert events[-1].event_type == "AllocationVoided" + assert events[-1].payload["reason"] == "Granted against the wrong cycle" + assert events[-1].principal_id == _PRINCIPAL_ID + + +@pytest.mark.unit +async def test_handler_raises_not_found_for_unknown_allocation() -> None: + deps = _build_deps() + handler = void_allocation.bind(deps) + with pytest.raises(AllocationNotFoundError): + await handler( + VoidAllocation(allocation_id=_ALLOCATION_ID, reason="Wrong beamline"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_cannot_void_when_already_voided() -> None: + store = InMemoryEventStore() + await seed_allocation_events( + store, + _ALLOCATION_ID, + granted_event(_ALLOCATION_ID), + AllocationVoided( + allocation_id=_ALLOCATION_ID, + reason="Granted against the wrong cycle", + occurred_at=ACTIVATED_AT, + ), + ) + deps = _build_deps(event_store=store) + handler = void_allocation.bind(deps) + with pytest.raises(AllocationCannotVoidError): + await handler( + VoidAllocation(allocation_id=_ALLOCATION_ID, reason="Second withdrawal"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_denies_via_authorize_port() -> None: + deps = _build_deps(deny=True) + handler = void_allocation.bind(deps) + with pytest.raises(UnauthorizedError): + await handler( + VoidAllocation(allocation_id=_ALLOCATION_ID, reason="Wrong beamline"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_denied_does_not_write_to_stream() -> None: + """Authorize-denial MUST NOT mutate the Allocation stream.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + deps = _build_deps(event_store=store, deny=True) + handler = void_allocation.bind(deps) + with pytest.raises(UnauthorizedError): + await handler( + VoidAllocation(allocation_id=_ALLOCATION_ID, reason="Wrong beamline"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 1 + assert len(events) == 1 + assert events[0].event_type == "AllocationGranted" diff --git a/apps/api/tests/unit/deployments/test_architecture_introspect.py b/apps/api/tests/unit/deployments/test_architecture_introspect.py index 9272d505c67..8e1542b5ebd 100644 --- a/apps/api/tests/unit/deployments/test_architecture_introspect.py +++ b/apps/api/tests/unit/deployments/test_architecture_introspect.py @@ -74,12 +74,13 @@ def test_introspection_aggregates_match_filesystem() -> None: assert generated == _filesystem_aggregates() -def test_counts_are_seventeen_bcs_and_forty_two_aggregates() -> None: +def test_counts_are_eighteen_bcs_and_forty_three_aggregates() -> None: # Anti-drift pins for the model.md headline; bump deliberately on a BC/aggregate add. - # 42 aggregates: the Agent BC gained LanguageModel (the facility model catalog). + # 18 BCs / 43 aggregates: the budget BC landed with Allocation (the + # beamline's spending envelope, the allocation arc's stage A). model = ai.introspect(_CORA) - assert model.bc_count == 17 - assert model.aggregate_count == 42 + assert model.bc_count == 18 + assert model.aggregate_count == 43 def test_enclosure_bc_and_equipment_role_are_present() -> None: @@ -125,9 +126,11 @@ def test_bc_table_renders_full_membership() -> None: table = ap.render_bc_table(_MODEL, {}) assert "`enclosure`" in table # the omitted 17th BC assert "`role`" in table # the omitted equipment aggregate - assert table.count("Active") == 17 - assert table.count("Planned") == 2 + # 18 Active rows since budget shipped; strategy is the one Planned row left. + assert table.count("Active") == 18 + assert table.count("Planned") == 1 assert "`strategy`" in table and "`budget`" in table + assert "`allocation`" in table assert chr(0x2014) not in table @@ -139,9 +142,9 @@ def test_bc_table_group_map_covers_every_bc() -> None: def test_count_renderer() -> None: - assert ap.render_count(_MODEL, {"kind": "bc", "spell": "true", "cap": "true"}) == "Seventeen" - assert ap.render_count(_MODEL, {"kind": "aggregate", "spell": "true"}) == "forty-two" - assert ap.render_count(_MODEL, {"kind": "bc"}) == "17" + assert ap.render_count(_MODEL, {"kind": "bc", "spell": "true", "cap": "true"}) == "Eighteen" + assert ap.render_count(_MODEL, {"kind": "aggregate", "spell": "true"}) == "forty-three" + assert ap.render_count(_MODEL, {"kind": "bc"}) == "18" assert ap.render_count(_MODEL, {"kind": "event", "bc": "decision"}) == "4" assert ap.render_count(_MODEL, {"kind": "slice", "bc": "equipment"}) == "60" @@ -168,7 +171,7 @@ def test_bc_aggregates_renderer() -> None: def test_expand_markers_idempotent() -> None: md = "lead X tail" out = ap.expand_markers(md, model=_MODEL, src_uri="architecture/model.md") - assert "Seventeen" in out + assert "Eighteen" in out assert out.startswith("lead tail") # re-expanding a generated page is stable assert ap.expand_markers(out, model=_MODEL, src_uri="architecture/model.md") == out diff --git a/scripts/architecture_pages.py b/scripts/architecture_pages.py index 866814a19f7..513dd403e09 100644 --- a/scripts/architecture_pages.py +++ b/scripts/architecture_pages.py @@ -43,6 +43,7 @@ ("Procedure", "campaign"), ("Resource", "supply"), ("Resource", "operation"), + ("Resource", "budget"), ("Authority", "trust"), ("Authority", "safety"), ("Authority", "federation"), @@ -56,9 +57,10 @@ ) # Reserved BCs scoped but not implemented (no code, so authored here). +# budget left this tuple when the Allocation aggregate shipped; it now +# renders as an Active row from the introspected code model. _PLANNED_ROWS: tuple[tuple[str, str, str], ...] = ( ("Governance", "strategy", "strategy"), - ("Resource", "budget", "budget"), ) _INLINE_KINDS = frozenset({"count", "bc-aggregates"}) diff --git a/scripts/scenarios_meta.py b/scripts/scenarios_meta.py index 81f40587360..9ab21986a3c 100644 --- a/scripts/scenarios_meta.py +++ b/scripts/scenarios_meta.py @@ -38,7 +38,7 @@ } ) -# All 17 BCs that exist in CORA's codebase today. New BCs are added +# All 18 BCs that exist in CORA's codebase today. New BCs are added # at ship time, not when first scenario covers them, so a zero-count # BC on the registry page is a visible signal of a coverage gap. BOUNDED_CONTEXTS: frozenset[str] = frozenset( @@ -60,6 +60,7 @@ "Agent", "Calibration", "Enclosure", + "Budget", } ) From 518db535990ce1a7260923876f3c2c40bc005ad8 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:17:33 +0300 Subject: [PATCH 2/3] Fold the envelope into every tier and seal it with the campaign Stages B and C of the allocation arc: the Allocation stops being a declaration and becomes the instrument-wide control the paper claims. Read side: proj_budget_allocation_summary + the AllocationLookup port (no-envelope-declared means no-envelope-constraint, the family's opt-in posture) with the budget BC's Postgres adapter picking the single Active envelope, newest-activated wins. SpendLookup gains find_total_spend, summing every costed row regardless of attribution: the envelope bounds the whole instrument's LLM spend, agent and operator paths alike, which is the paper's one-balance claim. Gates: find_envelope_breach carries both refusal arms, the pre-estimate projection (spent plus pending exceeds ceiling) and the post-hoc exhaustion (spent has reached ceiling), documented side by side. Both subscriber agents defer with AllocationExhausted after their per-agent cap check; the pre-estimate guard gains the envelope arm and now gates even agents with no stream or declared budget, because the envelope is the instrument's bound, not the agent's. The seal slice's reader seam swaps from the stage-A zero reader to the ledger fold, exactly the one-line change the seam promised. The books close themselves: a CampaignClosed subscriber seals the Active allocation bound to that campaign, stamping the final spend from the same ledger fold, authored as SYSTEM via the same-BC decider (the enclosure permit monitor precedent; the wired handler's authorize gate would rightly deny a system principal under real Trust). Deterministic event id and expected-version guard make replay a no-op; mismatched, unbound, or non-Active allocations skip with a log. Co-Authored-By: Claude Fable 5 --- apps/api/src/cora/agent/_budget_gate.py | 85 +++++ .../cora/agent/adapters/budget_spend_guard.py | 82 ++++- .../cora/agent/subscribers/caution_drafter.py | 45 ++- .../cora/agent/subscribers/run_debriefer.py | 48 ++- apps/api/src/cora/api/main.py | 20 +- apps/api/src/cora/budget/__init__.py | 7 + apps/api/src/cora/budget/_projections.py | 23 ++ apps/api/src/cora/budget/_subscribers.py | 41 +++ apps/api/src/cora/budget/adapters/__init__.py | 10 + .../adapters/postgres_allocation_lookup.py | 51 +++ .../features/seal_allocation/__init__.py | 9 +- .../features/seal_allocation/handler.py | 52 ++- .../src/cora/budget/projections/__init__.py | 10 + .../src/cora/budget/projections/allocation.py | 126 ++++++++ .../src/cora/budget/subscribers/__init__.py | 15 + .../budget/subscribers/allocation_sealer.py | 234 ++++++++++++++ apps/api/src/cora/budget/wire.py | 18 +- .../adapters/postgres_spend_lookup.py | 35 +- apps/api/src/cora/infrastructure/deps.py | 50 +++ apps/api/src/cora/infrastructure/kernel.py | 14 + .../src/cora/infrastructure/ports/__init__.py | 10 + .../infrastructure/ports/allocation_lookup.py | 89 ++++++ .../cora/infrastructure/ports/spend_lookup.py | 49 ++- apps/api/tach.toml | 7 +- .../test_allocation_lookup_postgres.py | 114 +++++++ .../integration/test_spend_lookup_postgres.py | 74 ++++- apps/api/tests/unit/_helpers.py | 9 + apps/api/tests/unit/agent/_helpers.py | 48 ++- apps/api/tests/unit/agent/test_budget_gate.py | 128 +++++++- .../unit/agent/test_budget_spend_guard.py | 144 ++++++++- .../agent/test_caution_drafter_subscriber.py | 58 ++++ .../agent/test_run_debriefer_subscriber.py | 60 ++++ apps/api/tests/unit/budget/_helpers.py | 11 +- .../test_allocation_sealer_subscriber.py | 300 ++++++++++++++++++ .../test_allocation_summary_projection.py | 221 +++++++++++++ ...00_init_proj_budget_allocation_summary.sql | 41 +++ infra/atlas/migrations/atlas.sum | 3 +- 37 files changed, 2283 insertions(+), 58 deletions(-) create mode 100644 apps/api/src/cora/budget/_projections.py create mode 100644 apps/api/src/cora/budget/_subscribers.py create mode 100644 apps/api/src/cora/budget/adapters/__init__.py create mode 100644 apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py create mode 100644 apps/api/src/cora/budget/projections/__init__.py create mode 100644 apps/api/src/cora/budget/projections/allocation.py create mode 100644 apps/api/src/cora/budget/subscribers/__init__.py create mode 100644 apps/api/src/cora/budget/subscribers/allocation_sealer.py create mode 100644 apps/api/src/cora/infrastructure/ports/allocation_lookup.py create mode 100644 apps/api/tests/integration/test_allocation_lookup_postgres.py create mode 100644 apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py create mode 100644 apps/api/tests/unit/budget/test_allocation_summary_projection.py create mode 100644 infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql diff --git a/apps/api/src/cora/agent/_budget_gate.py b/apps/api/src/cora/agent/_budget_gate.py index 25e6e342f82..4abac208d67 100644 --- a/apps/api/src/cora/agent/_budget_gate.py +++ b/apps/api/src/cora/agent/_budget_gate.py @@ -18,6 +18,12 @@ item gates identically (the non-determinism rule: subscribers decide from event facts, not ambient time). +`find_envelope_breach` is the instrument-wide arm above the per-agent +caps: when the deployment has an Active Allocation envelope (budget +BC), instance-total spend over the envelope's own lifecycle window +must stay inside its ceiling. Absent or sealed envelope means no +constraint (opt-in, like cap declaration). + ## Failure direction The spend sums undercount (NULL-cost legacy rows, unrecorded cache @@ -36,9 +42,11 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING +from uuid import UUID if TYPE_CHECKING: from cora.agent.aggregates.agent import Agent + from cora.infrastructure.ports.allocation_lookup import AllocationLookup from cora.infrastructure.ports.spend_lookup import SpendLookup @@ -132,9 +140,86 @@ async def find_budget_breach( return None +@dataclass(frozen=True) +class EnvelopeBreach: + """The Active allocation envelope cannot afford the next call.""" + + allocation_id: UUID + ceiling_usd: float + spent_usd: float + window_start: datetime + + def describe(self) -> str: + """One human-readable sentence for a deferred Decision's reasoning.""" + return ( + f"allocation {self.allocation_id} ceiling of {self.ceiling_usd:g} USD " + f"reached (instance-total spend {self.spent_usd:g} since " + f"{self.window_start.isoformat()})" + ) + + +async def find_envelope_breach( + *, + allocation_lookup: "AllocationLookup", + spend_lookup: "SpendLookup", + as_of: datetime, + pending_usd: float = 0.0, +) -> EnvelopeBreach | None: + """Return the Active envelope's breach, or None to permit. + + The instrument-wide arm above the per-agent caps: with an Active + allocation, instance-total spend is summed over the envelope's + own window `[activated_at, as_of)` (the lifecycle IS the window; + no calendar arithmetic) across ALL costed rows, agent-attributed + and operator-attributed alike. No Active envelope means no + constraint: declaring and activating one is what arms this check, + so every envelope-less deployment and test stays permissive. + + Two refusal arms because the two caller tiers ask different + questions, mirroring the per-agent convention split between + `find_budget_breach` (post-hoc, `>=`) and `BudgetSpendGuard` + (pre-estimate, strict `>`): + + - `spent + pending > ceiling`: the pre-estimate arm. Callers + pass `pending_usd` = the next call's estimated ceiling (plus + any in-conduct actuals they carry), and a projection landing + exactly ON the ceiling is the last call the envelope affords, + so only strictly-over refuses. + - `pending == 0 and spent >= ceiling`: the post-hoc arm. + Post-hoc callers pass no pending figure, so "exactly + exhausted" must refuse the NEXT call (`>` alone would let a + balance sitting exactly at the ceiling keep spending forever). + + `as_of` is the triggering event's `occurred_at`, not wall clock, + per the subscribers' determinism rule; a replayed event whose + `occurred_at` predates `activated_at` sums an empty window and + permits. A lookup ERROR propagates (fail closed), same stance as + the per-agent gate. + """ + envelope = await allocation_lookup.find_active() + if envelope is None: + return None + spend = await spend_lookup.find_total_spend( + window_start=envelope.activated_at, + window_end=as_of, + ) + projected_over = spend.usd_spent + pending_usd > envelope.ceiling_usd + exhausted_post_hoc = pending_usd == 0.0 and spend.usd_spent >= envelope.ceiling_usd + if projected_over or exhausted_post_hoc: + return EnvelopeBreach( + allocation_id=envelope.allocation_id, + ceiling_usd=envelope.ceiling_usd, + spent_usd=spend.usd_spent, + window_start=envelope.activated_at, + ) + return None + + __all__ = [ "BudgetBreach", + "EnvelopeBreach", "calendar_day_window", "calendar_month_window", "find_budget_breach", + "find_envelope_breach", ] diff --git a/apps/api/src/cora/agent/adapters/budget_spend_guard.py b/apps/api/src/cora/agent/adapters/budget_spend_guard.py index 8f5a7f666cf..03b25fa1f1a 100644 --- a/apps/api/src/cora/agent/adapters/budget_spend_guard.py +++ b/apps/api/src/cora/agent/adapters/budget_spend_guard.py @@ -9,17 +9,25 @@ ## Refusal policy - - Agent stream missing: permit. Declaration is opt-in; absence of an - Agent aggregate must never block (mirrors `find_budget_breach`). + - Agent stream missing: the per-agent checks permit. Declaration is + opt-in; absence of an Agent aggregate must never block (mirrors + `find_budget_breach`). The envelope check below still runs. - Agent not Versioned: refuse. Suspend means stop on every path, and the pre-call check is the earliest place the steering brain can be stopped (the post-hoc record path only notices AFTER spend). - - No declared budget: permit. + - No declared budget: the per-agent checks permit. - Projected breach: refuse when recorded spend plus the estimated ceiling would exceed a cap (strictly greater: a call that lands exactly on the cap is the last one the envelope affords). Monthly USD is checked first, then daily tokens, one lookup per declared cap, matching the post-hoc gate's order. + - Envelope breach: after the per-agent caps, the instrument-wide + allocation envelope is consulted with `pending = estimated_cost_usd` + (the caller already folds its in-conduct actuals into that + estimate). Instance-total spend plus the estimate strictly over + the Active ceiling refuses. Runs even when the agent has no + declared budget or no Agent stream: the envelope is armed by + declaring an allocation, not by per-agent ceremony. A lookup ERROR propagates, per the port's fail-closed stance. """ @@ -28,13 +36,20 @@ from typing import TYPE_CHECKING -from cora.agent._budget_gate import calendar_day_window, calendar_month_window +from cora.agent._budget_gate import ( + calendar_day_window, + calendar_month_window, + find_envelope_breach, +) from cora.agent.aggregates.agent import AgentStatus, load_agent +from cora.infrastructure.ports import NoActiveAllocationLookup if TYPE_CHECKING: from datetime import datetime from uuid import UUID + from cora.agent.aggregates.agent import AgentBudget + from cora.infrastructure.ports.allocation_lookup import AllocationLookup from cora.infrastructure.ports.event_store import EventStore from cora.infrastructure.ports.spend_lookup import SpendLookup @@ -42,9 +57,18 @@ class BudgetSpendGuard: """`SpendGuard` over the Agent aggregate's caps and the spend ledger.""" - def __init__(self, *, event_store: EventStore, spend_lookup: SpendLookup) -> None: + def __init__( + self, + *, + event_store: EventStore, + spend_lookup: SpendLookup, + allocation_lookup: AllocationLookup | None = None, + ) -> None: self._event_store = event_store self._spend_lookup = spend_lookup + # Defaults to no Active envelope so direct test construction stays + # cap-only; production wiring passes the Kernel's allocation_lookup. + self._allocation_lookup = allocation_lookup or NoActiveAllocationLookup() async def refusal_reason( self, @@ -55,13 +79,49 @@ async def refusal_reason( as_of: datetime, ) -> str | None: agent = await load_agent(self._event_store, agent_id) - if agent is None: - return None - if agent.status is not AgentStatus.VERSIONED: - return f"agent is {agent.status.value}, not Versioned; calls are stopped" - if agent.budget is None: + if agent is not None: + if agent.status is not AgentStatus.VERSIONED: + return f"agent is {agent.status.value}, not Versioned; calls are stopped" + per_agent_reason = await self._per_agent_cap_refusal( + agent_budget=agent.budget, + agent_id=agent_id, + estimated_cost_usd=estimated_cost_usd, + estimated_tokens=estimated_tokens, + as_of=as_of, + ) + if per_agent_reason is not None: + return per_agent_reason + + envelope_breach = await find_envelope_breach( + allocation_lookup=self._allocation_lookup, + spend_lookup=self._spend_lookup, + as_of=as_of, + pending_usd=estimated_cost_usd, + ) + if envelope_breach is not None: + return ( + f"allocation envelope {envelope_breach.allocation_id} ceiling of " + f"{envelope_breach.ceiling_usd:g} USD would be breached: " + f"{envelope_breach.spent_usd:g} instance-total spend since " + f"{envelope_breach.window_start.isoformat()} plus an estimated " + f"{estimated_cost_usd:g} ceiling for this call" + ) + + return None + + async def _per_agent_cap_refusal( + self, + *, + agent_budget: AgentBudget | None, + agent_id: UUID, + estimated_cost_usd: float, + estimated_tokens: int, + as_of: datetime, + ) -> str | None: + """Check the declared per-agent caps (monthly USD, then daily tokens).""" + if agent_budget is None: return None - budget = agent.budget + budget = agent_budget if budget.monthly_usd_cap is not None: window_start, window_end = calendar_month_window(as_of) diff --git a/apps/api/src/cora/agent/subscribers/caution_drafter.py b/apps/api/src/cora/agent/subscribers/caution_drafter.py index 2ef39c51fea..bd3f7629749 100644 --- a/apps/api/src/cora/agent/subscribers/caution_drafter.py +++ b/apps/api/src/cora/agent/subscribers/caution_drafter.py @@ -80,7 +80,7 @@ from uuid import UUID, uuid5 from cora.access.aggregates.actor import load_actor -from cora.agent._budget_gate import find_budget_breach +from cora.agent._budget_gate import find_budget_breach, find_envelope_breach from cora.agent._subscriber_lease import attempt_debrief_lease from cora.agent.aggregates.agent import AgentStatus, load_agent from cora.agent.prompts import ( @@ -121,6 +121,7 @@ AgentInferenceTrace, AlwaysZeroSpendLookup, ConcurrencyError, + NoActiveAllocationLookup, NullInferenceRecorder, ) from cora.infrastructure.signing import SIGNED_EVENT_TYPES @@ -133,6 +134,7 @@ from cora.infrastructure.kernel import Kernel from cora.infrastructure.ports import ( LLM, + AllocationLookup, CautionLookup, InferenceRecorder, LLMChatRequest, @@ -205,6 +207,7 @@ def __init__( signer: Signer | None = None, inference_recorder: InferenceRecorder | None = None, spend_lookup: SpendLookup | None = None, + allocation_lookup: AllocationLookup | None = None, ) -> None: self.event_store = event_store self.llm = llm @@ -218,6 +221,9 @@ def __init__( # don't exercise budget gating; production wiring passes the # Kernel's PostgresSpendLookup. self.spend_lookup = spend_lookup or AlwaysZeroSpendLookup() + # Defaults to no Active envelope so the instrument-wide allocation + # check stays disarmed unless a test (or production wiring) arms it. + self.allocation_lookup = allocation_lookup or NoActiveAllocationLookup() async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: """Process one terminal Run event.""" @@ -359,6 +365,42 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: ) return + # Instrument-wide envelope gate (mirrors RunDebriefer): an + # exhausted Active allocation stops every LLM caller regardless + # of this agent's own headroom (post-hoc arm, pending 0). + envelope_breach = await find_envelope_breach( + allocation_lookup=self.allocation_lookup, + spend_lookup=self.spend_lookup, + as_of=event.occurred_at, + ) + if envelope_breach is not None: + log.warning( + "caution_drafter.allocation_exhausted", + allocation_id=str(envelope_breach.allocation_id), + ceiling_usd=envelope_breach.ceiling_usd, + spent_usd=envelope_breach.spent_usd, + ) + await self._compose_and_append( + decision_id=decision_id, + actor=actor, + run_id=run_id, + terminal_event=event, + choice="NoAction", + confidence=None, + reasoning=( + f"Allocation exhausted: {envelope_breach.describe()}; LLM " + "call skipped. Raise the ceiling via " + "amend_allocation_ceiling or grant a new envelope." + ), + extra_inputs={ + "failure_error_class": "AllocationExhausted", + "allocation_id": str(envelope_breach.allocation_id), + }, + outcome="deferred", + log=log, + ) + return + # Build candidate-target list from Plan.asset_ids. v1 doesn't # include Procedures (Run aggregate doesn't yet carry a # procedure binding); per design memo, watch item for when @@ -867,6 +909,7 @@ def make_caution_drafter_subscriber(deps: Kernel) -> CautionDrafterSubscriber: signer=deps.signer, inference_recorder=deps.inference_recorder, spend_lookup=deps.spend_lookup, + allocation_lookup=deps.allocation_lookup, ) diff --git a/apps/api/src/cora/agent/subscribers/run_debriefer.py b/apps/api/src/cora/agent/subscribers/run_debriefer.py index e0beb4e31ae..15d6c92c669 100644 --- a/apps/api/src/cora/agent/subscribers/run_debriefer.py +++ b/apps/api/src/cora/agent/subscribers/run_debriefer.py @@ -139,7 +139,7 @@ from uuid import UUID, uuid5 from cora.access.aggregates.actor import load_actor -from cora.agent._budget_gate import find_budget_breach +from cora.agent._budget_gate import find_budget_breach, find_envelope_breach from cora.agent._subscriber_lease import attempt_debrief_lease from cora.agent.aggregates.agent import AgentStatus, load_agent from cora.agent.prompts import ( @@ -177,6 +177,7 @@ AgentInferenceTrace, AlwaysZeroSpendLookup, ConcurrencyError, + NoActiveAllocationLookup, NullInferenceRecorder, ) from cora.infrastructure.signing import SIGNED_EVENT_TYPES @@ -188,6 +189,7 @@ from cora.infrastructure.kernel import Kernel from cora.infrastructure.ports import ( LLM, + AllocationLookup, InferenceRecorder, LLMChatRequest, LLMResponse, @@ -299,6 +301,7 @@ def __init__( signer: Signer | None = None, inference_recorder: InferenceRecorder | None = None, spend_lookup: SpendLookup | None = None, + allocation_lookup: AllocationLookup | None = None, ) -> None: self.event_store = event_store self.llm = llm @@ -312,6 +315,9 @@ def __init__( # don't exercise budget gating; production wiring passes the # Kernel's PostgresSpendLookup. self.spend_lookup = spend_lookup or AlwaysZeroSpendLookup() + # Defaults to no Active envelope so the instrument-wide allocation + # check stays disarmed unless a test (or production wiring) arms it. + self.allocation_lookup = allocation_lookup or NoActiveAllocationLookup() async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: """Process one terminal Run event. @@ -471,6 +477,45 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: ) return + # Instrument-wide envelope gate, above the per-agent caps: an + # exhausted Active allocation stops every LLM caller regardless + # of that agent's own headroom (post-hoc arm, pending 0). Same + # deferral composition as the cap breach so the one-Decision- + # per-terminal-Run invariant holds and operators see WHY. + envelope_breach = await find_envelope_breach( + allocation_lookup=self.allocation_lookup, + spend_lookup=self.spend_lookup, + as_of=event.occurred_at, + ) + if envelope_breach is not None: + log.warning( + "run_debriefer.allocation_exhausted", + allocation_id=str(envelope_breach.allocation_id), + ceiling_usd=envelope_breach.ceiling_usd, + spent_usd=envelope_breach.spent_usd, + ) + await self._compose_and_append( + decision_id=decision_id, + actor=actor, + run_id=run_id, + terminal_event=event, + choice="DebriefDeferred", + confidence=None, + reasoning=( + f"Allocation exhausted: {envelope_breach.describe()}; LLM " + "call skipped. Raise the ceiling via " + "amend_allocation_ceiling or grant a new envelope, then " + "re-trigger the debrief." + ), + extra_inputs={ + "failure_error_class": "AllocationExhausted", + "allocation_id": str(envelope_breach.allocation_id), + }, + outcome="deferred", + log=log, + ) + return + payload = RunDebriefPayload( terminal_event_type=event.event_type, terminal_event_reason=terminal_event_reason, @@ -873,6 +918,7 @@ def make_run_debriefer_subscriber(deps: Kernel) -> RunDebrieferSubscriber: signer=deps.signer, inference_recorder=deps.inference_recorder, spend_lookup=deps.spend_lookup, + allocation_lookup=deps.allocation_lookup, ) diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 2a7aa87cdb8..1bc8072a825 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -88,10 +88,13 @@ from cora.api.protected_resource_metadata import register_protected_resource_metadata_route from cora.budget import ( BudgetHandlers, + register_budget_projections, register_budget_routes, + register_budget_subscribers, register_budget_tools, wire_budget, ) +from cora.budget.adapters import PostgresAllocationLookup from cora.calibration import ( CalibrationHandlers, register_calibration_projections, @@ -643,6 +646,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # stubs, matching the ports' opt-in posture. language_model_lookup_factory=PostgresLanguageModelLookup, model_usage_lookup_factory=PostgresModelUsageLookup, + # Active-envelope lookup for the allocation gate stack and + # the CampaignClosed sealer; same opt-in posture (test mode + # keeps the never-Active stub). + allocation_lookup_factory=PostgresAllocationLookup, run_actor_involvement_lookup_factory=PostgresRunActorInvolvementLookup, consequence_lookup_factory=PostgresConsequenceLookup, dataset_distribution_lookup_factory=PostgresDatasetDistributionLookup, @@ -714,7 +721,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: object.__setattr__( deps, "spend_guard", - BudgetSpendGuard(event_store=deps.event_store, spend_lookup=deps.spend_lookup), + BudgetSpendGuard( + event_store=deps.event_store, + spend_lookup=deps.spend_lookup, + allocation_lookup=deps.allocation_lookup, + ), ) app.state.supply = wire_supply(deps) app.state.enclosure = wire_enclosure(deps) @@ -836,6 +847,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: register_caution_projections(registry, deps) register_calibration_projections(registry, deps) register_campaign_projections(registry, deps) + # Budget BC's projection (proj_budget_allocation_summary): + # the read model the envelope gate's AllocationLookup and + # the CampaignClosed sealer answer from. + register_budget_projections(registry, deps) # Agent BC's projection (proj_agent_summary). Path C # lock — state-side lifecycle timestamps live on the # projection. @@ -844,6 +859,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # (RunDebriefer). Conditional: only registered when # `kernel.llm` is wired (ANTHROPIC_API_KEY configured). register_agent_subscribers(registry, deps) + # budget BC's deterministic CampaignClosed -> seal reaction; + # unconditional (bookkeeping must not depend on an LLM key). + register_budget_subscribers(registry, deps) app.state.projections = registry # seed the AuthorityRevocationHolder Agent record FIRST: the diff --git a/apps/api/src/cora/budget/__init__.py b/apps/api/src/cora/budget/__init__.py index eb248c4df04..0626eb790d5 100644 --- a/apps/api/src/cora/budget/__init__.py +++ b/apps/api/src/cora/budget/__init__.py @@ -25,11 +25,16 @@ Layout: aggregates// -- aggregate state, events union, evolver, read features/_/ -- vertical slice: command + decider + handler + route + tool + adapters/ -- PostgresAllocationLookup (the AllocationLookup port) + projections/ -- AllocationSummaryProjection (read model writer) + subscribers/ -- AllocationSealerSubscriber (CampaignClosed -> seal) wire.py -- BudgetHandlers bundle + wire_budget(deps) routes.py -- register_budget_routes(app) tools.py -- register_budget_tools(mcp, get_handlers=...) """ +from cora.budget._projections import register_budget_projections +from cora.budget._subscribers import register_budget_subscribers from cora.budget.errors import UnauthorizedError from cora.budget.routes import register_budget_routes from cora.budget.tools import register_budget_tools @@ -38,7 +43,9 @@ __all__ = [ "BudgetHandlers", "UnauthorizedError", + "register_budget_projections", "register_budget_routes", + "register_budget_subscribers", "register_budget_tools", "wire_budget", ] diff --git a/apps/api/src/cora/budget/_projections.py b/apps/api/src/cora/budget/_projections.py new file mode 100644 index 00000000000..10fbb1cfe2d --- /dev/null +++ b/apps/api/src/cora/budget/_projections.py @@ -0,0 +1,23 @@ +"""Budget BC's projection-registration entry point. + +The composition root (`cora.api.main`) calls +`register_budget_projections(registry, deps)` during the FastAPI +lifespan to populate the worker's registry, mirroring the per-BC +`register__projections` convention. +""" + +from cora.budget.projections import AllocationSummaryProjection +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.projection import ProjectionRegistry + + +def register_budget_projections( + registry: ProjectionRegistry, + deps: Kernel | None = None, +) -> None: + """Register every budget-owned projection on the worker registry.""" + _ = deps + registry.register(AllocationSummaryProjection()) + + +__all__ = ["register_budget_projections"] diff --git a/apps/api/src/cora/budget/_subscribers.py b/apps/api/src/cora/budget/_subscribers.py new file mode 100644 index 00000000000..ec883b279ba --- /dev/null +++ b/apps/api/src/cora/budget/_subscribers.py @@ -0,0 +1,41 @@ +"""Budget BC subscriber registration for the projection worker. + +The registration helper (`register_budget_subscribers(registry, +deps)`), NOT the home for the subscribers themselves; those live in +`cora.budget.subscribers/`. Mirrors the split +`cora.agent._subscribers` established (the leading-underscore module +bridges the subscriber package to the worker's registry). + +One Reaction today: `AllocationSealerSubscriber`, DETERMINISTIC (no +LLM) and registered UNCONDITIONALLY, because the campaign-bound seal +is bookkeeping that must not depend on whether an LLM is configured; +its per-event work is one projection read plus (rarely) one ledger +SUM and one append. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cora.budget.subscribers.allocation_sealer import make_allocation_sealer_subscriber +from cora.infrastructure.logging import get_logger + +if TYPE_CHECKING: + from cora.infrastructure.kernel import Kernel + from cora.infrastructure.projection.registry import ProjectionRegistry + +_log = get_logger(__name__) + + +def register_budget_subscribers(registry: ProjectionRegistry, deps: Kernel) -> None: + """Register budget BC's subscribers into the projection-worker registry.""" + sealer = make_allocation_sealer_subscriber(deps) + registry.register(sealer) + _log.info( + "budget_subscriber.registered", + subscriber=sealer.name, + subscribed_event_types=sorted(sealer.subscribed_event_types), + ) + + +__all__ = ["register_budget_subscribers"] diff --git a/apps/api/src/cora/budget/adapters/__init__.py b/apps/api/src/cora/budget/adapters/__init__.py new file mode 100644 index 00000000000..7a433dbba4f --- /dev/null +++ b/apps/api/src/cora/budget/adapters/__init__.py @@ -0,0 +1,10 @@ +"""Budget BC adapters (production implementations of neutral ports). + +`PostgresAllocationLookup` implements +`cora.infrastructure.ports.allocation_lookup.AllocationLookup` over +`proj_budget_allocation_summary`. +""" + +from cora.budget.adapters.postgres_allocation_lookup import PostgresAllocationLookup + +__all__ = ["PostgresAllocationLookup"] diff --git a/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py b/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py new file mode 100644 index 00000000000..b9839ba3c26 --- /dev/null +++ b/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py @@ -0,0 +1,51 @@ +"""PostgresAllocationLookup: the Active-envelope lookup over the projection. + +The budget BC ships the production adapter because it owns the fact: +`proj_budget_allocation_summary` is this BC's read model of its own +aggregate. The lookup answers the GATE's question, the deployment's +single Active envelope. At-most-one-Active is enforced best-effort at +grant/activate time; should an overlap slip through that race, the +newest `activated_at` wins (the most recently opened window is the +operator's current intent) with `allocation_id DESC` making +equal-timestamp rows deterministic, per the port contract. +""" + +from __future__ import annotations + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +from typing import TYPE_CHECKING + +from cora.infrastructure.ports.allocation_lookup import ActiveAllocation + +if TYPE_CHECKING: + import asyncpg + +_FIND_ACTIVE_SQL = """ +SELECT allocation_id, ceiling_usd, activated_at, campaign_id +FROM proj_budget_allocation_summary +WHERE status = 'Active' +ORDER BY activated_at DESC, allocation_id DESC +LIMIT 1 +""" + + +class PostgresAllocationLookup: + """`AllocationLookup` over `proj_budget_allocation_summary`.""" + + def __init__(self, pool: asyncpg.Pool) -> None: + self._pool = pool + + async def find_active(self) -> ActiveAllocation | None: + async with self._pool.acquire() as conn: + row = await conn.fetchrow(_FIND_ACTIVE_SQL) + if row is None: + return None + return ActiveAllocation( + allocation_id=row["allocation_id"], + ceiling_usd=row["ceiling_usd"], + activated_at=row["activated_at"], + campaign_id=row["campaign_id"], + ) + + +__all__ = ["PostgresAllocationLookup"] diff --git a/apps/api/src/cora/budget/features/seal_allocation/__init__.py b/apps/api/src/cora/budget/features/seal_allocation/__init__.py index 1839d2194df..8e8ac532e2f 100644 --- a/apps/api/src/cora/budget/features/seal_allocation/__init__.py +++ b/apps/api/src/cora/budget/features/seal_allocation/__init__.py @@ -9,9 +9,10 @@ handler = seal_allocation.bind(deps, total_spend_reader=reader) await handler(cmd, principal_id=..., correlation_id=...) -`zero_total_spend` is the stage-A reader (`wire.py` binds it); stage -C replaces it with the SpendLookup-backed fold without touching this -slice. +`make_ledger_total_spend(spend_lookup)` builds the production reader +over `SpendLookup.find_total_spend`; `wire.py` and the CampaignClosed +sealer subscriber both bind it. `zero_total_spend` stays exported for +tests that seal without standing up a ledger. """ from cora.budget.features.seal_allocation import tool @@ -21,6 +22,7 @@ Handler, TotalSpendReader, bind, + make_ledger_total_spend, zero_total_spend, ) from cora.budget.features.seal_allocation.route import router @@ -31,6 +33,7 @@ "TotalSpendReader", "bind", "decide", + "make_ledger_total_spend", "router", "tool", "zero_total_spend", diff --git a/apps/api/src/cora/budget/features/seal_allocation/handler.py b/apps/api/src/cora/budget/features/seal_allocation/handler.py index d6a096bbb39..c0edbc28108 100644 --- a/apps/api/src/cora/budget/features/seal_allocation/handler.py +++ b/apps/api/src/cora/budget/features/seal_allocation/handler.py @@ -10,11 +10,11 @@ closing-the-books figure. `total_spend_reader` is bound at wire time, NOT resolved from Kernel: -stage A has no instance-total spend query yet, so `wire.py` binds -`zero_total_spend` (every stage-A seal records 0.0) and stage C -replaces it with the SpendLookup-backed reader when -`find_total_spend` lands. Binding the seam now means the stage-C -subscriber and the route both flow through this one slice unchanged. +`wire.py` binds `make_ledger_total_spend(deps.spend_lookup)` (the +SpendLookup-backed fold the stage-A seam promised), so the +CampaignClosed sealer subscriber and the REST route both flow through +this one slice with the same ledger answer. `zero_total_spend` +remains exported for tests that want a seal without a ledger. The reader is only consulted when the loaded envelope has an open window (`activated_at` set); on the guard paths (missing stream, @@ -28,7 +28,7 @@ """ from datetime import datetime -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from uuid import UUID from cora.budget.aggregates.allocation import ( @@ -47,6 +47,9 @@ from cora.infrastructure.routing import NIL_SENTINEL_ID from cora.shared.identity import ActorId +if TYPE_CHECKING: + from cora.infrastructure.ports import SpendLookup + _STREAM_TYPE = "Allocation" _COMMAND_NAME = "SealAllocation" @@ -58,25 +61,48 @@ class TotalSpendReader(Protocol): The window is the envelope's own lifecycle: `[window_start, window_end)` with `window_start = activated_at` and `window_end` - the seal instant. Stage C implements this over SpendLookup's - `find_total_spend`; stage A binds `zero_total_spend`. + the seal instant. Production binds `make_ledger_total_spend` + (SpendLookup's `find_total_spend`); `zero_total_spend` is the + ledger-less test reader. """ async def __call__(self, *, window_start: datetime, window_end: datetime) -> float: ... async def zero_total_spend(*, window_start: datetime, window_end: datetime) -> float: - """Stage-A placeholder reader: no instance-total spend query exists yet. + """Ledger-less reader: every seal records `spent_usd = 0.0`. - Every stage-A seal records `spent_usd = 0.0`. Honest by - construction: the figure is the reader's answer, and until stage C - wires the SpendLookup-backed reader the ledger-backed answer is - not available, not silently approximated. + Was the stage-A wire binding before `find_total_spend` existed; + kept exported for tests that exercise the seal FSM without + standing up an inference ledger (the figure is honestly the + reader's answer, and a zero reader states that intent loudly). + Production wiring binds `make_ledger_total_spend` instead. """ _ = (window_start, window_end) return 0.0 +def make_ledger_total_spend(spend_lookup: "SpendLookup") -> TotalSpendReader: + """Build the production reader over `SpendLookup.find_total_spend`. + + One closure instead of a class because the seal snapshot needs + exactly the USD figure; the echoed window and call count on + `TotalSpendResult` stay available to the lookup's other consumers + (the envelope gate). Shared by `wire_budget` (route + MCP path) + and the CampaignClosed sealer subscriber so every seal records + the same ledger fold. + """ + + async def reader(*, window_start: datetime, window_end: datetime) -> float: + result = await spend_lookup.find_total_spend( + window_start=window_start, + window_end=window_end, + ) + return result.usd_spent + + return reader + + class Handler(Protocol): """Callable interface every seal_allocation handler implements.""" diff --git a/apps/api/src/cora/budget/projections/__init__.py b/apps/api/src/cora/budget/projections/__init__.py new file mode 100644 index 00000000000..2e497a26eaf --- /dev/null +++ b/apps/api/src/cora/budget/projections/__init__.py @@ -0,0 +1,10 @@ +"""Budget BC read-side projections. + +One projection today: `AllocationSummaryProjection` maintains +`proj_budget_allocation_summary`, the by-status read model the +`PostgresAllocationLookup` adapter answers the envelope gate from. +""" + +from cora.budget.projections.allocation import AllocationSummaryProjection + +__all__ = ["AllocationSummaryProjection"] diff --git a/apps/api/src/cora/budget/projections/allocation.py b/apps/api/src/cora/budget/projections/allocation.py new file mode 100644 index 00000000000..f32fda40360 --- /dev/null +++ b/apps/api/src/cora/budget/projections/allocation.py @@ -0,0 +1,126 @@ +"""AllocationSummaryProjection: folds the envelope's lifecycle into +the `proj_budget_allocation_summary` read model. + +The envelope's one production query shape is by status, not by id: +`PostgresAllocationLookup.find_active` asks "which envelope is Active +right now, and what are its ceiling, window start, and campaign +binding?" That question fires on every gated LLM call and on every +CampaignClosed delivery, so it cannot be a stream scan; the +projection carries exactly the columns the lookup selects plus the +lifecycle timestamps an operator surface would list. + +Balance is deliberately NOT projected: the envelope's spend folds +from `entries_decision_inferences` at read time (the design's +never-stored-balance stance), and only the seal freezes a snapshot +(`spent_usd_at_seal`). `end_reason` stays on the aggregate for the +same reason `cost_basis` stays off the language-model projection: +no consumer reads it from the read model. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import datetime +from uuid import UUID + +from cora.infrastructure.ports.event_store import StoredEvent +from cora.infrastructure.projection.handler import ConnectionLike + +_INSERT_SQL = """ +INSERT INTO proj_budget_allocation_summary + (allocation_id, ceiling_usd, campaign_id, holder_note, status, + granted_at, created_at) +VALUES ($1, $2, $3, $4, 'Granted', $5, $5) +ON CONFLICT (allocation_id) DO NOTHING +""" + +_UPDATE_ACTIVATED_SQL = """ +UPDATE proj_budget_allocation_summary +SET status = 'Active', + activated_at = $2, + updated_at = now() +WHERE allocation_id = $1 +""" + +_UPDATE_CEILING_AMENDED_SQL = """ +UPDATE proj_budget_allocation_summary +SET ceiling_usd = $2, + updated_at = now() +WHERE allocation_id = $1 +""" + +_UPDATE_SEALED_SQL = """ +UPDATE proj_budget_allocation_summary +SET status = 'Sealed', + sealed_at = $2, + spent_usd_at_seal = $3, + updated_at = now() +WHERE allocation_id = $1 +""" + +_UPDATE_VOIDED_SQL = """ +UPDATE proj_budget_allocation_summary +SET status = 'Voided', + updated_at = now() +WHERE allocation_id = $1 +""" + + +class AllocationSummaryProjection: + """Maintains the `proj_budget_allocation_summary` read model.""" + + name = "proj_budget_allocation_summary" + subscribed_event_types = frozenset( + { + "AllocationGranted", + "AllocationActivated", + "AllocationCeilingAmended", + "AllocationSealed", + "AllocationVoided", + } + ) + + async def apply( + self, + event: StoredEvent, + conn: ConnectionLike, + ) -> None: + match event.event_type: + case "AllocationGranted": + campaign_id_raw = event.payload.get("campaign_id") + await conn.execute( + _INSERT_SQL, + UUID(event.payload["allocation_id"]), + event.payload["ceiling_usd"], + (UUID(campaign_id_raw) if campaign_id_raw is not None else None), + event.payload["holder_note"], + datetime.fromisoformat(event.payload["occurred_at"]), + ) + case "AllocationActivated": + await conn.execute( + _UPDATE_ACTIVATED_SQL, + UUID(event.payload["allocation_id"]), + datetime.fromisoformat(event.payload["occurred_at"]), + ) + case "AllocationCeilingAmended": + await conn.execute( + _UPDATE_CEILING_AMENDED_SQL, + UUID(event.payload["allocation_id"]), + event.payload["ceiling_usd"], + ) + case "AllocationSealed": + await conn.execute( + _UPDATE_SEALED_SQL, + UUID(event.payload["allocation_id"]), + datetime.fromisoformat(event.payload["occurred_at"]), + event.payload["spent_usd"], + ) + case "AllocationVoided": + await conn.execute( + _UPDATE_VOIDED_SQL, + UUID(event.payload["allocation_id"]), + ) + case _: + pass + + +__all__ = ["AllocationSummaryProjection"] diff --git a/apps/api/src/cora/budget/subscribers/__init__.py b/apps/api/src/cora/budget/subscribers/__init__.py new file mode 100644 index 00000000000..d82b5c001a7 --- /dev/null +++ b/apps/api/src/cora/budget/subscribers/__init__.py @@ -0,0 +1,15 @@ +"""Budget BC side-effecting subscribers. + +One Reaction today: `AllocationSealerSubscriber` closes the books on +a campaign-bound Active allocation when its Campaign closes. +""" + +from cora.budget.subscribers.allocation_sealer import ( + AllocationSealerSubscriber, + make_allocation_sealer_subscriber, +) + +__all__ = [ + "AllocationSealerSubscriber", + "make_allocation_sealer_subscriber", +] diff --git a/apps/api/src/cora/budget/subscribers/allocation_sealer.py b/apps/api/src/cora/budget/subscribers/allocation_sealer.py new file mode 100644 index 00000000000..13fb33c82e4 --- /dev/null +++ b/apps/api/src/cora/budget/subscribers/allocation_sealer.py @@ -0,0 +1,234 @@ +"""Reaction: a CampaignClosed -> seal the campaign-bound Active allocation. + +The paper's close-the-books claim: an allocation whose `campaign_id` +references the closed Campaign is sealed automatically at the same +instant the experiment's own books close, freezing the final-spend +snapshot beside the campaign record. An allocation with no campaign +binding (or bound to a different campaign) is untouched: binding is +what opts an envelope into the automatic seal, so beamline-total +envelopes without a campaign stay under manual `seal_allocation`. + +## Authz posture: system actor, raw decide + append (not the handler) + +The seal must carry a principal, and no operator issued this command. +Precedents inspected: the LLM subscribers and the kill-switch write +as their own SEEDED agent principals (each is a modeled Agent with an +Actor and, under real Trust, an operator-granted policy), while +seed/bootstrap writes and the enclosure permit monitor stamp +`SYSTEM_PRINCIPAL_ID` and run the slice's load -> decide -> append +WITHOUT the handler's authorize gate, because `SYSTEM_PRINCIPAL_ID` +is deliberately NOT an authz wildcard and a real Trust policy would +deny it. This sealer is a deterministic bookkeeping reflex, not an +agent with judgment worth modeling, so it mirrors the enclosure +monitor: `sealed_by = SYSTEM_PRINCIPAL_ID`, same-BC decider reuse +(the Active-only guard in `seal_allocation.decider` is the real +gate), no Authorize call. The spend snapshot flows through the SAME +`make_ledger_total_spend` reader `wire_budget` binds, so an automatic +seal and an operator seal can never disagree about the figure. + +## Determinism + idempotency + +The seal's `occurred_at` and its ledger window end are the +CampaignClosed event's `occurred_at`, not wall clock, so a replayed +delivery derives the same event (the subscribers' determinism rule). +The seal event's `event_id` is a UUIDv5 of (trigger event, allocation) +and the append is guarded by the loaded stream version, so a +re-delivery after a successful seal either short-circuits on the +non-Active pre-guard or no-ops on `ConcurrencyError`. Skips (no +Active envelope, unbound envelope, campaign mismatch, lost race) log +at info and advance the bookmark; nothing here may wedge it. + +## Lookup + fold double-check + +`AllocationLookup.find_active` (projection-backed) finds the +candidate cheaply, then the aggregate stream is loaded and folded +before deciding: the projection may lag its own stream, and the fold +is the truth the optimistic append is versioned against. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID, uuid5 + +from cora.budget.aggregates.allocation import ( + AllocationStatus, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.budget.features.seal_allocation import ( + SealAllocation, + decide, + make_ledger_total_spend, +) +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import ConcurrencyError +from cora.infrastructure.routing import SYSTEM_PRINCIPAL_ID +from cora.shared.identity import ActorId + +if TYPE_CHECKING: + from cora.infrastructure.kernel import Kernel + from cora.infrastructure.ports import AllocationLookup, SpendLookup + from cora.infrastructure.ports.event_store import EventStore, StoredEvent + from cora.infrastructure.projection.handler import ConnectionLike + +_STREAM_TYPE = "Allocation" +_COMMAND_NAME = "AllocationSealerSubscriber" +_TRIGGER_EVENT_TYPE = "CampaignClosed" + +# Stable namespace for deriving the seal event's id from the +# (CampaignClosed event, allocation) pair, following the sibling +# suffix convention (cf. aaaa0002 / bbbb0002 / b1110002) in the +# sealer's own a10c block. A re-delivered CampaignClosed derives the +# same id, so the event store's UNIQUE(event_id) backs up the +# expected-version guard. +_ALLOCATION_SEALER_NAMESPACE = UUID("01900000-0000-7000-8000-0000a10c0002") + +_log = get_logger(__name__) + + +class AllocationSealerSubscriber: + """Reaction: CampaignClosed -> seal the allocation bound to that campaign. + + Constructed by `make_allocation_sealer_subscriber` from the + Kernel; satisfies the `Reaction` Protocol structurally. + Deterministic (no LLM); `batch_size = 1` keeps the per-event + load + ledger fold + append sequence inside one small bookmark + transaction, matching the sibling Reactions. + """ + + name = "allocation_sealer" + subscribed_event_types = frozenset({_TRIGGER_EVENT_TYPE}) + batch_size = 1 + + def __init__( + self, + *, + event_store: EventStore, + allocation_lookup: AllocationLookup, + spend_lookup: SpendLookup, + ) -> None: + self.event_store = event_store + self.allocation_lookup = allocation_lookup + self._total_spend_reader = make_ledger_total_spend(spend_lookup) + + async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: + """Process one CampaignClosed; seal its bound Active allocation, if any.""" + # conn unused: the seal goes through the event store, like the + # other side-effecting subscribers. + _ = conn + if event.event_type != _TRIGGER_EVENT_TYPE: + return + + closed_campaign_id = UUID(event.payload["campaign_id"]) + log = _log.bind( + subscriber=self.name, + command_name=_COMMAND_NAME, + campaign_id=str(closed_campaign_id), + trigger_event_id=str(event.event_id), + correlation_id=str(event.correlation_id), + ) + + active = await self.allocation_lookup.find_active() + if active is None: + log.info("allocation_sealer.skip.no_active_allocation") + return + if active.campaign_id != closed_campaign_id: + log.info( + "allocation_sealer.skip.campaign_mismatch", + allocation_id=str(active.allocation_id), + bound_campaign_id=( + str(active.campaign_id) if active.campaign_id is not None else None + ), + ) + return + + stored, current_version = await self.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=active.allocation_id, + ) + state = fold([from_stored(s) for s in stored]) + if state is None or state.status is not AllocationStatus.ACTIVE: + # Stale projection row or a replayed delivery after the seal: + # either way the envelope is not an open window anymore. + log.info( + "allocation_sealer.skip.not_active", + allocation_id=str(active.allocation_id), + status=(str(state.status) if state is not None else None), + ) + return + + window_start = state.activated_at + assert window_start is not None # Active state always folds activated_at + spent_usd = await self._total_spend_reader( + window_start=window_start, + window_end=event.occurred_at, + ) + + command = SealAllocation( + allocation_id=state.id, + reason=f"Campaign {closed_campaign_id} closed; books sealed automatically.", + ) + domain_events = decide( + state=state, + command=command, + now=event.occurred_at, + spent_usd=spent_usd, + sealed_by=ActorId(SYSTEM_PRINCIPAL_ID), + ) + new_events = [ + to_new_event( + event_type=event_type_name(domain_event), + payload=to_payload(domain_event), + occurred_at=domain_event.occurred_at, + event_id=uuid5( + _ALLOCATION_SEALER_NAMESPACE, + f"seal:{event.event_id}:{state.id}", + ), + command_name=_COMMAND_NAME, + correlation_id=event.correlation_id, + causation_id=event.event_id, + principal_id=SYSTEM_PRINCIPAL_ID, + ) + for domain_event in domain_events + ] + try: + await self.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=state.id, + expected_version=current_version, + events=new_events, + ) + except ConcurrencyError: + # The stream advanced between load and append (an operator + # sealed or voided concurrently, or a duplicate delivery + # raced). The next delivery, if any, re-evaluates fresh. + log.info( + "allocation_sealer.skip.lost_seal_race", + allocation_id=str(state.id), + ) + return + + log.info( + "allocation_sealer.sealed", + allocation_id=str(state.id), + spent_usd=spent_usd, + ) + + +def make_allocation_sealer_subscriber(deps: Kernel) -> AllocationSealerSubscriber: + """Build the allocation sealer closed over the shared deps.""" + return AllocationSealerSubscriber( + event_store=deps.event_store, + allocation_lookup=deps.allocation_lookup, + spend_lookup=deps.spend_lookup, + ) + + +__all__ = [ + "AllocationSealerSubscriber", + "make_allocation_sealer_subscriber", +] diff --git a/apps/api/src/cora/budget/wire.py b/apps/api/src/cora/budget/wire.py index cbd5750ecc7..eb79e958821 100644 --- a/apps/api/src/cora/budget/wire.py +++ b/apps/api/src/cora/budget/wire.py @@ -23,13 +23,13 @@ - `seal_allocation` (transition; no idempotency wrap) - `void_allocation` (transition; no idempotency wrap) -## Stage-A TotalSpendReader +## TotalSpendReader -`seal_allocation.bind` takes the reader as an explicit keyword. -Stage A binds `zero_total_spend` (no instance-total spend query -exists yet, so every stage-A seal honestly records 0.0); stage C -replaces this one argument with the SpendLookup-backed fold when -`find_total_spend` lands, leaving the slice untouched. +`seal_allocation.bind` takes the reader as an explicit keyword so +every wiring site states which ledger fold the seal snapshot records. +Production binds `make_ledger_total_spend(deps.spend_lookup)`, the +instance-total fold over `entries_decision_inferences` (the one-line +swap the stage-A `zero_total_spend` seam promised). """ from dataclasses import dataclass @@ -42,7 +42,7 @@ seal_allocation, void_allocation, ) -from cora.budget.features.seal_allocation import zero_total_spend +from cora.budget.features.seal_allocation import make_ledger_total_spend from cora.infrastructure.idempotency import with_idempotency from cora.infrastructure.kernel import Kernel from cora.infrastructure.observability import with_tracing @@ -89,7 +89,9 @@ def wire_budget(deps: Kernel) -> BudgetHandlers: bc=_BC, ), seal_allocation=with_tracing( - seal_allocation.bind(deps, total_spend_reader=zero_total_spend), + seal_allocation.bind( + deps, total_spend_reader=make_ledger_total_spend(deps.spend_lookup) + ), command_name="SealAllocation", bc=_BC, ), diff --git a/apps/api/src/cora/decision/adapters/postgres_spend_lookup.py b/apps/api/src/cora/decision/adapters/postgres_spend_lookup.py index e200255ba20..c7cf741ac88 100644 --- a/apps/api/src/cora/decision/adapters/postgres_spend_lookup.py +++ b/apps/api/src/cora/decision/adapters/postgres_spend_lookup.py @@ -34,7 +34,7 @@ import asyncpg -from cora.infrastructure.ports.spend_lookup import SpendLookupResult +from cora.infrastructure.ports.spend_lookup import SpendLookupResult, TotalSpendResult _SUM_AGENT_SPEND_SQL = """ SELECT @@ -48,6 +48,19 @@ AND occurred_at < $3 """ +# No agent filter on purpose: the allocation envelope's one balance +# covers the whole instrument, so agent-attributed and agentless +# (operator-attributed) rows sum alike. Same COALESCE posture as the +# per-agent SUM: NULL-cost legacy rows contribute $0, never poison. +_SUM_TOTAL_SPEND_SQL = """ +SELECT + COALESCE(SUM(cost_usd), 0)::float8 AS usd_spent, + COUNT(*)::bigint AS call_count +FROM entries_decision_inferences +WHERE occurred_at >= $1 + AND occurred_at < $2 +""" + # Token sums accumulate in NUMERIC (arbitrary precision) so one # adversarially large row can never raise bigint-out-of-range and wedge # the subscriber's retry loop; the Python side clamps the Decimal back @@ -85,5 +98,25 @@ async def find_agent_spend( call_count=int(row["call_count"]), ) + async def find_total_spend( + self, + *, + window_start: datetime, + window_end: datetime, + ) -> TotalSpendResult: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + _SUM_TOTAL_SPEND_SQL, + window_start, + window_end, + ) + assert row is not None # aggregate queries always return one row + return TotalSpendResult( + window_start=window_start, + window_end=window_end, + usd_spent=float(row["usd_spent"]), + call_count=int(row["call_count"]), + ) + __all__ = ["PostgresSpendLookup"] diff --git a/apps/api/src/cora/infrastructure/deps.py b/apps/api/src/cora/infrastructure/deps.py index 492fab4ee58..85bcaa5d378 100644 --- a/apps/api/src/cora/infrastructure/deps.py +++ b/apps/api/src/cora/infrastructure/deps.py @@ -81,6 +81,7 @@ from cora.infrastructure.logging import configure_logging from cora.infrastructure.ports import ( LLM, + AllocationLookup, AllSatisfiedSupplyLookup, AlwaysApprovedLanguageModelLookup, AlwaysCoveredClearanceLookup, @@ -111,6 +112,7 @@ LanguageModelLookup, LogbookMirror, ModelUsageLookup, + NoActiveAllocationLookup, NoComputeReachabilityLookup, NoDatasetDistributionsLookup, NoInvolvementLookup, @@ -188,6 +190,7 @@ def make_postgres_kernel( enclosure_lookup: EnclosureLookup | None = None, language_model_lookup: LanguageModelLookup | None = None, model_usage_lookup: ModelUsageLookup | None = None, + allocation_lookup: AllocationLookup | None = None, profile_store: ProfileStore | None = None, llm: LLM | None = None, logbook_mirror: LogbookMirror | None = None, @@ -320,6 +323,13 @@ def make_postgres_kernel( `model_usage_lookup_factory` argument; slice-specific tests override here explicitly. + `allocation_lookup` defaults to `NoActiveAllocationLookup` (no + envelope Active) so existing tests and allocation-less deployments + keep the unconstrained envelope behavior; activating an allocation + is what arms the gate. Production's `build_kernel` injects the real + `PostgresAllocationLookup` via the `allocation_lookup_factory` + argument; envelope-gate tests override here explicitly. + `llm` defaults to `None` because most BCs and tests don't need an LLM; only Agent BC subscribers consume it. Production's `build_kernel` injects `AnthropicLLM` when @@ -402,6 +412,9 @@ def make_postgres_kernel( model_usage_lookup=( model_usage_lookup if model_usage_lookup is not None else AlwaysEmptyModelUsageLookup() ), + allocation_lookup=( + allocation_lookup if allocation_lookup is not None else NoActiveAllocationLookup() + ), profile_store=(profile_store if profile_store is not None else PostgresProfileStore(pool)), canonicalization_registry=_build_default_canonicalization_registry(), signing_registry=SigningRegistry(), @@ -443,6 +456,7 @@ def make_inmemory_kernel( enclosure_lookup: EnclosureLookup | None = None, language_model_lookup: LanguageModelLookup | None = None, model_usage_lookup: ModelUsageLookup | None = None, + allocation_lookup: AllocationLookup | None = None, profile_store: ProfileStore | None = None, llm: LLM | None = None, logbook_mirror: LogbookMirror | None = None, @@ -558,6 +572,13 @@ def make_inmemory_kernel( it. Slice-specific tests override with a fake returning seeded `ModelUsageLookupResult` rows. + `allocation_lookup` defaults to `NoActiveAllocationLookup` (no + envelope Active) for the same reason: no projection worker, no + `proj_budget_allocation_summary` table to read from, and the + envelope check must stay disarmed for every test that never + declared an allocation. Envelope-gate tests override with a fake + returning a chosen `ActiveAllocation`. + `llm` defaults to `None`; the in-memory kernel is for unit / contract tests that don't exercise LLM subscribers. Subscriber tests that DO exercise the LLM path inject `FakeLLM` @@ -640,6 +661,9 @@ def make_inmemory_kernel( model_usage_lookup=( model_usage_lookup if model_usage_lookup is not None else AlwaysEmptyModelUsageLookup() ), + allocation_lookup=( + allocation_lookup if allocation_lookup is not None else NoActiveAllocationLookup() + ), profile_store=profile_store if profile_store is not None else InMemoryProfileStore(), canonicalization_registry=_build_default_canonicalization_registry(), signing_registry=SigningRegistry(), @@ -777,6 +801,25 @@ def __call__( ) -> LanguageModelLookup: ... +class AllocationLookupFactory(Protocol): + """Builds the production AllocationLookup port for the Kernel. + + Budget BC's `cora.budget.adapters.PostgresAllocationLookup` is the + production factory; `cora.api.main` binds it. Same factory- + injection shape as the other lookup factories so + `cora.infrastructure.deps` doesn't import from any BC. + + `pool` is `None` only when `app_env=test`; the production factory + requires a real pool. Test mode falls back to + `NoActiveAllocationLookup` automatically. + """ + + def __call__( + self, + pool: asyncpg.Pool, + ) -> AllocationLookup: ... + + class ModelUsageLookupFactory(Protocol): """Builds the production ModelUsageLookup port for the Kernel. @@ -1044,6 +1087,7 @@ async def build_kernel( spend_lookup_factory: SpendLookupFactory | None = None, language_model_lookup_factory: LanguageModelLookupFactory | None = None, model_usage_lookup_factory: ModelUsageLookupFactory | None = None, + allocation_lookup_factory: AllocationLookupFactory | None = None, run_actor_involvement_lookup_factory: RunActorInvolvementLookupFactory | None = None, consequence_lookup_factory: ConsequenceLookupFactory | None = None, dataset_distribution_lookup_factory: DatasetDistributionLookupFactory | None = None, @@ -1167,6 +1211,11 @@ async def build_kernel( if model_usage_lookup_factory is not None else AlwaysEmptyModelUsageLookup() ) + allocation_lookup: AllocationLookup = ( + allocation_lookup_factory(pool) + if allocation_lookup_factory is not None + else NoActiveAllocationLookup() + ) run_actor_involvement_lookup: RunActorInvolvementLookup = ( run_actor_involvement_lookup_factory(pool) if run_actor_involvement_lookup_factory is not None @@ -1233,6 +1282,7 @@ async def build_kernel( spend_lookup=spend_lookup, language_model_lookup=language_model_lookup, model_usage_lookup=model_usage_lookup, + allocation_lookup=allocation_lookup, run_actor_involvement_lookup=run_actor_involvement_lookup, consequence_lookup=consequence_lookup, dataset_distribution_lookup=dataset_distribution_lookup, diff --git a/apps/api/src/cora/infrastructure/kernel.py b/apps/api/src/cora/infrastructure/kernel.py index 4a107949f77..e79f3e8a110 100644 --- a/apps/api/src/cora/infrastructure/kernel.py +++ b/apps/api/src/cora/infrastructure/kernel.py @@ -45,6 +45,7 @@ from cora.infrastructure.ports import ( LLM, AllBeamOpenLookup, + AllocationLookup, AlwaysApprovedLanguageModelLookup, AlwaysEmptyModelUsageLookup, AlwaysGrantedSpendGuard, @@ -71,6 +72,7 @@ LanguageModelLookup, LogbookMirror, ModelUsageLookup, + NoActiveAllocationLookup, NullInferenceRecorder, ProfileStore, RoleLookup, @@ -463,6 +465,18 @@ class Kernel: root binds the Decision BC's `PostgresModelUsageLookup` over `entries_decision_inferences` when a pool exists.""" + allocation_lookup: AllocationLookup = field(default_factory=NoActiveAllocationLookup) + """Resolve the deployment's single Active spending envelope. + Consumed by the envelope arm of the budget gate stack (post-hoc + subscriber gate, pre-estimate `BudgetSpendGuard`) and by the + budget BC's CampaignClosed sealer. Defaults to the never-Active + stub so tests and deployments without a declared allocation keep + the unconstrained behavior; the composition root binds the budget + BC's `PostgresAllocationLookup` over + `proj_budget_allocation_summary` when a pool exists. Activating + an envelope is what arms the check (the `spend_lookup` opt-in + posture).""" + Teardown = Callable[[], Awaitable[None]] """Async callable returned by kernel-construction; the FastAPI diff --git a/apps/api/src/cora/infrastructure/ports/__init__.py b/apps/api/src/cora/infrastructure/ports/__init__.py index 8fcc684acc9..8e2cab34124 100644 --- a/apps/api/src/cora/infrastructure/ports/__init__.py +++ b/apps/api/src/cora/infrastructure/ports/__init__.py @@ -5,6 +5,11 @@ from `ports/`, never from adapter modules. """ +from cora.infrastructure.ports.allocation_lookup import ( + ActiveAllocation, + AllocationLookup, + NoActiveAllocationLookup, +) from cora.infrastructure.ports.assembly_lookup import ( AssemblyLookup, AssemblyLookupResult, @@ -164,6 +169,7 @@ AlwaysZeroSpendLookup, SpendLookup, SpendLookupResult, + TotalSpendResult, ) from cora.infrastructure.ports.supply_lookup import ( AllSatisfiedSupplyLookup, @@ -181,9 +187,11 @@ __all__ = [ "LLM", + "ActiveAllocation", "AgentInferenceTrace", "AllBeamOpenLookup", "AllSatisfiedSupplyLookup", + "AllocationLookup", "Allow", "AllowAllAuthorize", "AlwaysApprovedLanguageModelLookup", @@ -270,6 +278,7 @@ "ModelUsageLookupResult", "NeverRatifiedConsequenceLookup", "NewEvent", + "NoActiveAllocationLookup", "NoComputeReachabilityLookup", "NoDatasetDistributionsLookup", "NoInvolvementLookup", @@ -296,6 +305,7 @@ "SupplyLookupResult", "SystemClock", "TokenVerifier", + "TotalSpendResult", "UUIDv7Generator", "VerifiedPrincipal", ] diff --git a/apps/api/src/cora/infrastructure/ports/allocation_lookup.py b/apps/api/src/cora/infrastructure/ports/allocation_lookup.py new file mode 100644 index 00000000000..2d4cf66562d --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/allocation_lookup.py @@ -0,0 +1,89 @@ +"""AllocationLookup port: which spending envelope is currently Active? + +Consumed by the envelope arm of the budget gate stack: the coarse +post-hoc gate (`cora.agent._budget_gate.find_envelope_breach`), the +pre-estimate `BudgetSpendGuard`, and the budget BC's own +CampaignClosed sealer subscriber. All three ask the same one-row +question: "is an envelope Active right now, and what are its ceiling +and window start?" v1 models at most one Active envelope per +deployment (one CORA instance, one beamline), so the port has no +by-id or list surface; `find_active` IS the whole query shape. + +## Convention + +Same neutral-port shape as `SpendLookup` and `LanguageModelLookup`: +a consumer-shaped Protocol + frozen result VO + an opt-out test stub +here, with the production adapter shipped by the BC that owns the +fact (the budget BC's `PostgresAllocationLookup` over +`proj_budget_allocation_summary`). The port lives in infrastructure +because the consumers span BCs (agent gates, budget sealer) and the +kernel carries it as a cross-cutting field. + +## Failure direction + +The kernel default is `NoActiveAllocationLookup` (always None): no +envelope declared means no envelope constraint. Declaring and +activating an allocation is what arms the gate, the same opt-in +posture as every lookup in the family, so every existing test and +allocation-less deployment is unaffected. The at-most-one-Active +invariant is enforced best-effort at grant/activate time; on an +anomalous overlap the adapter answers with the newest-activated +envelope (documented on `find_active`), so the gate keys on the most +recent operator intent rather than refusing to answer. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol +from uuid import UUID + + +@dataclass(frozen=True) +class ActiveAllocation: + """The deployment's currently Active spending envelope. + + `activated_at` is the spend-window start every instance-total + ledger fold uses (`[activated_at, as_of)`); `campaign_id` is the + optional Campaign binding the CampaignClosed sealer matches on. + """ + + allocation_id: UUID + ceiling_usd: float + activated_at: datetime + campaign_id: UUID | None + + +class AllocationLookup(Protocol): + """Cross-cutting port: resolve the deployment's single Active envelope.""" + + async def find_active(self) -> ActiveAllocation | None: + """Return the Active allocation envelope, or None when none is Active. + + None disarms the envelope check entirely (no envelope, no + constraint). Should more than one row ever be Active (the + best-effort grant/activate guard lost a race), the newest + `activated_at` wins, with `allocation_id` as a deterministic + tiebreak: the most recently opened window is the operator's + current intent, and a stable answer beats an arbitrary one. + """ + ... + + +class NoActiveAllocationLookup: + """Test-default stub: no envelope is ever Active. + + Mirrors `AlwaysZeroSpendLookup`'s role: tests and deployments + that never declared an allocation get this from the kernel + defaults, so the envelope check stays disarmed (the opt-in + posture: activating an envelope is what arms the gate). + """ + + async def find_active(self) -> ActiveAllocation | None: + return None + + +__all__ = [ + "ActiveAllocation", + "AllocationLookup", + "NoActiveAllocationLookup", +] diff --git a/apps/api/src/cora/infrastructure/ports/spend_lookup.py b/apps/api/src/cora/infrastructure/ports/spend_lookup.py index c5c78b6a123..86a0be2368b 100644 --- a/apps/api/src/cora/infrastructure/ports/spend_lookup.py +++ b/apps/api/src/cora/infrastructure/ports/spend_lookup.py @@ -80,8 +80,25 @@ class SpendLookupResult: call_count: int +@dataclass(frozen=True) +class TotalSpendResult: + """The deployment's instance-total spend inside one window. + + No agent axis: the sum covers EVERY costed inference row, + agent-attributed and operator-attributed alike, because the + allocation envelope covers the whole instrument's LLM spend (one + balance per beamline). The echoed window bounds make gate and + seal log lines self-describing. + """ + + window_start: datetime + window_end: datetime + usd_spent: float + call_count: int + + class SpendLookup(Protocol): - """Cross-BC port: sum an agent's recorded LLM spend in a window.""" + """Cross-BC port: sum recorded LLM spend in a window (per agent or instance-total).""" async def find_agent_spend( self, @@ -98,6 +115,22 @@ async def find_agent_spend( """ ... + async def find_total_spend( + self, + *, + window_start: datetime, + window_end: datetime, + ) -> TotalSpendResult: + """Return the instance-total spend for `[window_start, window_end)`. + + Sums ALL rows regardless of `agent_id` (including agentless, + operator-attributed rows): the allocation envelope's one + balance covers the whole instrument. A window with no + recorded calls returns a zero row (never None), matching the + per-agent contract. + """ + ... + class AlwaysZeroSpendLookup: """Test-default stub: every agent has spent nothing. @@ -125,9 +158,23 @@ async def find_agent_spend( call_count=0, ) + async def find_total_spend( + self, + *, + window_start: datetime, + window_end: datetime, + ) -> TotalSpendResult: + return TotalSpendResult( + window_start=window_start, + window_end=window_end, + usd_spent=0.0, + call_count=0, + ) + __all__ = [ "AlwaysZeroSpendLookup", "SpendLookup", "SpendLookupResult", + "TotalSpendResult", ] diff --git a/apps/api/tach.toml b/apps/api/tach.toml index efc9136394f..aa0f76cadab 100644 --- a/apps/api/tach.toml +++ b/apps/api/tach.toml @@ -403,9 +403,10 @@ depends_on = [ # lock: `campaign_id` is a bare UUID reference with NO cross-BC kernel # load on the write path (eventual-consistency stance per the Caution / # Calibration precedent), and the seal's TotalSpendReader is an -# injected callable (stage A binds a zero-reader; stage C binds the -# SpendLookup-backed fold), so the BC depends only on shared + -# infrastructure. +# injected callable (production binds the SpendLookup-backed fold via +# the neutral port), so the BC depends only on shared + infrastructure. +# The CampaignClosed sealer subscribes by event-type STRING and reads +# the campaign id off the payload; no cora.campaign import. [[modules]] path = "cora.budget" depends_on = ["cora.infrastructure", "cora.shared", "cora.budget.aggregates"] diff --git a/apps/api/tests/integration/test_allocation_lookup_postgres.py b/apps/api/tests/integration/test_allocation_lookup_postgres.py new file mode 100644 index 00000000000..921665bb5bc --- /dev/null +++ b/apps/api/tests/integration/test_allocation_lookup_postgres.py @@ -0,0 +1,114 @@ +"""Integration tests for `PostgresAllocationLookup` over `proj_budget_allocation_summary`. + +Seeds projection rows by direct INSERT (the read model has no store +abstraction; the projection worker is the only production writer) and +verifies the Active-only contract: the lookup answers with the single +Active envelope, ignoring Granted and terminal rows, and resolves an +anomalous Active overlap to the newest-activated row. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.budget.adapters import PostgresAllocationLookup + +_GRANTED_AT = datetime(2026, 7, 10, tzinfo=UTC) +_T1 = datetime(2026, 7, 11, tzinfo=UTC) +_T2 = datetime(2026, 7, 12, tzinfo=UTC) + +_INSERT_SQL = """ +INSERT INTO proj_budget_allocation_summary + (allocation_id, ceiling_usd, campaign_id, holder_note, status, + granted_at, activated_at, created_at) +VALUES ($1, $2, $3, 'FY26 imaging award', $4, $5, $6, $5) +""" + + +async def _insert_row( + pool: asyncpg.Pool, + *, + allocation_id: UUID, + status: str, + activated_at: datetime | None = None, + ceiling_usd: float = 25000.0, + campaign_id: UUID | None = None, +) -> None: + async with pool.acquire() as conn: + await conn.execute( + _INSERT_SQL, + allocation_id, + ceiling_usd, + campaign_id, + status, + _GRANTED_AT, + activated_at, + ) + + +@pytest.mark.integration +async def test_active_envelope_wins_over_granted_and_sealed_siblings( + db_pool: asyncpg.Pool, +) -> None: + """A dormant grant and a sealed predecessor never shadow the one + open window: only the Active row arms the gate.""" + active_id = uuid4() + campaign_id = uuid4() + await _insert_row(db_pool, allocation_id=uuid4(), status="Granted") + await _insert_row(db_pool, allocation_id=uuid4(), status="Sealed", activated_at=_T1) + await _insert_row( + db_pool, + allocation_id=active_id, + status="Active", + activated_at=_T2, + ceiling_usd=12000.0, + campaign_id=campaign_id, + ) + + result = await PostgresAllocationLookup(db_pool).find_active() + + assert result is not None + assert result.allocation_id == active_id + assert result.ceiling_usd == 12000.0 + assert result.activated_at == _T2 + assert result.campaign_id == campaign_id + + +@pytest.mark.integration +async def test_newest_activated_wins_on_anomalous_active_overlap( + db_pool: asyncpg.Pool, +) -> None: + """At-most-one-Active is best-effort at grant/activate; if two + rows are ever Active the most recently opened window is the + operator's current intent.""" + newer_id = uuid4() + await _insert_row(db_pool, allocation_id=uuid4(), status="Active", activated_at=_T1) + await _insert_row(db_pool, allocation_id=newer_id, status="Active", activated_at=_T2) + + result = await PostgresAllocationLookup(db_pool).find_active() + + assert result is not None + assert result.allocation_id == newer_id + + +@pytest.mark.integration +async def test_no_active_envelope_returns_none(db_pool: asyncpg.Pool) -> None: + """Granted-only and terminal rows leave the envelope check + disarmed: None means no constraint.""" + await _insert_row(db_pool, allocation_id=uuid4(), status="Granted") + await _insert_row(db_pool, allocation_id=uuid4(), status="Voided") + + result = await PostgresAllocationLookup(db_pool).find_active() + + assert result is None + + +@pytest.mark.integration +async def test_empty_table_returns_none(db_pool: asyncpg.Pool) -> None: + result = await PostgresAllocationLookup(db_pool).find_active() + + assert result is None diff --git a/apps/api/tests/integration/test_spend_lookup_postgres.py b/apps/api/tests/integration/test_spend_lookup_postgres.py index fd08485591b..d4974ac4774 100644 --- a/apps/api/tests/integration/test_spend_lookup_postgres.py +++ b/apps/api/tests/integration/test_spend_lookup_postgres.py @@ -1,9 +1,11 @@ """Integration tests for `PostgresSpendLookup` over `entries_decision_inferences`. Seeds inference rows directly through `PostgresInferenceStore` (the same -write path production uses) and verifies the spend sums respect the -agent filter, the half-open window bounds, and the NULL-cost COALESCE -that keeps pre-migration history from inflating a balance. +write path production uses) and verifies both sum shapes: the per-agent +sums respect the agent filter, and the instance-total sums (the +allocation envelope's one balance) cover agented and agentless rows +alike. Both respect the half-open window bounds and the NULL-cost +COALESCE that keeps pre-migration history from inflating a balance. """ from datetime import UTC, datetime @@ -141,3 +143,69 @@ async def test_agent_with_no_rows_returns_a_zero_row(db_pool: asyncpg.Pool) -> N assert result.usd_spent == 0.0 assert result.tokens_spent == 0 assert result.call_count == 0 + + +@pytest.mark.integration +async def test_total_spend_sums_agented_and_agentless_rows_alike( + db_pool: asyncpg.Pool, +) -> None: + """The envelope covers the whole instrument: two different agents' + rows AND an operator-attributed (agentless) row all debit the one + balance, with NULL-cost history contributing $0 but still counting + as a call.""" + store = PostgresInferenceStore(db_pool) + in_window = datetime(2026, 7, 10, tzinfo=UTC) + await store.append( + [ + _row(agent_id=_AGENT_ID, occurred_at=in_window, cost_usd=0.002), + _row(agent_id=_OTHER_AGENT_ID, occurred_at=in_window, cost_usd=5.0), + _row(agent_id=None, occurred_at=in_window, cost_usd=3.0), + _row(agent_id=None, occurred_at=in_window, cost_usd=None), + ] + ) + + result = await PostgresSpendLookup(db_pool).find_total_spend( + window_start=_WINDOW_START, + window_end=_WINDOW_END, + ) + + assert result.usd_spent == pytest.approx(8.002) + assert result.call_count == 4 + assert result.window_start == _WINDOW_START + assert result.window_end == _WINDOW_END + + +@pytest.mark.integration +async def test_total_spend_respects_the_half_open_window(db_pool: asyncpg.Pool) -> None: + """A row exactly at window_start counts, a row exactly at + window_end does not, and rows outside the bounds never leak into + the envelope's balance.""" + store = PostgresInferenceStore(db_pool) + await store.append( + [ + _row(agent_id=_AGENT_ID, occurred_at=_WINDOW_START, cost_usd=0.5), + _row(agent_id=None, occurred_at=_WINDOW_END, cost_usd=9.0), + _row(agent_id=None, occurred_at=datetime(2026, 6, 30, tzinfo=UTC), cost_usd=7.0), + ] + ) + + result = await PostgresSpendLookup(db_pool).find_total_spend( + window_start=_WINDOW_START, + window_end=_WINDOW_END, + ) + + assert result.usd_spent == pytest.approx(0.5) + assert result.call_count == 1 + + +@pytest.mark.integration +async def test_total_spend_over_an_empty_window_returns_a_zero_row( + db_pool: asyncpg.Pool, +) -> None: + result = await PostgresSpendLookup(db_pool).find_total_spend( + window_start=_WINDOW_START, + window_end=_WINDOW_END, + ) + + assert result.usd_spent == 0.0 + assert result.call_count == 0 diff --git a/apps/api/tests/unit/_helpers.py b/apps/api/tests/unit/_helpers.py index cf22549b5c8..01076be1be4 100644 --- a/apps/api/tests/unit/_helpers.py +++ b/apps/api/tests/unit/_helpers.py @@ -45,6 +45,7 @@ from cora.infrastructure.kernel import Kernel from cora.infrastructure.ports import ( LLM, + AllocationLookup, Allow, AllowAllAuthorize, AssemblyLookup, @@ -137,6 +138,7 @@ def build_deps( spend_lookup: SpendLookup | None = None, language_model_lookup: LanguageModelLookup | None = None, model_usage_lookup: ModelUsageLookup | None = None, + allocation_lookup: AllocationLookup | None = None, ) -> Kernel: """Build a Kernel for unit-test handler invocation. @@ -204,6 +206,12 @@ def build_deps( seeded `ModelUsageLookupResult` rows). Defaults to the kernel's always-empty stub via `make_inmemory_kernel`, so tests that don't exercise the at-risk surface see no touched Decisions. + + `allocation_lookup` injects an envelope-lookup fake for the + budget-gate and allocation-sealer tests (typically a fake + returning a chosen `ActiveAllocation`). Defaults to the kernel's + never-Active stub via `make_inmemory_kernel`, so tests that never + declared an allocation keep the envelope check disarmed. """ if authz is None: authz = DenyAllAuthorize() if deny else AllowAllAuthorize() @@ -250,6 +258,7 @@ def build_deps( spend_lookup=spend_lookup, language_model_lookup=language_model_lookup, model_usage_lookup=model_usage_lookup, + allocation_lookup=allocation_lookup, ) diff --git a/apps/api/tests/unit/agent/_helpers.py b/apps/api/tests/unit/agent/_helpers.py index 4b3a511a9c3..d1a8ff153a1 100644 --- a/apps/api/tests/unit/agent/_helpers.py +++ b/apps/api/tests/unit/agent/_helpers.py @@ -28,7 +28,8 @@ from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore from cora.infrastructure.event_envelope import to_new_event from cora.infrastructure.ports import AgentInferenceTrace -from cora.infrastructure.ports.spend_lookup import SpendLookupResult +from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports.spend_lookup import SpendLookupResult, TotalSpendResult from cora.infrastructure.signing import event_type_to_payload_type from cora.shared.content_hash import canonical_body_bytes, pae_bytes from cora.shared.identity import ActorId @@ -47,9 +48,13 @@ class RecordedInference: class FakeSpendLookup: """SpendLookup stub returning configured sums; records each query. - `windows` captures every `(agent_id, window_start, window_end)` - asked for, so gate tests can assert the window math without a - real store. + `windows` captures every per-agent `(agent_id, window_start, + window_end)` asked for, and `total_windows` every instance-total + `(window_start, window_end)`, so gate tests can assert the window + math without a real store. `total_usd_spent` configures the + instance-total answer independently of the per-agent `usd_spent` + because the envelope check and the per-agent caps sum different + row sets. """ def __init__( @@ -58,11 +63,16 @@ def __init__( usd_spent: float = 0.0, tokens_spent: int = 0, call_count: int = 0, + total_usd_spent: float = 0.0, + total_call_count: int = 0, ) -> None: self.usd_spent = usd_spent self.tokens_spent = tokens_spent self.call_count = call_count + self.total_usd_spent = total_usd_spent + self.total_call_count = total_call_count self.windows: list[tuple[UUID, datetime, datetime]] = [] + self.total_windows: list[tuple[datetime, datetime]] = [] async def find_agent_spend( self, @@ -81,6 +91,36 @@ async def find_agent_spend( call_count=self.call_count, ) + async def find_total_spend( + self, + *, + window_start: datetime, + window_end: datetime, + ) -> TotalSpendResult: + self.total_windows.append((window_start, window_end)) + return TotalSpendResult( + window_start=window_start, + window_end=window_end, + usd_spent=self.total_usd_spent, + call_count=self.total_call_count, + ) + + +class FakeAllocationLookup: + """AllocationLookup stub returning one configured envelope (or None). + + `find_active_calls` counts consultations so envelope-gate tests + can pin that the disarmed path never reaches the total-spend sum. + """ + + def __init__(self, active: ActiveAllocation | None = None) -> None: + self.active = active + self.find_active_calls = 0 + + async def find_active(self) -> ActiveAllocation | None: + self.find_active_calls += 1 + return self.active + class FakeInferenceRecorder: """Spy `InferenceRecorder` for agent-subscriber tests. diff --git a/apps/api/tests/unit/agent/test_budget_gate.py b/apps/api/tests/unit/agent/test_budget_gate.py index c53e8251f02..0008d88fede 100644 --- a/apps/api/tests/unit/agent/test_budget_gate.py +++ b/apps/api/tests/unit/agent/test_budget_gate.py @@ -9,15 +9,35 @@ calendar_day_window, calendar_month_window, find_budget_breach, + find_envelope_breach, ) from cora.agent.aggregates.agent import load_agent from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore -from tests.unit.agent._helpers import FakeSpendLookup, seed_defined_agent +from cora.infrastructure.ports.allocation_lookup import ( + ActiveAllocation, + NoActiveAllocationLookup, +) +from tests.unit.agent._helpers import ( + FakeAllocationLookup, + FakeSpendLookup, + seed_defined_agent, +) _NOW = datetime(2026, 7, 11, 15, 30, 0, tzinfo=UTC) _CORRELATION_ID = uuid4() _PRINCIPAL_ID = uuid4() +_ACTIVATED_AT = datetime(2026, 7, 1, 8, 0, 0, tzinfo=UTC) + + +def _envelope(*, ceiling_usd: float = 100.0) -> ActiveAllocation: + return ActiveAllocation( + allocation_id=uuid4(), + ceiling_usd=ceiling_usd, + activated_at=_ACTIVATED_AT, + campaign_id=None, + ) + async def _agent_with_caps( store: InMemoryEventStore, @@ -176,3 +196,109 @@ async def test_zero_cap_refuses_every_call() -> None: assert breach is not None assert breach.cap_kind == "monthly_usd_cap" + + +# ---------- find_envelope_breach ---------- + + +@pytest.mark.unit +async def test_no_active_envelope_permits_without_summing_spend() -> None: + """No envelope declared means no envelope constraint; the ledger + SUM is never issued on the disarmed path.""" + spend = FakeSpendLookup(total_usd_spent=1_000_000.0) + + breach = await find_envelope_breach( + allocation_lookup=NoActiveAllocationLookup(), + spend_lookup=spend, + as_of=_NOW, + ) + + assert breach is None + assert spend.total_windows == [] + + +@pytest.mark.unit +async def test_envelope_spend_below_ceiling_permits_over_the_lifecycle_window() -> None: + """The window is the envelope's own lifecycle [activated_at, + as_of), not calendar arithmetic.""" + envelope = _envelope(ceiling_usd=100.0) + spend = FakeSpendLookup(total_usd_spent=99.99) + + breach = await find_envelope_breach( + allocation_lookup=FakeAllocationLookup(envelope), + spend_lookup=spend, + as_of=_NOW, + ) + + assert breach is None + assert spend.total_windows == [(_ACTIVATED_AT, _NOW)] + + +@pytest.mark.unit +async def test_post_hoc_exactly_exhausted_envelope_breaches() -> None: + """Post-hoc callers pass no pending figure, so spend landing + exactly on the ceiling refuses the NEXT call (the >= arm).""" + envelope = _envelope(ceiling_usd=100.0) + + breach = await find_envelope_breach( + allocation_lookup=FakeAllocationLookup(envelope), + spend_lookup=FakeSpendLookup(total_usd_spent=100.0), + as_of=_NOW, + ) + + assert breach is not None + assert breach.allocation_id == envelope.allocation_id + assert breach.ceiling_usd == 100.0 + assert breach.spent_usd == pytest.approx(100.0) + assert breach.window_start == _ACTIVATED_AT + + +@pytest.mark.unit +async def test_pre_estimate_projection_landing_exactly_on_ceiling_grants() -> None: + """With a pending estimate the check is strictly-greater: a call + projected to land exactly ON the ceiling is the last one the + envelope affords (matches BudgetSpendGuard's per-agent + convention).""" + envelope = _envelope(ceiling_usd=100.0) + + breach = await find_envelope_breach( + allocation_lookup=FakeAllocationLookup(envelope), + spend_lookup=FakeSpendLookup(total_usd_spent=99.5), + as_of=_NOW, + pending_usd=0.5, + ) + + assert breach is None + + +@pytest.mark.unit +async def test_pre_estimate_projection_over_ceiling_breaches() -> None: + envelope = _envelope(ceiling_usd=100.0) + + breach = await find_envelope_breach( + allocation_lookup=FakeAllocationLookup(envelope), + spend_lookup=FakeSpendLookup(total_usd_spent=99.5), + as_of=_NOW, + pending_usd=0.6, + ) + + assert breach is not None + assert breach.spent_usd == pytest.approx(99.5) + + +@pytest.mark.unit +async def test_envelope_breach_describe_names_the_allocation() -> None: + """The describe() sentence lands on deferred Decisions, so it + must name the envelope and its window start for operators.""" + envelope = _envelope(ceiling_usd=100.0) + + breach = await find_envelope_breach( + allocation_lookup=FakeAllocationLookup(envelope), + spend_lookup=FakeSpendLookup(total_usd_spent=250.0), + as_of=_NOW, + ) + + assert breach is not None + description = breach.describe() + assert str(envelope.allocation_id) in description + assert _ACTIVATED_AT.isoformat() in description diff --git a/apps/api/tests/unit/agent/test_budget_spend_guard.py b/apps/api/tests/unit/agent/test_budget_spend_guard.py index 806dafbcf46..8482999f4b4 100644 --- a/apps/api/tests/unit/agent/test_budget_spend_guard.py +++ b/apps/api/tests/unit/agent/test_budget_spend_guard.py @@ -1,9 +1,10 @@ """Unit tests for `BudgetSpendGuard`, the Agent BC's pre-estimate adapter. Drives the guard against `InMemoryEventStore` seeds + `FakeSpendLookup` -so every refusal branch (lifecycle, monthly USD, daily tokens) and every -permissive branch (no agent, no budget, headroom, exact-cap landing) is -pinned, along with the calendar windows the lookup is asked for. +so every refusal branch (lifecycle, monthly USD, daily tokens, the +instrument-wide allocation envelope) and every permissive branch (no +agent, no budget, headroom, exact-cap landing) is pinned, along with +the windows the lookup is asked for. """ from datetime import UTC, datetime @@ -13,7 +14,9 @@ from cora.agent.adapters import BudgetSpendGuard from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.ports.allocation_lookup import ActiveAllocation from tests.unit.agent._helpers import ( + FakeAllocationLookup, FakeSpendLookup, seed_defined_agent, seed_suspended_agent, @@ -24,6 +27,17 @@ _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000099001") _CORRELATION_ID = UUID("01900000-0000-7000-8000-00000009900a") +_ENVELOPE_ACTIVATED_AT = datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC) + + +def _envelope(*, ceiling_usd: float = 100.0) -> ActiveAllocation: + return ActiveAllocation( + allocation_id=uuid4(), + ceiling_usd=ceiling_usd, + activated_at=_ENVELOPE_ACTIVATED_AT, + campaign_id=None, + ) + async def _versioned_agent( store: InMemoryEventStore, @@ -219,3 +233,127 @@ async def test_defined_seeded_agent_is_refused_until_versioned() -> None: assert reason is not None assert "Defined" in reason + + +@pytest.mark.unit +async def test_envelope_projection_over_ceiling_refuses_naming_the_envelope() -> None: + """The instrument-wide arm: instance-total spend plus this call's + estimate strictly over the Active ceiling refuses, over the + envelope's own lifecycle window.""" + store = InMemoryEventStore() + agent_id = uuid4() + await _versioned_agent(store, agent_id) + envelope = _envelope(ceiling_usd=100.0) + lookup = FakeSpendLookup(total_usd_spent=99.5) + guard = BudgetSpendGuard( + event_store=store, + spend_lookup=lookup, + allocation_lookup=FakeAllocationLookup(envelope), + ) + + reason = await guard.refusal_reason( + agent_id=agent_id, estimated_cost_usd=0.6, estimated_tokens=100, as_of=_NOW + ) + + assert reason is not None + assert str(envelope.allocation_id) in reason + assert lookup.total_windows == [(_ENVELOPE_ACTIVATED_AT, _NOW)] + + +@pytest.mark.unit +async def test_envelope_projection_landing_exactly_on_ceiling_grants() -> None: + """Strictly-greater, matching the per-agent pre-estimate stance: + the ceiling is an amount the envelope affords.""" + store = InMemoryEventStore() + agent_id = uuid4() + await _versioned_agent(store, agent_id) + guard = BudgetSpendGuard( + event_store=store, + spend_lookup=FakeSpendLookup(total_usd_spent=99.5), + allocation_lookup=FakeAllocationLookup(_envelope(ceiling_usd=100.0)), + ) + + reason = await guard.refusal_reason( + agent_id=agent_id, estimated_cost_usd=0.5, estimated_tokens=100, as_of=_NOW + ) + + assert reason is None + + +@pytest.mark.unit +async def test_envelope_gates_an_agent_with_no_declared_budget() -> None: + """The envelope is armed by declaring an allocation, not by + per-agent cap ceremony: an uncapped agent is still stopped when + the instrument's one balance is exhausted.""" + store = InMemoryEventStore() + agent_id = uuid4() + await _versioned_agent(store, agent_id) + guard = BudgetSpendGuard( + event_store=store, + spend_lookup=FakeSpendLookup(total_usd_spent=200.0), + allocation_lookup=FakeAllocationLookup(_envelope(ceiling_usd=100.0)), + ) + + reason = await guard.refusal_reason( + agent_id=agent_id, estimated_cost_usd=1.0, estimated_tokens=10, as_of=_NOW + ) + + assert reason is not None + assert "allocation envelope" in reason + + +@pytest.mark.unit +async def test_envelope_gates_even_when_the_agent_stream_is_missing() -> None: + """A caller with no Agent stream skips the per-agent checks but + not the instrument-wide one.""" + guard = BudgetSpendGuard( + event_store=InMemoryEventStore(), + spend_lookup=FakeSpendLookup(total_usd_spent=200.0), + allocation_lookup=FakeAllocationLookup(_envelope(ceiling_usd=100.0)), + ) + + reason = await guard.refusal_reason( + agent_id=uuid4(), estimated_cost_usd=1.0, estimated_tokens=10, as_of=_NOW + ) + + assert reason is not None + assert "allocation envelope" in reason + + +@pytest.mark.unit +async def test_per_agent_cap_refusal_wins_before_the_envelope_is_consulted() -> None: + """Cap order: the declared per-agent caps refuse first; the + envelope lookup is never made once a cap refuses.""" + store = InMemoryEventStore() + agent_id = uuid4() + await _versioned_agent(store, agent_id, monthly_usd_cap=1.0) + allocation_lookup = FakeAllocationLookup(_envelope(ceiling_usd=100.0)) + guard = BudgetSpendGuard( + event_store=store, + spend_lookup=FakeSpendLookup(usd_spent=5.0), + allocation_lookup=allocation_lookup, + ) + + reason = await guard.refusal_reason( + agent_id=agent_id, estimated_cost_usd=0.5, estimated_tokens=10, as_of=_NOW + ) + + assert reason is not None + assert "monthly_usd_cap" in reason + assert allocation_lookup.find_active_calls == 0 + + +@pytest.mark.unit +async def test_no_active_envelope_leaves_the_guard_cap_only() -> None: + """Default construction (no allocation_lookup) keeps the guard's + pre-envelope behavior byte-for-byte: headroom on caps grants.""" + store = InMemoryEventStore() + agent_id = uuid4() + await _versioned_agent(store, agent_id, monthly_usd_cap=100.0) + guard = _guard(store, FakeSpendLookup(usd_spent=1.0)) + + reason = await guard.refusal_reason( + agent_id=agent_id, estimated_cost_usd=0.5, estimated_tokens=10, as_of=_NOW + ) + + assert reason is None diff --git a/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py b/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py index 7df49d7b79c..7f45d2d73fd 100644 --- a/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py +++ b/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py @@ -46,6 +46,7 @@ LLMServerError, LLMUsage, ) +from cora.infrastructure.ports.allocation_lookup import ActiveAllocation from cora.infrastructure.ports.event_store import StoredEvent from cora.recipe.aggregates.plan import ( PlanDefined, @@ -66,6 +67,7 @@ from tests.unit._helpers import build_deps from tests.unit.agent._helpers import ( Ed25519FakeSigner, + FakeAllocationLookup, FakeInferenceRecorder, FakeSpendLookup, seed_suspended_agent, @@ -246,6 +248,7 @@ async def _build_subscriber( llm: FakeLLM, inference_recorder: FakeInferenceRecorder | None = None, spend_lookup: FakeSpendLookup | None = None, + allocation_lookup: FakeAllocationLookup | None = None, ) -> CautionDrafterSubscriber: return CautionDrafterSubscriber( event_store=event_store, @@ -253,6 +256,7 @@ async def _build_subscriber( caution_lookup=AlwaysQuietCautionLookup(), inference_recorder=inference_recorder, spend_lookup=spend_lookup, + allocation_lookup=allocation_lookup, ) @@ -515,6 +519,60 @@ async def test_apply_defers_noaction_when_monthly_usd_cap_exhausted() -> None: assert llm.received == [] +@pytest.mark.unit +async def test_apply_defers_noaction_when_allocation_envelope_exhausted() -> None: + """The instrument-wide envelope stops the draft even though this + agent has no declared caps of its own; the refusal is this Run's + NoAction Decision with the AllocationExhausted marker (mirrors the + RunDebriefer arm).""" + store = InMemoryEventStore() + llm = FakeLLM(responses=[_CANNED_NO_ACTION]) + recorder = FakeInferenceRecorder() + await _seed_caution_drafter_actor(store) + await seed_versioned_agent( + store, + agent_id=CAUTION_DRAFTER_AGENT_ID, + genesis_event_id=uuid4(), + version_event_id=uuid4(), + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + defined_at=_NOW, + versioned_at=_NOW, + ) + await _seed_plan(store) + run_id = uuid4() + await _seed_run(store, run_id) + activated_at = datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC) + envelope = ActiveAllocation( + allocation_id=uuid4(), + ceiling_usd=100.0, + activated_at=activated_at, + campaign_id=None, + ) + spend_lookup = FakeSpendLookup(total_usd_spent=100.0) + subscriber = await _build_subscriber( + store, + llm, + recorder, + spend_lookup=spend_lookup, + allocation_lookup=FakeAllocationLookup(envelope), + ) + event = _terminal_event(event_type="RunCompleted", run_id=run_id) + + await subscriber.apply(event, conn=None) + + decision = await load_decision(store, _derive_decision_id(event.event_id)) + assert decision is not None + assert decision.choice.value == "NoAction" + assert "Allocation exhausted" in (decision.reasoning or "") + assert decision.inputs is not None + assert decision.inputs["failure_error_class"] == "AllocationExhausted" + assert decision.inputs["allocation_id"] == str(envelope.allocation_id) + assert recorder.calls == [] + assert llm.received == [] + assert spend_lookup.total_windows == [(activated_at, _LATER)] + + @pytest.mark.unit async def test_apply_proceeds_normally_when_spend_is_under_cap() -> None: """A declared budget with headroom never interferes with the diff --git a/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py b/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py index 9c5c2252c6b..86bdaea8e12 100644 --- a/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py +++ b/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py @@ -50,6 +50,7 @@ LLMServerError, LLMUsage, ) +from cora.infrastructure.ports.allocation_lookup import ActiveAllocation from cora.infrastructure.ports.event_store import StoredEvent from cora.run.aggregates.run import ( RunStarted, @@ -69,6 +70,7 @@ from tests.unit._helpers import build_deps from tests.unit.agent._helpers import ( Ed25519FakeSigner, + FakeAllocationLookup, FakeInferenceRecorder, FakeSpendLookup, seed_defined_agent, @@ -232,6 +234,7 @@ async def _build_subscriber( llm: FakeLLM, inference_recorder: FakeInferenceRecorder | None = None, spend_lookup: FakeSpendLookup | None = None, + allocation_lookup: FakeAllocationLookup | None = None, ) -> RunDebrieferSubscriber: return RunDebrieferSubscriber( event_store=event_store, @@ -239,6 +242,7 @@ async def _build_subscriber( logbook_mirror=None, inference_recorder=inference_recorder, spend_lookup=spend_lookup, + allocation_lookup=allocation_lookup, ) @@ -535,6 +539,62 @@ async def test_apply_defers_debrief_when_daily_token_cap_exhausted() -> None: ] +@pytest.mark.unit +async def test_apply_defers_debrief_when_allocation_envelope_exhausted() -> None: + """The instrument-wide envelope stops the call even though this + agent has no declared caps of its own: instance-total spend over + [activated_at, event.occurred_at) has reached the Active ceiling, + and the refusal is recorded as this Run's DebriefDeferred with the + AllocationExhausted marker.""" + store = InMemoryEventStore() + llm = FakeLLM(responses=[_CANNED_OK]) + recorder = FakeInferenceRecorder() + await _seed_run_debrief_actor(store) + await seed_versioned_agent( + store, + agent_id=RUN_DEBRIEFER_AGENT_ID, + genesis_event_id=uuid4(), + version_event_id=uuid4(), + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + defined_at=_NOW, + versioned_at=_NOW, + ) + run_id = uuid4() + await _seed_run(store, run_id) + activated_at = datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC) + envelope = ActiveAllocation( + allocation_id=uuid4(), + ceiling_usd=100.0, + activated_at=activated_at, + campaign_id=None, + ) + spend_lookup = FakeSpendLookup(total_usd_spent=100.0) + subscriber = await _build_subscriber( + store, + llm, + recorder, + spend_lookup=spend_lookup, + allocation_lookup=FakeAllocationLookup(envelope), + ) + event = _terminal_event(event_type="RunCompleted", run_id=run_id) + + await subscriber.apply(event, conn=None) + + decision = await load_decision(store, _derive_decision_id(event.event_id)) + assert decision is not None + assert decision.choice.value == "DebriefDeferred" + assert "Allocation exhausted" in (decision.reasoning or "") + assert decision.inputs is not None + assert decision.inputs["failure_error_class"] == "AllocationExhausted" + assert decision.inputs["allocation_id"] == str(envelope.allocation_id) + assert recorder.calls == [] + assert llm.received == [] + # The envelope window is its own lifecycle: activation to the + # terminal event's occurred_at (_LATER), never wall clock. + assert spend_lookup.total_windows == [(activated_at, _LATER)] + + @pytest.mark.unit async def test_apply_skips_entirely_when_agent_deprecated() -> None: """The lifecycle gate is Versioned-only: Deprecated is terminal diff --git a/apps/api/tests/unit/budget/_helpers.py b/apps/api/tests/unit/budget/_helpers.py index 949a9b350e3..8722acfe08b 100644 --- a/apps/api/tests/unit/budget/_helpers.py +++ b/apps/api/tests/unit/budget/_helpers.py @@ -56,11 +56,16 @@ def make_allocation( ) -def granted_event(allocation_id: UUID) -> AllocationGranted: +def granted_event( + allocation_id: UUID, + *, + ceiling_usd: float = 25000.0, + campaign_id: UUID | None = None, +) -> AllocationGranted: return AllocationGranted( allocation_id=allocation_id, - ceiling_usd=25000.0, - campaign_id=None, + ceiling_usd=ceiling_usd, + campaign_id=campaign_id, holder_note="FY26 imaging award", granted_by=GRANTED_BY, occurred_at=GRANTED_AT, diff --git a/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py b/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py new file mode 100644 index 00000000000..c48509649d0 --- /dev/null +++ b/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py @@ -0,0 +1,300 @@ +"""Unit tests for AllocationSealerSubscriber (CampaignClosed -> seal). + +Drives the subscriber against `InMemoryEventStore` seeds with local +lookup fakes so every path is pinned: the campaign-bound seal (with +the ledger snapshot and the SYSTEM principal), the skip family +(no Active envelope, unbound envelope, campaign mismatch, stale +projection row), and idempotent replay. +""" + +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +import pytest + +from cora.budget.aggregates.allocation import AllocationStatus, load_allocation +from cora.budget.subscribers.allocation_sealer import AllocationSealerSubscriber +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports.event_store import StoredEvent +from cora.infrastructure.ports.spend_lookup import TotalSpendResult +from cora.infrastructure.routing import SYSTEM_PRINCIPAL_ID +from tests.unit.budget._helpers import ( + ACTIVATED_AT, + activated_event, + granted_event, + seed_allocation_events, +) + +_CAMPAIGN_ID = uuid4() +_CLOSED_AT = datetime(2026, 7, 13, 18, 30, 0, tzinfo=UTC) + + +class _FakeAllocationLookup: + def __init__(self, active: ActiveAllocation | None) -> None: + self.active = active + + async def find_active(self) -> ActiveAllocation | None: + return self.active + + +class _FakeTotalSpendLookup: + """SpendLookup fake covering only the total-spend arm the sealer uses.""" + + def __init__(self, *, total_usd_spent: float = 0.0) -> None: + self.total_usd_spent = total_usd_spent + self.total_windows: list[tuple[datetime, datetime]] = [] + + async def find_total_spend( + self, + *, + window_start: datetime, + window_end: datetime, + ) -> TotalSpendResult: + self.total_windows.append((window_start, window_end)) + return TotalSpendResult( + window_start=window_start, + window_end=window_end, + usd_spent=self.total_usd_spent, + call_count=3, + ) + + +def _campaign_closed(campaign_id: UUID) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Campaign", + stream_id=campaign_id, + version=4, + event_type="CampaignClosed", + schema_version=1, + payload={"campaign_id": str(campaign_id), "occurred_at": _CLOSED_AT.isoformat()}, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_CLOSED_AT, + recorded_at=_CLOSED_AT, + ) + + +def _active_row(allocation_id: UUID, campaign_id: UUID | None) -> ActiveAllocation: + return ActiveAllocation( + allocation_id=allocation_id, + ceiling_usd=25000.0, + activated_at=ACTIVATED_AT, + campaign_id=campaign_id, + ) + + +async def _seed_active_allocation( + store: InMemoryEventStore, + allocation_id: UUID, + *, + campaign_id: UUID | None, +) -> None: + await seed_allocation_events( + store, + allocation_id, + granted_event(allocation_id, campaign_id=campaign_id), + activated_event(allocation_id), + ) + + +def _sealer( + store: InMemoryEventStore, + lookup: _FakeAllocationLookup, + spend: _FakeTotalSpendLookup | None = None, +) -> tuple[AllocationSealerSubscriber, _FakeTotalSpendLookup]: + spend = spend or _FakeTotalSpendLookup() + sub = AllocationSealerSubscriber( + event_store=store, + allocation_lookup=lookup, + spend_lookup=spend, # type: ignore[arg-type] # total-spend arm only + ) + return sub, spend + + +@pytest.mark.unit +def test_subscriber_metadata() -> None: + store = InMemoryEventStore() + sub, _ = _sealer(store, _FakeAllocationLookup(None)) + assert sub.name == "allocation_sealer" + assert sub.subscribed_event_types == frozenset({"CampaignClosed"}) + assert sub.batch_size == 1 + + +@pytest.mark.unit +async def test_bound_active_allocation_is_sealed_with_ledger_snapshot() -> None: + """The close-the-books path: the envelope bound to the closed + campaign folds Sealed, the snapshot is the ledger sum over + [activated_at, campaign-close), and the seal is attributed to the + SYSTEM principal (no operator issued a command).""" + store = InMemoryEventStore() + allocation_id = uuid4() + await _seed_active_allocation(store, allocation_id, campaign_id=_CAMPAIGN_ID) + sub, spend = _sealer( + store, + _FakeAllocationLookup(_active_row(allocation_id, _CAMPAIGN_ID)), + _FakeTotalSpendLookup(total_usd_spent=431.25), + ) + trigger = _campaign_closed(_CAMPAIGN_ID) + + await sub.apply(trigger, AsyncMock()) + + state = await load_allocation(store, allocation_id) + assert state is not None + assert state.status is AllocationStatus.SEALED + assert state.sealed_at == _CLOSED_AT + assert state.spent_usd_at_seal == 431.25 + assert state.sealed_by == SYSTEM_PRINCIPAL_ID + assert state.end_reason is not None + assert str(_CAMPAIGN_ID) in state.end_reason + assert spend.total_windows == [(ACTIVATED_AT, _CLOSED_AT)] + + stored, _version = await store.load("Allocation", allocation_id) + seal_envelope = stored[-1] + assert seal_envelope.event_type == "AllocationSealed" + assert seal_envelope.correlation_id == trigger.correlation_id + assert seal_envelope.causation_id == trigger.event_id + + +@pytest.mark.unit +async def test_no_active_allocation_skips_without_write() -> None: + store = InMemoryEventStore() + sub, spend = _sealer(store, _FakeAllocationLookup(None)) + + await sub.apply(_campaign_closed(_CAMPAIGN_ID), AsyncMock()) + + assert spend.total_windows == [] + + +@pytest.mark.unit +async def test_allocation_bound_to_another_campaign_is_left_active() -> None: + store = InMemoryEventStore() + allocation_id = uuid4() + other_campaign_id = uuid4() + await _seed_active_allocation(store, allocation_id, campaign_id=other_campaign_id) + sub, spend = _sealer( + store, _FakeAllocationLookup(_active_row(allocation_id, other_campaign_id)) + ) + + await sub.apply(_campaign_closed(_CAMPAIGN_ID), AsyncMock()) + + state = await load_allocation(store, allocation_id) + assert state is not None + assert state.status is AllocationStatus.ACTIVE + assert spend.total_windows == [] + + +@pytest.mark.unit +async def test_unbound_allocation_is_left_active() -> None: + """No campaign binding means no automatic seal: binding is what + opts an envelope into the campaign-close reflex.""" + store = InMemoryEventStore() + allocation_id = uuid4() + await _seed_active_allocation(store, allocation_id, campaign_id=None) + sub, spend = _sealer(store, _FakeAllocationLookup(_active_row(allocation_id, None))) + + await sub.apply(_campaign_closed(_CAMPAIGN_ID), AsyncMock()) + + state = await load_allocation(store, allocation_id) + assert state is not None + assert state.status is AllocationStatus.ACTIVE + assert spend.total_windows == [] + + +@pytest.mark.unit +async def test_stale_projection_row_for_granted_allocation_skips() -> None: + """The lookup says Active but the fold says Granted (projection + lag): the fold is the truth, so the sealer stands down instead of + letting the decider raise into the bookmark.""" + store = InMemoryEventStore() + allocation_id = uuid4() + await seed_allocation_events( + store, + allocation_id, + granted_event(allocation_id, campaign_id=_CAMPAIGN_ID), + ) + sub, spend = _sealer(store, _FakeAllocationLookup(_active_row(allocation_id, _CAMPAIGN_ID))) + + await sub.apply(_campaign_closed(_CAMPAIGN_ID), AsyncMock()) + + state = await load_allocation(store, allocation_id) + assert state is not None + assert state.status is AllocationStatus.GRANTED + assert spend.total_windows == [] + + +@pytest.mark.unit +async def test_replayed_delivery_after_seal_no_ops() -> None: + """At-least-once delivery: the second apply() finds the envelope + already Sealed (via the fold double-check) and leaves the stream + untouched, so the seal snapshot is never overwritten.""" + store = InMemoryEventStore() + allocation_id = uuid4() + await _seed_active_allocation(store, allocation_id, campaign_id=_CAMPAIGN_ID) + lookup = _FakeAllocationLookup(_active_row(allocation_id, _CAMPAIGN_ID)) + sub, spend = _sealer(store, lookup, _FakeTotalSpendLookup(total_usd_spent=431.25)) + trigger = _campaign_closed(_CAMPAIGN_ID) + + await sub.apply(trigger, AsyncMock()) + _stored_after_first, version_after_first = await store.load("Allocation", allocation_id) + + # The projection row may ALSO still say Active on replay (worker + # lag); the fold double-check is what makes the replay a no-op. + sub.allocation_lookup = lookup + later = StoredEvent( + position=trigger.position, + event_id=trigger.event_id, + stream_type=trigger.stream_type, + stream_id=trigger.stream_id, + version=trigger.version, + event_type=trigger.event_type, + schema_version=trigger.schema_version, + payload=trigger.payload, + correlation_id=trigger.correlation_id, + causation_id=trigger.causation_id, + occurred_at=trigger.occurred_at, + recorded_at=trigger.recorded_at + timedelta(seconds=5), + ) + await sub.apply(later, AsyncMock()) + + _stored_after_second, version_after_second = await store.load("Allocation", allocation_id) + assert version_after_second == version_after_first + assert len(spend.total_windows) == 1 + + +@pytest.mark.unit +async def test_non_trigger_event_type_is_ignored(monkeypatch: Any) -> None: + store = InMemoryEventStore() + lookup = _FakeAllocationLookup(None) + sub, _ = _sealer(store, lookup) + trigger = _campaign_closed(_CAMPAIGN_ID) + other = StoredEvent( + position=trigger.position, + event_id=trigger.event_id, + stream_type=trigger.stream_type, + stream_id=trigger.stream_id, + version=trigger.version, + event_type="CampaignHeld", + schema_version=trigger.schema_version, + payload=trigger.payload, + correlation_id=trigger.correlation_id, + causation_id=trigger.causation_id, + occurred_at=trigger.occurred_at, + recorded_at=trigger.recorded_at, + ) + + called = False + + async def _boom() -> ActiveAllocation | None: + nonlocal called + called = True + return None + + monkeypatch.setattr(lookup, "find_active", _boom) + await sub.apply(other, AsyncMock()) + + assert called is False diff --git a/apps/api/tests/unit/budget/test_allocation_summary_projection.py b/apps/api/tests/unit/budget/test_allocation_summary_projection.py new file mode 100644 index 00000000000..225b725e183 --- /dev/null +++ b/apps/api/tests/unit/budget/test_allocation_summary_projection.py @@ -0,0 +1,221 @@ +"""Unit tests for AllocationSummaryProjection. + +Pins per-event-type apply() dispatch for the 5 subscribed Allocation +lifecycle events. Postgres-side behavior is in the integration suite +(test_allocation_lookup_postgres.py exercises the read path). Mirrors +test_language_model_summary_projection.py. +""" + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from cora.budget.projections import AllocationSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent + +_ALLOCATION_ID = uuid4() +_CAMPAIGN_ID = uuid4() +_EVENT_ID = uuid4() +_CORRELATION_ID = uuid4() +_NOW = datetime(2026, 7, 13, 9, 0, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=_EVENT_ID, + stream_type="Allocation", + stream_id=_ALLOCATION_ID, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +@pytest.mark.unit +def test_projection_metadata() -> None: + proj = AllocationSummaryProjection() + assert proj.name == "proj_budget_allocation_summary" + assert proj.subscribed_event_types == frozenset( + { + "AllocationGranted", + "AllocationActivated", + "AllocationCeilingAmended", + "AllocationSealed", + "AllocationVoided", + } + ) + + +@pytest.mark.unit +async def test_allocation_granted_inserts_with_granted_status() -> None: + proj = AllocationSummaryProjection() + conn = AsyncMock() + event = _stored( + "AllocationGranted", + { + "allocation_id": str(_ALLOCATION_ID), + "ceiling_usd": 25000.0, + "campaign_id": str(_CAMPAIGN_ID), + "holder_note": "FY26 imaging award", + "granted_by": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + conn.execute.assert_awaited_once() + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "INSERT INTO proj_budget_allocation_summary" in sql + assert "ON CONFLICT (allocation_id) DO NOTHING" in sql + assert "'Granted'" in sql + assert args.args[1] == _ALLOCATION_ID + assert args.args[2] == 25000.0 + assert args.args[3] == _CAMPAIGN_ID + assert args.args[4] == "FY26 imaging award" + assert args.args[5] == _NOW + + +@pytest.mark.unit +async def test_allocation_granted_without_campaign_inserts_null_binding() -> None: + proj = AllocationSummaryProjection() + conn = AsyncMock() + event = _stored( + "AllocationGranted", + { + "allocation_id": str(_ALLOCATION_ID), + "ceiling_usd": 100.0, + "campaign_id": None, + "holder_note": "unbound envelope", + "granted_by": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[3] is None + + +@pytest.mark.unit +async def test_allocation_activated_updates_status_and_activated_at() -> None: + proj = AllocationSummaryProjection() + conn = AsyncMock() + event = _stored( + "AllocationActivated", + { + "allocation_id": str(_ALLOCATION_ID), + "activated_by": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_budget_allocation_summary" in sql + assert "SET status = 'Active'" in sql + assert "activated_at = $2" in sql + assert args.args[1] == _ALLOCATION_ID + assert args.args[2] == _NOW + + +@pytest.mark.unit +async def test_ceiling_amended_overwrites_ceiling_without_status_change() -> None: + proj = AllocationSummaryProjection() + conn = AsyncMock() + event = _stored( + "AllocationCeilingAmended", + { + "allocation_id": str(_ALLOCATION_ID), + "ceiling_usd": 12000.0, + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_budget_allocation_summary" in sql + assert "SET ceiling_usd = $2" in sql + assert "status" not in sql + assert args.args[1] == _ALLOCATION_ID + assert args.args[2] == 12000.0 + + +@pytest.mark.unit +async def test_allocation_sealed_updates_status_timestamp_and_snapshot() -> None: + proj = AllocationSummaryProjection() + conn = AsyncMock() + event = _stored( + "AllocationSealed", + { + "allocation_id": str(_ALLOCATION_ID), + "spent_usd": 431.25, + "reason": "Campaign closed", + "sealed_by": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_budget_allocation_summary" in sql + assert "SET status = 'Sealed'" in sql + assert "sealed_at = $2" in sql + assert "spent_usd_at_seal = $3" in sql + assert args.args[1] == _ALLOCATION_ID + assert args.args[2] == _NOW + assert args.args[3] == 431.25 + + +@pytest.mark.unit +async def test_allocation_voided_updates_status_only() -> None: + proj = AllocationSummaryProjection() + conn = AsyncMock() + event = _stored( + "AllocationVoided", + { + "allocation_id": str(_ALLOCATION_ID), + "reason": "granted by mistake", + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_budget_allocation_summary" in sql + assert "SET status = 'Voided'" in sql + assert args.args[1] == _ALLOCATION_ID + + +@pytest.mark.unit +async def test_unknown_event_type_falls_through_match() -> None: + proj = AllocationSummaryProjection() + conn = AsyncMock() + event = _stored("UnrelatedEvent", {}) + await proj.apply(event, conn) + conn.execute.assert_not_awaited() diff --git a/infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql b/infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql new file mode 100644 index 00000000000..c48cf9a0e53 --- /dev/null +++ b/infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql @@ -0,0 +1,41 @@ +-- The allocation envelope's read model: one row per Allocation, +-- queried by status. The envelope gate's `PostgresAllocationLookup` +-- asks "which envelope is Active right now?" on every gated LLM call, +-- and the CampaignClosed sealer asks the same question plus the +-- campaign binding; neither is answerable from the event store +-- without a by-status index, which is exactly what a projection is +-- for. +-- +-- Balance is deliberately absent: spend folds from +-- entries_decision_inferences at read time (the never-stored-balance +-- stance); only the seal freezes a snapshot (spent_usd_at_seal). +-- +-- Mutable read model. cora_app gets full DML. Bookmark seeded so the +-- projection worker advances from genesis on first run. + +CREATE TABLE proj_budget_allocation_summary ( + allocation_id UUID PRIMARY KEY, + ceiling_usd DOUBLE PRECISION NOT NULL CHECK (ceiling_usd > 0), + campaign_id UUID, + holder_note TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('Granted', 'Active', 'Sealed', 'Voided') + ), + granted_at TIMESTAMPTZ NOT NULL, + activated_at TIMESTAMPTZ, + sealed_at TIMESTAMPTZ, + spent_usd_at_seal DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The lookup's query shape: the newest-activated Active envelope. +CREATE INDEX proj_budget_allocation_summary_status_idx + ON proj_budget_allocation_summary (status, activated_at); + +GRANT SELECT, INSERT, UPDATE, DELETE + ON proj_budget_allocation_summary TO cora_app; + +INSERT INTO projection_bookmarks (name) +VALUES ('proj_budget_allocation_summary') +ON CONFLICT DO NOTHING; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index fb210ad42a7..e9964e806b1 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:eIoAo5tEj7taBRkIG9fTABxQsaSy8V0uJ9FtUb7fz2Y= +h1:Hy+Kjffwh6U5zweMzYLKt0c3ZcDS8bhCwgphpJQx02I= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -158,3 +158,4 @@ h1:eIoAo5tEj7taBRkIG9fTABxQsaSy8V0uJ9FtUb7fz2Y= 20260706000000_init_proj_trust_ratification_coverage.sql h1:YDYrypgQNOid1GnlfYbXI0j7300NUcGdu5L0Tsev/3I= 20260711000000_add_entries_decision_inferences_cost_usd.sql h1:Ebt1cyOO33cAzACL7Va1e8n7+kuPedHqVMln7mlWqHI= 20260712000000_init_proj_agent_language_model_summary.sql h1:HIICLCDFUvO5YP33NcgkpWSiU+Jo+PVSqUXUapnCtw0= +20260713000000_init_proj_budget_allocation_summary.sql h1:uwPaMErMybrGNUn8nwpiCC7md3ENdymmzl1MHh2QX3g= From eec44044c6c09d94546767b6364f027692c01ca6 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:50:59 +0300 Subject: [PATCH 3/3] Apply the allocation arc's five-reviewer panel: single-Active guard and hardening Security. The one real bug: activating a second envelope silently stopped enforcing the first's ceiling and orphaned its automatic seal, and the docstrings claimed a guard that did not exist. activate_allocation is now longhand (the seal precedent) and consults find_active before the transition, refusing with AllocationAlreadyActiveError when a DIFFERENT envelope is Active; the subscribers' remediation text now steers operators to seal or void before activating a replacement rather than orphaning the exhausted one. cost_usd gained a 100k upper bound at the inference write edge, so one poisoned entry can no longer exhaust the whole instrument's envelope or corrupt a sealed books figure. build_kernel raises in its Postgres branch when a financial factory is missing, rather than silently metering zero and disarming the envelope. The BC docstring names the instrument-admin authz tier (the five commands plus CloseCampaign, which transitively disarms via the auto-seal). Correctness. The sealer no longer forfeits the automatic seal on a benign concurrent amend: it re-loads and retries at the fresh version (bounded, deterministic event id keeps it idempotent), and it documents the stale-negative projection-lag window whose recovery is the manual seal slice. The fold-divergence skip logs at WARNING. Naming (per the panel): AllocationCannotAmendCeilingError (full verb phrase), AllocationBreach / find_allocation_breach (the fourth meaning of "envelope" was one too many; the paper word stays in prose), AllocationLookupResult (rejoins the LookupResult family), and note / AllocationNote (a name that needed a disclaimer in its own docstring). Conventions. Purged eleven build-stage markers that had leaked into source and test docstrings, rewording each to name the mechanism; the no-phase-markers fitness regex gains a lowercase-stage arm so letter-only markers cannot slip through again (Title-Case "Stage A" spared, it is the shape of real device names). Tests close the panel's mutation survivors: the single-Active guard both directions, the sealer retry and event-id determinism, gate ordering (cap before envelope, envelope not consulted) in both subscribers, the allocation_lookup factory wiring, the wire_budget ledger-reader binding, the Postgres projection apply arms, the daily-token equality edge, and the build_kernel fail-loud raise. Co-Authored-By: Claude Fable 5 --- apps/api/openapi.json | 11 +- apps/api/src/cora/agent/_budget_gate.py | 14 +- .../cora/agent/adapters/budget_spend_guard.py | 4 +- .../cora/agent/subscribers/caution_drafter.py | 7 +- .../cora/agent/subscribers/run_debriefer.py | 8 +- apps/api/src/cora/budget/__init__.py | 13 +- .../adapters/postgres_allocation_lookup.py | 16 +- .../budget/aggregates/allocation/__init__.py | 20 +- .../budget/aggregates/allocation/events.py | 10 +- .../budget/aggregates/allocation/evolver.py | 6 +- .../budget/aggregates/allocation/state.py | 49 +++-- .../features/activate_allocation/handler.py | 134 +++++++++++-- .../amend_allocation_ceiling/decider.py | 8 +- .../features/grant_allocation/__init__.py | 2 +- .../features/grant_allocation/command.py | 6 +- .../features/grant_allocation/decider.py | 14 +- .../budget/features/grant_allocation/route.py | 12 +- .../budget/features/grant_allocation/tool.py | 10 +- .../features/seal_allocation/command.py | 2 +- .../features/seal_allocation/handler.py | 4 +- .../src/cora/budget/projections/allocation.py | 4 +- apps/api/src/cora/budget/routes.py | 16 +- .../budget/subscribers/allocation_sealer.py | 187 +++++++++++------- apps/api/src/cora/budget/wire.py | 2 +- .../features/append_inferences/route.py | 7 +- .../features/append_inferences/tool.py | 6 +- apps/api/src/cora/infrastructure/deps.py | 41 ++-- .../src/cora/infrastructure/ports/__init__.py | 4 +- .../infrastructure/ports/allocation_lookup.py | 14 +- .../architecture/test_no_phase_markers.py | 18 ++ .../architecture/test_slice_test_coverage.py | 2 +- .../test_append_reasoning_entries_endpoint.py | 26 +++ .../test_allocation_lookup_postgres.py | 2 +- ..._postgres_allocation_summary_projection.py | 172 ++++++++++++++++ apps/api/tests/unit/_helpers.py | 2 +- apps/api/tests/unit/agent/_helpers.py | 6 +- apps/api/tests/unit/agent/test_budget_gate.py | 22 +-- .../unit/agent/test_budget_spend_guard.py | 23 ++- .../agent/test_caution_drafter_subscriber.py | 52 ++++- .../agent/test_run_debriefer_subscriber.py | 52 ++++- apps/api/tests/unit/budget/_helpers.py | 6 +- .../test_activate_allocation_handler.py | 91 +++++++++ .../unit/budget/test_allocation_evolver.py | 4 +- .../test_allocation_sealer_subscriber.py | 87 +++++++- .../test_allocation_summary_projection.py | 4 +- .../test_amend_allocation_ceiling_decider.py | 4 +- ...d_allocation_ceiling_decider_properties.py | 6 +- .../test_amend_allocation_ceiling_handler.py | 4 +- .../budget/test_grant_allocation_decider.py | 22 +-- ...est_grant_allocation_decider_properties.py | 14 +- .../budget/test_grant_allocation_handler.py | 6 +- .../budget/test_seal_allocation_handler.py | 36 +++- .../test_architecture_introspect.py | 4 +- apps/api/tests/unit/test_deps.py | 12 ++ ...00_init_proj_budget_allocation_summary.sql | 2 +- infra/atlas/migrations/atlas.sum | 4 +- 56 files changed, 1032 insertions(+), 282 deletions(-) create mode 100644 apps/api/tests/integration/test_postgres_allocation_summary_projection.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 052f6896adb..95d501acc60 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7826,17 +7826,17 @@ "title": "Ceiling Usd", "type": "number" }, - "holder_note": { + "note": { "description": "Operator-facing name for the envelope (award cycle, proposal block). The holder itself is implicitly this deployment's beamline.", "maxLength": 200, "minLength": 1, - "title": "Holder Note", + "title": "Note", "type": "string" } }, "required": [ "ceiling_usd", - "holder_note" + "note" ], "title": "GrantAllocationRequest", "type": "object" @@ -11142,6 +11142,7 @@ "cost_usd": { "anyOf": [ { + "maximum": 100000.0, "minimum": 0.0, "type": "number" }, @@ -11149,7 +11150,7 @@ "type": "null" } ], - "description": "Actual call cost in USD computed from usage tokens and provider pricing (CORA custom; no OTel attribute exists for call cost).", + "description": "Actual call cost in USD computed from usage tokens and provider pricing (CORA custom; no OTel attribute exists for call cost). Upper bound 100000: no single LLM call costs six figures, and an unbounded value would let one poisoned entry exhaust any instrument envelope and permanently corrupt a sealed books figure.", "title": "Cost Usd" }, "duration": { @@ -19277,7 +19278,7 @@ } } }, - "description": "Domain invariant violated (non-finite ceiling, whitespace-only holder note)." + "description": "Domain invariant violated (non-finite ceiling, whitespace-only note)." }, "403": { "content": { diff --git a/apps/api/src/cora/agent/_budget_gate.py b/apps/api/src/cora/agent/_budget_gate.py index 4abac208d67..0a376d16a5e 100644 --- a/apps/api/src/cora/agent/_budget_gate.py +++ b/apps/api/src/cora/agent/_budget_gate.py @@ -18,7 +18,7 @@ item gates identically (the non-determinism rule: subscribers decide from event facts, not ambient time). -`find_envelope_breach` is the instrument-wide arm above the per-agent +`find_allocation_breach` is the instrument-wide arm above the per-agent caps: when the deployment has an Active Allocation envelope (budget BC), instance-total spend over the envelope's own lifecycle window must stay inside its ceiling. Absent or sealed envelope means no @@ -141,7 +141,7 @@ async def find_budget_breach( @dataclass(frozen=True) -class EnvelopeBreach: +class AllocationBreach: """The Active allocation envelope cannot afford the next call.""" allocation_id: UUID @@ -158,13 +158,13 @@ def describe(self) -> str: ) -async def find_envelope_breach( +async def find_allocation_breach( *, allocation_lookup: "AllocationLookup", spend_lookup: "SpendLookup", as_of: datetime, pending_usd: float = 0.0, -) -> EnvelopeBreach | None: +) -> AllocationBreach | None: """Return the Active envelope's breach, or None to permit. The instrument-wide arm above the per-agent caps: with an Active @@ -206,7 +206,7 @@ async def find_envelope_breach( projected_over = spend.usd_spent + pending_usd > envelope.ceiling_usd exhausted_post_hoc = pending_usd == 0.0 and spend.usd_spent >= envelope.ceiling_usd if projected_over or exhausted_post_hoc: - return EnvelopeBreach( + return AllocationBreach( allocation_id=envelope.allocation_id, ceiling_usd=envelope.ceiling_usd, spent_usd=spend.usd_spent, @@ -216,10 +216,10 @@ async def find_envelope_breach( __all__ = [ + "AllocationBreach", "BudgetBreach", - "EnvelopeBreach", "calendar_day_window", "calendar_month_window", + "find_allocation_breach", "find_budget_breach", - "find_envelope_breach", ] diff --git a/apps/api/src/cora/agent/adapters/budget_spend_guard.py b/apps/api/src/cora/agent/adapters/budget_spend_guard.py index 03b25fa1f1a..74620550593 100644 --- a/apps/api/src/cora/agent/adapters/budget_spend_guard.py +++ b/apps/api/src/cora/agent/adapters/budget_spend_guard.py @@ -39,7 +39,7 @@ from cora.agent._budget_gate import ( calendar_day_window, calendar_month_window, - find_envelope_breach, + find_allocation_breach, ) from cora.agent.aggregates.agent import AgentStatus, load_agent from cora.infrastructure.ports import NoActiveAllocationLookup @@ -92,7 +92,7 @@ async def refusal_reason( if per_agent_reason is not None: return per_agent_reason - envelope_breach = await find_envelope_breach( + envelope_breach = await find_allocation_breach( allocation_lookup=self._allocation_lookup, spend_lookup=self._spend_lookup, as_of=as_of, diff --git a/apps/api/src/cora/agent/subscribers/caution_drafter.py b/apps/api/src/cora/agent/subscribers/caution_drafter.py index bd3f7629749..8eb1f80debf 100644 --- a/apps/api/src/cora/agent/subscribers/caution_drafter.py +++ b/apps/api/src/cora/agent/subscribers/caution_drafter.py @@ -80,7 +80,7 @@ from uuid import UUID, uuid5 from cora.access.aggregates.actor import load_actor -from cora.agent._budget_gate import find_budget_breach, find_envelope_breach +from cora.agent._budget_gate import find_allocation_breach, find_budget_breach from cora.agent._subscriber_lease import attempt_debrief_lease from cora.agent.aggregates.agent import AgentStatus, load_agent from cora.agent.prompts import ( @@ -368,7 +368,7 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: # Instrument-wide envelope gate (mirrors RunDebriefer): an # exhausted Active allocation stops every LLM caller regardless # of this agent's own headroom (post-hoc arm, pending 0). - envelope_breach = await find_envelope_breach( + envelope_breach = await find_allocation_breach( allocation_lookup=self.allocation_lookup, spend_lookup=self.spend_lookup, as_of=event.occurred_at, @@ -390,7 +390,8 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: reasoning=( f"Allocation exhausted: {envelope_breach.describe()}; LLM " "call skipped. Raise the ceiling via " - "amend_allocation_ceiling or grant a new envelope." + "amend_allocation_ceiling, or seal or void this envelope " + "and activate a new one." ), extra_inputs={ "failure_error_class": "AllocationExhausted", diff --git a/apps/api/src/cora/agent/subscribers/run_debriefer.py b/apps/api/src/cora/agent/subscribers/run_debriefer.py index 15d6c92c669..e659e5d9480 100644 --- a/apps/api/src/cora/agent/subscribers/run_debriefer.py +++ b/apps/api/src/cora/agent/subscribers/run_debriefer.py @@ -139,7 +139,7 @@ from uuid import UUID, uuid5 from cora.access.aggregates.actor import load_actor -from cora.agent._budget_gate import find_budget_breach, find_envelope_breach +from cora.agent._budget_gate import find_allocation_breach, find_budget_breach from cora.agent._subscriber_lease import attempt_debrief_lease from cora.agent.aggregates.agent import AgentStatus, load_agent from cora.agent.prompts import ( @@ -482,7 +482,7 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: # of that agent's own headroom (post-hoc arm, pending 0). Same # deferral composition as the cap breach so the one-Decision- # per-terminal-Run invariant holds and operators see WHY. - envelope_breach = await find_envelope_breach( + envelope_breach = await find_allocation_breach( allocation_lookup=self.allocation_lookup, spend_lookup=self.spend_lookup, as_of=event.occurred_at, @@ -504,8 +504,8 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: reasoning=( f"Allocation exhausted: {envelope_breach.describe()}; LLM " "call skipped. Raise the ceiling via " - "amend_allocation_ceiling or grant a new envelope, then " - "re-trigger the debrief." + "amend_allocation_ceiling, or seal or void this envelope " + "and activate a new one, then re-trigger the debrief." ), extra_inputs={ "failure_error_class": "AllocationExhausted", diff --git a/apps/api/src/cora/budget/__init__.py b/apps/api/src/cora/budget/__init__.py index 0626eb790d5..e010892ee6c 100644 --- a/apps/api/src/cora/budget/__init__.py +++ b/apps/api/src/cora/budget/__init__.py @@ -4,14 +4,14 @@ beamline receives and spends, the system-of-record half of the facility LLM-budget arc. Design lock: [[project_allocation_design]]. - - `Allocation` aggregate: identity + USD ceiling + holder note + + - `Allocation` aggregate: identity + USD ceiling + note + optional Campaign binding + 4-state FSM `Granted -> Active -> Sealed`, with `Voided` from Granted/Active. Balance is never stored: it folds from the inference ledger every other spend tier sums. The allocation's own lifecycle IS the award window (`activated_at` to `sealed_at`); the gate stack's envelope -check (stage C) reads the Active envelope's ceiling against the +check reads the Active envelope's ceiling against the instance-total ledger fold over that window. Lifecycle slices: `grant_allocation` (genesis -> Granted, dormant), @@ -22,6 +22,15 @@ `void_allocation` (Granted | Active -> Voided; operator withdraws a mistaken grant, REQUIRED reason). +Authz posture: every one of these five commands bounds the whole +instrument's LLM spend, so a real Trust policy should treat them as a +single instrument-admin tier, kept off the routine-operator grant. +`CloseCampaign` belongs in that tier too: the CampaignClosed sealer +seals the bound envelope, and a sealed envelope no longer constrains +spend, so authority to close a campaign transitively lifts the +ceiling. Under the default `AllowAllAuthorize` posture all of these +are open, the pre-existing deployment default, not new here. + Layout: aggregates// -- aggregate state, events union, evolver, read features/_/ -- vertical slice: command + decider + handler + route + tool diff --git a/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py b/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py index b9839ba3c26..63361b1fc68 100644 --- a/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py +++ b/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py @@ -4,10 +4,12 @@ `proj_budget_allocation_summary` is this BC's read model of its own aggregate. The lookup answers the GATE's question, the deployment's single Active envelope. At-most-one-Active is enforced best-effort at -grant/activate time; should an overlap slip through that race, the -newest `activated_at` wins (the most recently opened window is the -operator's current intent) with `allocation_id DESC` making -equal-timestamp rows deterministic, per the port contract. +activate time (activate_allocation refuses with +AllocationAlreadyActiveError when another envelope is already Active); +should an overlap slip through that projection-read race, the newest +`activated_at` wins (the most recently opened window is the operator's +current intent) with `allocation_id DESC` making equal-timestamp rows +deterministic, per the port contract. """ from __future__ import annotations @@ -15,7 +17,7 @@ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false from typing import TYPE_CHECKING -from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult if TYPE_CHECKING: import asyncpg @@ -35,12 +37,12 @@ class PostgresAllocationLookup: def __init__(self, pool: asyncpg.Pool) -> None: self._pool = pool - async def find_active(self) -> ActiveAllocation | None: + async def find_active(self) -> AllocationLookupResult | None: async with self._pool.acquire() as conn: row = await conn.fetchrow(_FIND_ACTIVE_SQL) if row is None: return None - return ActiveAllocation( + return AllocationLookupResult( allocation_id=row["allocation_id"], ceiling_usd=row["ceiling_usd"], activated_at=row["activated_at"], diff --git a/apps/api/src/cora/budget/aggregates/allocation/__init__.py b/apps/api/src/cora/budget/aggregates/allocation/__init__.py index b464d71bd94..036330b1102 100644 --- a/apps/api/src/cora/budget/aggregates/allocation/__init__.py +++ b/apps/api/src/cora/budget/aggregates/allocation/__init__.py @@ -1,4 +1,4 @@ -"""Allocation aggregate: status FSM, holder-note and reason VOs, +"""Allocation aggregate: status FSM, note and reason VOs, ceiling validation, errors, events, evolver, read repo. The spending envelope the deployment's beamline receives and spends, @@ -26,43 +26,45 @@ from cora.budget.aggregates.allocation.evolver import evolve, fold from cora.budget.aggregates.allocation.read import load_allocation from cora.budget.aggregates.allocation.state import ( - ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + ALLOCATION_NOTE_MAX_LENGTH, Allocation, + AllocationAlreadyActiveError, AllocationAlreadyExistsError, AllocationCannotActivateError, - AllocationCannotAmendError, + AllocationCannotAmendCeilingError, AllocationCannotSealError, AllocationCannotVoidError, - AllocationHolderNote, + AllocationNote, AllocationNotFoundError, AllocationReason, AllocationStatus, InvalidAllocationCeilingError, - InvalidAllocationHolderNoteError, + InvalidAllocationNoteError, InvalidAllocationReasonError, validate_allocation_ceiling, ) __all__ = [ - "ALLOCATION_HOLDER_NOTE_MAX_LENGTH", + "ALLOCATION_NOTE_MAX_LENGTH", "Allocation", "AllocationActivated", + "AllocationAlreadyActiveError", "AllocationAlreadyExistsError", "AllocationCannotActivateError", - "AllocationCannotAmendError", + "AllocationCannotAmendCeilingError", "AllocationCannotSealError", "AllocationCannotVoidError", "AllocationCeilingAmended", "AllocationEvent", "AllocationGranted", - "AllocationHolderNote", "AllocationNotFoundError", + "AllocationNote", "AllocationReason", "AllocationSealed", "AllocationStatus", "AllocationVoided", "InvalidAllocationCeilingError", - "InvalidAllocationHolderNoteError", + "InvalidAllocationNoteError", "InvalidAllocationReasonError", "event_type_name", "evolve", diff --git a/apps/api/src/cora/budget/aggregates/allocation/events.py b/apps/api/src/cora/budget/aggregates/allocation/events.py index c9fa910eb8b..40ca1eaa568 100644 --- a/apps/api/src/cora/budget/aggregates/allocation/events.py +++ b/apps/api/src/cora/budget/aggregates/allocation/events.py @@ -58,14 +58,14 @@ class AllocationGranted: `campaign_id` is a bare cross-BC reference (eventual-consistency stance per the Caution / Calibration precedent); when set, the - stage-C CampaignClosed subscriber seals this envelope beside the + CampaignClosed subscriber seals this envelope beside the campaign's own books. """ allocation_id: UUID ceiling_usd: float campaign_id: UUID | None - holder_note: str + note: str granted_by: ActorId occurred_at: datetime @@ -158,7 +158,7 @@ def to_payload(event: AllocationEvent) -> dict[str, Any]: allocation_id=allocation_id, ceiling_usd=ceiling_usd, campaign_id=campaign_id, - holder_note=holder_note, + note=note, granted_by=granted_by, occurred_at=occurred_at, ): @@ -166,7 +166,7 @@ def to_payload(event: AllocationEvent) -> dict[str, Any]: "allocation_id": str(allocation_id), "ceiling_usd": ceiling_usd, "campaign_id": str(campaign_id) if campaign_id is not None else None, - "holder_note": holder_note, + "note": note, "granted_by": str(granted_by), "occurred_at": occurred_at.isoformat(), } @@ -243,7 +243,7 @@ def _build_granted() -> AllocationGranted: allocation_id=UUID(payload["allocation_id"]), ceiling_usd=payload["ceiling_usd"], campaign_id=(UUID(campaign_id_raw) if campaign_id_raw is not None else None), - holder_note=payload["holder_note"], + note=payload["note"], granted_by=ActorId(UUID(payload["granted_by"])), occurred_at=datetime.fromisoformat(payload["occurred_at"]), ) diff --git a/apps/api/src/cora/budget/aggregates/allocation/evolver.py b/apps/api/src/cora/budget/aggregates/allocation/evolver.py index 10e31c50328..9bdd304d700 100644 --- a/apps/api/src/cora/budget/aggregates/allocation/evolver.py +++ b/apps/api/src/cora/budget/aggregates/allocation/evolver.py @@ -49,7 +49,7 @@ class the Agent evolver guards against field-by-field cannot occur ) from cora.budget.aggregates.allocation.state import ( Allocation, - AllocationHolderNote, + AllocationNote, AllocationStatus, ) from cora.infrastructure.evolver import require_state @@ -62,7 +62,7 @@ def evolve(state: Allocation | None, event: AllocationEvent) -> Allocation: allocation_id=allocation_id, ceiling_usd=ceiling_usd, campaign_id=campaign_id, - holder_note=holder_note, + note=note, granted_by=granted_by, occurred_at=occurred_at, ): @@ -70,7 +70,7 @@ def evolve(state: Allocation | None, event: AllocationEvent) -> Allocation: return Allocation( id=allocation_id, ceiling_usd=ceiling_usd, - holder_note=AllocationHolderNote(holder_note), + note=AllocationNote(note), campaign_id=campaign_id, granted_at=occurred_at, granted_by=granted_by, diff --git a/apps/api/src/cora/budget/aggregates/allocation/state.py b/apps/api/src/cora/budget/aggregates/allocation/state.py index 6894ff1f187..5ac7af8df9d 100644 --- a/apps/api/src/cora/budget/aggregates/allocation/state.py +++ b/apps/api/src/cora/budget/aggregates/allocation/state.py @@ -8,7 +8,7 @@ Balance is never stored: it folds from the same inference ledger every other spend tier sums, so this aggregate carries only the envelope's -declared shape (ceiling, holder note, window timestamps) and its FSM. +declared shape (ceiling, note, window timestamps) and its FSM. Per [[project_lifecycle_status_naming]] the FSM is a single axis, `AllocationStatus`: @@ -50,7 +50,7 @@ from cora.shared.identity import ActorId from cora.shared.text_bounds import REASON_MAX_LENGTH -ALLOCATION_HOLDER_NOTE_MAX_LENGTH = 200 +ALLOCATION_NOTE_MAX_LENGTH = 200 class AllocationStatus(StrEnum): @@ -93,12 +93,12 @@ def __init__(self, value: float) -> None: self.value = value -class InvalidAllocationHolderNoteError(ValueError): - """The supplied holder note is empty, whitespace-only, or too long.""" +class InvalidAllocationNoteError(ValueError): + """The supplied note is empty, whitespace-only, or too long.""" def __init__(self, value: str) -> None: super().__init__( - f"Allocation holder note must be 1-{ALLOCATION_HOLDER_NOTE_MAX_LENGTH} chars " + f"Allocation note must be 1-{ALLOCATION_NOTE_MAX_LENGTH} chars " f"after trimming (got: {value!r})" ) self.value = value @@ -157,7 +157,30 @@ def __init__(self, allocation_id: UUID, current_status: "AllocationStatus") -> N self.current_status = current_status -class AllocationCannotAmendError(Exception): +class AllocationAlreadyActiveError(Exception): + """Attempted to activate a second envelope while one is already Active. + + Best-effort single-writer guard on the deployment's one instrument + envelope: the gates enforce a single Active allocation's ceiling and + the sealer closes a single Active allocation on CampaignClosed, so a + second Active envelope would silently reset the spend window (older + ceiling stops being enforced) and orphan the first (its auto-seal + would never fire). The active allocation must be sealed or voided + before another is activated. The check reads the projection, so a + tight concurrent race is possible and accepted; the invariant is a + guard, not a lock. + """ + + def __init__(self, allocation_id: UUID, active_allocation_id: UUID) -> None: + super().__init__( + f"Allocation {allocation_id} cannot be activated: allocation " + f"{active_allocation_id} is already Active; seal or void it first" + ) + self.allocation_id = allocation_id + self.active_allocation_id = active_allocation_id + + +class AllocationCannotAmendCeilingError(Exception): """Attempted `amend_allocation_ceiling` from a disqualifying status. Source set is `{Granted, Active}`: the ceiling is the cost-overrun @@ -220,17 +243,15 @@ def __init__(self, allocation_id: UUID, current_status: "AllocationStatus") -> N @bounded_name( - max_length=ALLOCATION_HOLDER_NOTE_MAX_LENGTH, - error_class=InvalidAllocationHolderNoteError, + max_length=ALLOCATION_NOTE_MAX_LENGTH, + error_class=InvalidAllocationNoteError, ) @dataclass(frozen=True) -class AllocationHolderNote: +class AllocationNote: """Operator-facing name for the envelope. Trimmed; 1-200 chars. - A note, not a holder reference: v1's holder is implicitly the - deployment's own beamline, so the note names the envelope for - operators (award cycle, proposal block) rather than pointing at a - holder aggregate that does not exist yet. + The note labels the envelope for operators (award cycle, proposal + block). """ value: str @@ -289,7 +310,7 @@ class Allocation: id: UUID ceiling_usd: float - holder_note: AllocationHolderNote + note: AllocationNote campaign_id: UUID | None granted_at: datetime granted_by: ActorId diff --git a/apps/api/src/cora/budget/features/activate_allocation/handler.py b/apps/api/src/cora/budget/features/activate_allocation/handler.py index 5b2bae36405..c6db920267e 100644 --- a/apps/api/src/cora/budget/features/activate_allocation/handler.py +++ b/apps/api/src/cora/budget/features/activate_allocation/handler.py @@ -1,21 +1,47 @@ """Application handler for the `activate_allocation` slice. -Built on the actor-stamping `make_allocation_actor_update_handler` -factory: the fold records `(activated_at, activated_by)` per +Longhand, not factory-built, for the same reason `seal_allocation` is: +it consults a CROSS-stream fact between authorize and append that no +single-stream update factory expresses. Activation opens the +deployment's one instrument spend window, so before it fires the +handler asks `allocation_lookup.find_active()` and refuses with +`AllocationAlreadyActiveError` if a DIFFERENT envelope is already +Active. That makes the single-Active invariant the gates and the +sealer rely on best-effort true: without it, activating a second +envelope would silently stop enforcing the first's ceiling and orphan +its automatic seal. + +The check reads the summary projection, so a tight concurrent +activate race is possible and accepted; the invariant is a guard, not +a lock. The fold still records `(activated_at, activated_by)` per [[project_fold_symmetry_design]], so the handler threads the envelope's `principal_id` into the decider as `activated_by`. -Single-source from Granted; the decider's guard enforces this, the -factory is source-set-agnostic. """ from typing import Protocol from uuid import UUID -from cora.budget._allocation_update_handler import make_allocation_actor_update_handler +from cora.budget.aggregates.allocation import ( + AllocationAlreadyActiveError, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.budget.errors import UnauthorizedError from cora.budget.features.activate_allocation.command import ActivateAllocation from cora.budget.features.activate_allocation.decider import decide +from cora.infrastructure.event_envelope import to_new_event from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.shared.identity import ActorId + +_STREAM_TYPE = "Allocation" +_COMMAND_NAME = "ActivateAllocation" + +_log = get_logger("activate_allocation") class Handler(Protocol): @@ -34,10 +60,94 @@ async def __call__( def bind(deps: Kernel) -> Handler: """Build an activate_allocation handler closed over the shared deps.""" - return make_allocation_actor_update_handler( - deps, - command_name="ActivateAllocation", - log_prefix="activate_allocation", - decide_fn=decide, - actor_kwarg="activated_by", - ) + + async def handler( + command: ActivateAllocation, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + allocation_id = command.allocation_id + _log.info( + "activate_allocation.start", + command_name=_COMMAND_NAME, + allocation_id=str(allocation_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "activate_allocation.denied", + command_name=_COMMAND_NAME, + allocation_id=str(allocation_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + active = await deps.allocation_lookup.find_active() + if active is not None and active.allocation_id != allocation_id: + _log.info( + "activate_allocation.already_active", + command_name=_COMMAND_NAME, + allocation_id=str(allocation_id), + active_allocation_id=str(active.allocation_id), + correlation_id=str(correlation_id), + ) + raise AllocationAlreadyActiveError(allocation_id, active.allocation_id) + + now = deps.clock.now() + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=allocation_id, + ) + state = fold([from_stored(s) for s in stored]) + + domain_events = decide( + state=state, + command=command, + now=now, + activated_by=ActorId(principal_id), + ) + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=allocation_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "activate_allocation.success", + command_name=_COMMAND_NAME, + allocation_id=str(allocation_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py b/apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py index 1b15ef91a35..4dc4d5a0a27 100644 --- a/apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py +++ b/apps/api/src/cora/budget/features/amend_allocation_ceiling/decider.py @@ -9,7 +9,7 @@ - State must not be None -> `AllocationNotFoundError` - Current status must be Granted or Active - -> `AllocationCannotAmendError` + -> `AllocationCannotAmendCeilingError` - `ceiling_usd` must be finite and strictly positive -> `InvalidAllocationCeilingError` """ @@ -18,7 +18,7 @@ from cora.budget.aggregates.allocation import ( Allocation, - AllocationCannotAmendError, + AllocationCannotAmendCeilingError, AllocationCeilingAmended, AllocationNotFoundError, AllocationStatus, @@ -43,14 +43,14 @@ def decide( Invariants: - State must not be None -> AllocationNotFoundError - Current status must be Granted or Active - -> AllocationCannotAmendError + -> AllocationCannotAmendCeilingError - Ceiling must be finite and strictly positive -> InvalidAllocationCeilingError """ if state is None: raise AllocationNotFoundError(command.allocation_id) if state.status not in _AMENDABLE_STATUSES: - raise AllocationCannotAmendError(state.id, state.status) + raise AllocationCannotAmendCeilingError(state.id, state.status) # Validate BEFORE the idempotency short-circuit so a bad ceiling # fires even when it happens to equal the stored value shape-wise. diff --git a/apps/api/src/cora/budget/features/grant_allocation/__init__.py b/apps/api/src/cora/budget/features/grant_allocation/__init__.py index 95916f23624..6326830a9c1 100644 --- a/apps/api/src/cora/budget/features/grant_allocation/__init__.py +++ b/apps/api/src/cora/budget/features/grant_allocation/__init__.py @@ -7,7 +7,7 @@ cmd = grant_allocation.GrantAllocation( ceiling_usd=25000.0, - holder_note="FY26 imaging award", + note="FY26 imaging award", ) handler = grant_allocation.bind(deps) allocation_id = await handler(cmd, principal_id=..., correlation_id=...) diff --git a/apps/api/src/cora/budget/features/grant_allocation/command.py b/apps/api/src/cora/budget/features/grant_allocation/command.py index 5c44fda03e7..526c87dea8d 100644 --- a/apps/api/src/cora/budget/features/grant_allocation/command.py +++ b/apps/api/src/cora/budget/features/grant_allocation/command.py @@ -1,9 +1,9 @@ """The `GrantAllocation` command -- intent dataclass for this slice. Carries the caller-controlled fields for one spending envelope: the -USD ceiling, the operator-facing holder note, and an optional +USD ceiling, the operator-facing note, and an optional `campaign_id` that binds the award window to a Campaign's lifecycle -(the stage-C CampaignClosed subscriber seals a campaign-bound +(the CampaignClosed subscriber seals a campaign-bound envelope beside the campaign's own books). `allocation_id` is optional. None (the default) keeps the @@ -27,6 +27,6 @@ class GrantAllocation: """Grant a new spending envelope (lands in Granted, dormant).""" ceiling_usd: float - holder_note: str + note: str campaign_id: UUID | None = None allocation_id: UUID | None = None diff --git a/apps/api/src/cora/budget/features/grant_allocation/decider.py b/apps/api/src/cora/budget/features/grant_allocation/decider.py index aabbbfeccda..2c7c5a77f73 100644 --- a/apps/api/src/cora/budget/features/grant_allocation/decider.py +++ b/apps/api/src/cora/budget/features/grant_allocation/decider.py @@ -15,8 +15,8 @@ - State must be None (genesis-only) -> `AllocationAlreadyExistsError` - `ceiling_usd` must be finite and strictly positive -> `InvalidAllocationCeilingError` - - `holder_note` wrapped via `AllocationHolderNote(...)`; 1-200 chars - after trim -> `InvalidAllocationHolderNoteError` + - `note` wrapped via `AllocationNote(...)`; 1-200 chars + after trim -> `InvalidAllocationNoteError` - `campaign_id` is a bare cross-BC reference; NOT resolved here (eventual-consistency stance per the Caution / Calibration precedent), so no validation beyond the boundary's UUID typing. @@ -32,7 +32,7 @@ Allocation, AllocationAlreadyExistsError, AllocationGranted, - AllocationHolderNote, + AllocationNote, validate_allocation_ceiling, ) from cora.budget.features.grant_allocation.command import GrantAllocation @@ -53,21 +53,21 @@ def decide( - State must be None (genesis-only) -> AllocationAlreadyExistsError - Ceiling must be finite and strictly positive -> InvalidAllocationCeilingError - - Holder note must be valid -> InvalidAllocationHolderNoteError - (via AllocationHolderNote VO) + - Note must be valid -> InvalidAllocationNoteError + (via AllocationNote VO) """ if state is not None: raise AllocationAlreadyExistsError(state.id) validate_allocation_ceiling(command.ceiling_usd) - holder_note = AllocationHolderNote(command.holder_note) + note = AllocationNote(command.note) return [ AllocationGranted( allocation_id=new_id, ceiling_usd=command.ceiling_usd, campaign_id=command.campaign_id, - holder_note=holder_note.value, + note=note.value, granted_by=granted_by, occurred_at=now, ) diff --git a/apps/api/src/cora/budget/features/grant_allocation/route.py b/apps/api/src/cora/budget/features/grant_allocation/route.py index 2a8465bcf57..98741830297 100644 --- a/apps/api/src/cora/budget/features/grant_allocation/route.py +++ b/apps/api/src/cora/budget/features/grant_allocation/route.py @@ -1,6 +1,6 @@ """HTTP route for the `grant_allocation` slice. -`POST /allocations` with body carrying ceiling_usd / holder_note + +`POST /allocations` with body carrying ceiling_usd / note + optional campaign_id / allocation_id. Returns 201 + `{allocation_id}` on success. @@ -16,7 +16,7 @@ from fastapi import APIRouter, Depends, Header, Request, status from pydantic import BaseModel, Field -from cora.budget.aggregates.allocation import ALLOCATION_HOLDER_NOTE_MAX_LENGTH +from cora.budget.aggregates.allocation import ALLOCATION_NOTE_MAX_LENGTH from cora.budget.features.grant_allocation.command import GrantAllocation from cora.budget.features.grant_allocation.handler import IdempotentHandler from cora.infrastructure.routing import ( @@ -35,10 +35,10 @@ class GrantAllocationRequest(BaseModel): gt=0.0, description="USD spending ceiling for the envelope. Finite and greater than 0.", ) - holder_note: str = Field( + note: str = Field( ..., min_length=1, - max_length=ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + max_length=ALLOCATION_NOTE_MAX_LENGTH, description=( "Operator-facing name for the envelope (award cycle, proposal " "block). The holder itself is implicitly this deployment's beamline." @@ -84,7 +84,7 @@ def _get_handler(request: Request) -> IdempotentHandler: status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, "description": ( - "Domain invariant violated (non-finite ceiling, whitespace-only holder note)." + "Domain invariant violated (non-finite ceiling, whitespace-only note)." ), }, status.HTTP_403_FORBIDDEN: { @@ -120,7 +120,7 @@ async def post_allocations( allocation_id = await handler( GrantAllocation( ceiling_usd=body.ceiling_usd, - holder_note=body.holder_note, + note=body.note, campaign_id=body.campaign_id, allocation_id=body.allocation_id, ), diff --git a/apps/api/src/cora/budget/features/grant_allocation/tool.py b/apps/api/src/cora/budget/features/grant_allocation/tool.py index 9cf7b90d9e0..cfae36e01c0 100644 --- a/apps/api/src/cora/budget/features/grant_allocation/tool.py +++ b/apps/api/src/cora/budget/features/grant_allocation/tool.py @@ -11,7 +11,7 @@ from mcp.server.fastmcp import Context, FastMCP from pydantic import BaseModel, Field -from cora.budget.aggregates.allocation import ALLOCATION_HOLDER_NOTE_MAX_LENGTH +from cora.budget.aggregates.allocation import ALLOCATION_NOTE_MAX_LENGTH from cora.budget.features.grant_allocation.command import GrantAllocation from cora.budget.features.grant_allocation.handler import IdempotentHandler from cora.infrastructure.mcp_principal import get_mcp_principal_id @@ -33,7 +33,7 @@ def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> N description=( "Grant a new spending envelope for this deployment's beamline " "(lands in Granted, dormant; a separate activation opens the " - "spend window). Required: ceiling_usd, holder_note. Optional: " + "spend window). Required: ceiling_usd, note. Optional: " "campaign_id (binds the award window to a Campaign), " "allocation_id (omit to mint server-side)." ), @@ -47,11 +47,11 @@ async def grant_allocation_tool( # pyright: ignore[reportUnusedFunction] description="USD spending ceiling. Finite and greater than 0.", ), ], - holder_note: Annotated[ + note: Annotated[ str, Field( min_length=1, - max_length=ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + max_length=ALLOCATION_NOTE_MAX_LENGTH, description="Operator-facing name for the envelope.", ), ], @@ -80,7 +80,7 @@ async def grant_allocation_tool( # pyright: ignore[reportUnusedFunction] new_id = await handler( GrantAllocation( ceiling_usd=ceiling_usd, - holder_note=holder_note, + note=note, campaign_id=campaign_id, allocation_id=allocation_id, ), diff --git a/apps/api/src/cora/budget/features/seal_allocation/command.py b/apps/api/src/cora/budget/features/seal_allocation/command.py index f6cd043e6f3..740be303239 100644 --- a/apps/api/src/cora/budget/features/seal_allocation/command.py +++ b/apps/api/src/cora/budget/features/seal_allocation/command.py @@ -5,7 +5,7 @@ at seal time by folding the inference ledger over the envelope's own window (`activated_at` to the seal instant) via the injected TotalSpendReader, so a caller can never assert a figure the ledger -does not support. The stage-C CampaignClosed subscriber and the REST +does not support. The CampaignClosed subscriber and the REST route both drive this one slice. `reason` is optional: a routine end-of-window seal needs no diff --git a/apps/api/src/cora/budget/features/seal_allocation/handler.py b/apps/api/src/cora/budget/features/seal_allocation/handler.py index c0edbc28108..66512e932d8 100644 --- a/apps/api/src/cora/budget/features/seal_allocation/handler.py +++ b/apps/api/src/cora/budget/features/seal_allocation/handler.py @@ -11,7 +11,7 @@ `total_spend_reader` is bound at wire time, NOT resolved from Kernel: `wire.py` binds `make_ledger_total_spend(deps.spend_lookup)` (the -SpendLookup-backed fold the stage-A seam promised), so the +SpendLookup-backed fold), so the CampaignClosed sealer subscriber and the REST route both flow through this one slice with the same ledger answer. `zero_total_spend` remains exported for tests that want a seal without a ledger. @@ -72,7 +72,7 @@ async def __call__(self, *, window_start: datetime, window_end: datetime) -> flo async def zero_total_spend(*, window_start: datetime, window_end: datetime) -> float: """Ledger-less reader: every seal records `spent_usd = 0.0`. - Was the stage-A wire binding before `find_total_spend` existed; + The reader that predates `find_total_spend`; kept exported for tests that exercise the seal FSM without standing up an inference ledger (the figure is honestly the reader's answer, and a zero reader states that intent loudly). diff --git a/apps/api/src/cora/budget/projections/allocation.py b/apps/api/src/cora/budget/projections/allocation.py index f32fda40360..28b2b9803f0 100644 --- a/apps/api/src/cora/budget/projections/allocation.py +++ b/apps/api/src/cora/budget/projections/allocation.py @@ -27,7 +27,7 @@ _INSERT_SQL = """ INSERT INTO proj_budget_allocation_summary - (allocation_id, ceiling_usd, campaign_id, holder_note, status, + (allocation_id, ceiling_usd, campaign_id, note, status, granted_at, created_at) VALUES ($1, $2, $3, $4, 'Granted', $5, $5) ON CONFLICT (allocation_id) DO NOTHING @@ -92,7 +92,7 @@ async def apply( UUID(event.payload["allocation_id"]), event.payload["ceiling_usd"], (UUID(campaign_id_raw) if campaign_id_raw is not None else None), - event.payload["holder_note"], + event.payload["note"], datetime.fromisoformat(event.payload["occurred_at"]), ) case "AllocationActivated": diff --git a/apps/api/src/cora/budget/routes.py b/apps/api/src/cora/budget/routes.py index 55e26199b17..639490750d7 100644 --- a/apps/api/src/cora/budget/routes.py +++ b/apps/api/src/cora/budget/routes.py @@ -20,7 +20,7 @@ pattern: - 400 (validation): InvalidAllocationCeiling, - InvalidAllocationHolderNote, InvalidAllocationReason + InvalidAllocationNote, InvalidAllocationReason - 404 (load miss): AllocationNotFound - 409 (defensive guard for AlreadyExists): AllocationAlreadyExists - 409 (transition guards): AllocationCannotActivate, @@ -31,14 +31,15 @@ from fastapi.responses import JSONResponse from cora.budget.aggregates.allocation import ( + AllocationAlreadyActiveError, AllocationAlreadyExistsError, AllocationCannotActivateError, - AllocationCannotAmendError, + AllocationCannotAmendCeilingError, AllocationCannotSealError, AllocationCannotVoidError, AllocationNotFoundError, InvalidAllocationCeilingError, - InvalidAllocationHolderNoteError, + InvalidAllocationNoteError, InvalidAllocationReasonError, ) from cora.budget.errors import UnauthorizedError @@ -116,17 +117,20 @@ def register_budget_routes(app: FastAPI) -> None: app.include_router(void_allocation.router) for validation_cls in ( InvalidAllocationCeilingError, - InvalidAllocationHolderNoteError, + InvalidAllocationNoteError, InvalidAllocationReasonError, ): app.add_exception_handler(validation_cls, _handle_validation_error) for not_found_cls in (AllocationNotFoundError,): app.add_exception_handler(not_found_cls, _handle_not_found) - for already_exists_cls in (AllocationAlreadyExistsError,): + for already_exists_cls in ( + AllocationAlreadyExistsError, + AllocationAlreadyActiveError, + ): app.add_exception_handler(already_exists_cls, _handle_already_exists) for cannot_transition_cls in ( AllocationCannotActivateError, - AllocationCannotAmendError, + AllocationCannotAmendCeilingError, AllocationCannotSealError, AllocationCannotVoidError, ): diff --git a/apps/api/src/cora/budget/subscribers/allocation_sealer.py b/apps/api/src/cora/budget/subscribers/allocation_sealer.py index 13fb33c82e4..dfffc954fe1 100644 --- a/apps/api/src/cora/budget/subscribers/allocation_sealer.py +++ b/apps/api/src/cora/budget/subscribers/allocation_sealer.py @@ -32,18 +32,31 @@ CampaignClosed event's `occurred_at`, not wall clock, so a replayed delivery derives the same event (the subscribers' determinism rule). The seal event's `event_id` is a UUIDv5 of (trigger event, allocation) -and the append is guarded by the loaded stream version, so a -re-delivery after a successful seal either short-circuits on the -non-Active pre-guard or no-ops on `ConcurrencyError`. Skips (no -Active envelope, unbound envelope, campaign mismatch, lost race) log -at info and advance the bookmark; nothing here may wedge it. +and the append is guarded by the loaded stream version. A benign +concurrent write (an operator `amend_allocation_ceiling` landing +between load and append) bumps the version while the envelope is +still Active, so instead of forfeiting the automatic seal the sealer +re-loads and retries the append at the fresh version (bounded, a few +attempts); the deterministic event id keeps the retry idempotent. A +re-delivery after a real seal short-circuits on the non-Active fold +pre-guard. Skips (no Active envelope, unbound envelope, campaign +mismatch, retry exhaustion) advance the bookmark; nothing here may +wedge it. -## Lookup + fold double-check +## Lookup + fold double-check, and the projection-lag caveat `AllocationLookup.find_active` (projection-backed) finds the candidate cheaply, then the aggregate stream is loaded and folded -before deciding: the projection may lag its own stream, and the fold -is the truth the optimistic append is versioned against. +before deciding: the fold is the truth the optimistic append is +versioned against. The double-check guards the stale-POSITIVE +direction (projection still shows Active, fold says otherwise) and +logs it at WARNING as a real divergence. It cannot guard the +stale-NEGATIVE direction: because subscribers run on independent +cursors, the summary projection may not yet reflect an +`AllocationActivated` that landed just before CampaignClosed, so a +`no_active_allocation` or `campaign_mismatch` skip can be a false +negative. A missed automatic seal is recovered by the operator's +manual `seal_allocation` slice, which reads the stream directly. """ from __future__ import annotations @@ -87,6 +100,13 @@ # expected-version guard. _ALLOCATION_SEALER_NAMESPACE = UUID("01900000-0000-7000-8000-0000a10c0002") +_MAX_SEAL_ATTEMPTS = 3 +"""Bounded reload-and-retry on a lost seal race. A benign concurrent +writer (an operator amend while the envelope is still Active) bumps the +stream version; rather than forfeit the automatic seal, the sealer +re-loads and retries at the fresh version. Three attempts covers the +realistic burst; beyond it the seal is deferred to the manual slice.""" + _log = get_logger(__name__) @@ -146,78 +166,105 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: ) return - stored, current_version = await self.event_store.load( - stream_type=_STREAM_TYPE, - stream_id=active.allocation_id, - ) - state = fold([from_stored(s) for s in stored]) - if state is None or state.status is not AllocationStatus.ACTIVE: - # Stale projection row or a replayed delivery after the seal: - # either way the envelope is not an open window anymore. - log.info( - "allocation_sealer.skip.not_active", - allocation_id=str(active.allocation_id), - status=(str(state.status) if state is not None else None), + allocation_id = active.allocation_id + for attempt in range(_MAX_SEAL_ATTEMPTS): + stored, current_version = await self.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=allocation_id, ) - return + state = fold([from_stored(s) for s in stored]) + if state is None or state.status is not AllocationStatus.ACTIVE: + # Fold disagrees with the projection that named this + # candidate Active: a stale-positive projection row, or a + # replayed delivery after the seal already landed. Either + # way the window is closed; the divergence is worth a + # WARNING (the projection is behind its own stream). + log.warning( + "allocation_sealer.skip.not_active", + allocation_id=str(allocation_id), + status=(str(state.status) if state is not None else None), + ) + return + if state.campaign_id != closed_campaign_id: + # The stream rebound to another campaign since the + # projection read; not this CampaignClosed's envelope. + log.info( + "allocation_sealer.skip.campaign_mismatch_on_fold", + allocation_id=str(allocation_id), + bound_campaign_id=( + str(state.campaign_id) if state.campaign_id is not None else None + ), + ) + return - window_start = state.activated_at - assert window_start is not None # Active state always folds activated_at - spent_usd = await self._total_spend_reader( - window_start=window_start, - window_end=event.occurred_at, - ) + window_start = state.activated_at + assert window_start is not None # Active state always folds activated_at + spent_usd = await self._total_spend_reader( + window_start=window_start, + window_end=event.occurred_at, + ) - command = SealAllocation( - allocation_id=state.id, - reason=f"Campaign {closed_campaign_id} closed; books sealed automatically.", - ) - domain_events = decide( - state=state, - command=command, - now=event.occurred_at, - spent_usd=spent_usd, - sealed_by=ActorId(SYSTEM_PRINCIPAL_ID), - ) - new_events = [ - to_new_event( - event_type=event_type_name(domain_event), - payload=to_payload(domain_event), - occurred_at=domain_event.occurred_at, - event_id=uuid5( - _ALLOCATION_SEALER_NAMESPACE, - f"seal:{event.event_id}:{state.id}", - ), - command_name=_COMMAND_NAME, - correlation_id=event.correlation_id, - causation_id=event.event_id, - principal_id=SYSTEM_PRINCIPAL_ID, + command = SealAllocation( + allocation_id=state.id, + reason=f"Campaign {closed_campaign_id} closed; books sealed automatically.", ) - for domain_event in domain_events - ] - try: - await self.event_store.append( - stream_type=_STREAM_TYPE, - stream_id=state.id, - expected_version=current_version, - events=new_events, + domain_events = decide( + state=state, + command=command, + now=event.occurred_at, + spent_usd=spent_usd, + sealed_by=ActorId(SYSTEM_PRINCIPAL_ID), ) - except ConcurrencyError: - # The stream advanced between load and append (an operator - # sealed or voided concurrently, or a duplicate delivery - # raced). The next delivery, if any, re-evaluates fresh. + new_events = [ + to_new_event( + event_type=event_type_name(domain_event), + payload=to_payload(domain_event), + occurred_at=domain_event.occurred_at, + event_id=uuid5( + _ALLOCATION_SEALER_NAMESPACE, + f"seal:{event.event_id}:{state.id}", + ), + command_name=_COMMAND_NAME, + correlation_id=event.correlation_id, + causation_id=event.event_id, + principal_id=SYSTEM_PRINCIPAL_ID, + ) + for domain_event in domain_events + ] + try: + await self.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=state.id, + expected_version=current_version, + events=new_events, + ) + except ConcurrencyError: + # The stream advanced between load and append. If a benign + # writer (an operator amend) bumped the version while the + # envelope is still Active, re-load and retry; the + # deterministic event id keeps this idempotent. A concurrent + # seal or void lands the next iteration on the non-Active + # pre-guard and returns. + if attempt + 1 < _MAX_SEAL_ATTEMPTS: + log.info( + "allocation_sealer.seal_conflict_retry", + allocation_id=str(state.id), + attempt=attempt, + ) + continue + log.warning( + "allocation_sealer.skip.seal_retries_exhausted", + allocation_id=str(state.id), + ) + return + log.info( - "allocation_sealer.skip.lost_seal_race", + "allocation_sealer.sealed", allocation_id=str(state.id), + spent_usd=spent_usd, ) return - log.info( - "allocation_sealer.sealed", - allocation_id=str(state.id), - spent_usd=spent_usd, - ) - def make_allocation_sealer_subscriber(deps: Kernel) -> AllocationSealerSubscriber: """Build the allocation sealer closed over the shared deps.""" diff --git a/apps/api/src/cora/budget/wire.py b/apps/api/src/cora/budget/wire.py index eb79e958821..05eb0b4ebbc 100644 --- a/apps/api/src/cora/budget/wire.py +++ b/apps/api/src/cora/budget/wire.py @@ -29,7 +29,7 @@ every wiring site states which ledger fold the seal snapshot records. Production binds `make_ledger_total_spend(deps.spend_lookup)`, the instance-total fold over `entries_decision_inferences` (the one-line -swap the stage-A `zero_total_spend` seam promised). +swap the `zero_total_spend` seam promised). """ from dataclasses import dataclass diff --git a/apps/api/src/cora/decision/features/append_inferences/route.py b/apps/api/src/cora/decision/features/append_inferences/route.py index 4e38b52583a..a98f430defa 100644 --- a/apps/api/src/cora/decision/features/append_inferences/route.py +++ b/apps/api/src/cora/decision/features/append_inferences/route.py @@ -114,10 +114,15 @@ class ReasoningEntryRequest(BaseModel): cost_usd: float | None = Field( default=None, ge=0.0, + le=100_000.0, allow_inf_nan=False, description=( "Actual call cost in USD computed from usage tokens and provider " - "pricing (CORA custom; no OTel attribute exists for call cost)." + "pricing (CORA custom; no OTel attribute exists for call cost). " + "Upper bound 100000: no single LLM call costs six figures, and " + "an unbounded value would let one poisoned entry exhaust any " + "instrument envelope and permanently corrupt a sealed books " + "figure." ), ) agent_id: str | None = Field( diff --git a/apps/api/src/cora/decision/features/append_inferences/tool.py b/apps/api/src/cora/decision/features/append_inferences/tool.py index fe7a512d18f..9f05c94fbde 100644 --- a/apps/api/src/cora/decision/features/append_inferences/tool.py +++ b/apps/api/src/cora/decision/features/append_inferences/tool.py @@ -111,11 +111,15 @@ async def append_reasoning_entries_tool( # pyright: ignore[reportUnusedFunction Field( default=None, ge=0.0, + le=100_000.0, allow_inf_nan=False, description=( "Actual call cost in USD computed from usage tokens and " "provider pricing (CORA custom; no OTel attribute exists " - "for call cost)." + "for call cost). Upper bound 100000: no single LLM call " + "costs six figures, and an unbounded value would let one " + "poisoned entry exhaust any instrument envelope and " + "permanently corrupt a sealed books figure." ), ), ] = None, diff --git a/apps/api/src/cora/infrastructure/deps.py b/apps/api/src/cora/infrastructure/deps.py index 85bcaa5d378..6b04d1cf7df 100644 --- a/apps/api/src/cora/infrastructure/deps.py +++ b/apps/api/src/cora/infrastructure/deps.py @@ -244,6 +244,14 @@ def make_postgres_kernel( `supply_lookup_factory` argument; gate-specific tests override here explicitly. See [[project_supply_preflight_gate_design]]. + `spend_lookup` and `allocation_lookup` default to the disarmed + stubs (`AlwaysZeroSpendLookup` / `NoActiveAllocationLookup`) here, + the same opt-in posture as every other lookup: an integration test + that does not exercise the envelope never meters or gates on it. + The fail-loud requirement lives one layer up, in `build_kernel`'s + Postgres branch, which raises when either financial factory is + missing so a real deployment can never silently meter zero. + `run_actor_involvement_lookup` defaults to `NoInvolvementLookup` (returns `[]`) so existing tests don't have to seed runs. Production's `build_kernel` injects the real @@ -323,13 +331,6 @@ def make_postgres_kernel( `model_usage_lookup_factory` argument; slice-specific tests override here explicitly. - `allocation_lookup` defaults to `NoActiveAllocationLookup` (no - envelope Active) so existing tests and allocation-less deployments - keep the unconstrained envelope behavior; activating an allocation - is what arms the gate. Production's `build_kernel` injects the real - `PostgresAllocationLookup` via the `allocation_lookup_factory` - argument; envelope-gate tests override here explicitly. - `llm` defaults to `None` because most BCs and tests don't need an LLM; only Agent BC subscribers consume it. Production's `build_kernel` injects `AnthropicLLM` when @@ -577,7 +578,7 @@ def make_inmemory_kernel( `proj_budget_allocation_summary` table to read from, and the envelope check must stay disarmed for every test that never declared an allocation. Envelope-gate tests override with a fake - returning a chosen `ActiveAllocation`. + returning a chosen `AllocationLookupResult`. `llm` defaults to `None`; the in-memory kernel is for unit / contract tests that don't exercise LLM subscribers. Subscriber @@ -1164,6 +1165,18 @@ async def build_kernel( ) return kernel, _noop_teardown + if spend_lookup_factory is None or allocation_lookup_factory is None: + # Fail loud, not open: a Postgres deployment that silently fell back + # to the zero-spend stub and the no-active-envelope stub would meter + # every call at $0 and disarm the instrument ceiling, and every seal + # would record $0 as the official closing figure. The financial + # lookups are the one place a missing binding must stop startup. + raise ValueError( + "build_kernel requires spend_lookup_factory and " + "allocation_lookup_factory in a Postgres deployment; a missing " + "financial lookup would silently meter zero and disarm the " + "spend envelope" + ) pool = await create_pool( settings.database_url, min_size=settings.db_pool_min_size, @@ -1198,9 +1211,8 @@ async def build_kernel( if supply_lookup_factory is not None else AllSatisfiedSupplyLookup() ) - spend_lookup: SpendLookup = ( - spend_lookup_factory(pool) if spend_lookup_factory is not None else AlwaysZeroSpendLookup() - ) + # Non-None guaranteed by the fail-loud guard above. + spend_lookup: SpendLookup = spend_lookup_factory(pool) language_model_lookup: LanguageModelLookup = ( language_model_lookup_factory(pool) if language_model_lookup_factory is not None @@ -1211,11 +1223,8 @@ async def build_kernel( if model_usage_lookup_factory is not None else AlwaysEmptyModelUsageLookup() ) - allocation_lookup: AllocationLookup = ( - allocation_lookup_factory(pool) - if allocation_lookup_factory is not None - else NoActiveAllocationLookup() - ) + # Non-None guaranteed by the fail-loud guard above. + allocation_lookup: AllocationLookup = allocation_lookup_factory(pool) run_actor_involvement_lookup: RunActorInvolvementLookup = ( run_actor_involvement_lookup_factory(pool) if run_actor_involvement_lookup_factory is not None diff --git a/apps/api/src/cora/infrastructure/ports/__init__.py b/apps/api/src/cora/infrastructure/ports/__init__.py index 8e2cab34124..9403cfdcb9c 100644 --- a/apps/api/src/cora/infrastructure/ports/__init__.py +++ b/apps/api/src/cora/infrastructure/ports/__init__.py @@ -6,8 +6,8 @@ """ from cora.infrastructure.ports.allocation_lookup import ( - ActiveAllocation, AllocationLookup, + AllocationLookupResult, NoActiveAllocationLookup, ) from cora.infrastructure.ports.assembly_lookup import ( @@ -187,11 +187,11 @@ __all__ = [ "LLM", - "ActiveAllocation", "AgentInferenceTrace", "AllBeamOpenLookup", "AllSatisfiedSupplyLookup", "AllocationLookup", + "AllocationLookupResult", "Allow", "AllowAllAuthorize", "AlwaysApprovedLanguageModelLookup", diff --git a/apps/api/src/cora/infrastructure/ports/allocation_lookup.py b/apps/api/src/cora/infrastructure/ports/allocation_lookup.py index 2d4cf66562d..3b659708e27 100644 --- a/apps/api/src/cora/infrastructure/ports/allocation_lookup.py +++ b/apps/api/src/cora/infrastructure/ports/allocation_lookup.py @@ -1,7 +1,7 @@ """AllocationLookup port: which spending envelope is currently Active? Consumed by the envelope arm of the budget gate stack: the coarse -post-hoc gate (`cora.agent._budget_gate.find_envelope_breach`), the +post-hoc gate (`cora.agent._budget_gate.find_allocation_breach`), the pre-estimate `BudgetSpendGuard`, and the budget BC's own CampaignClosed sealer subscriber. All three ask the same one-row question: "is an envelope Active right now, and what are its ceiling @@ -26,7 +26,9 @@ activating an allocation is what arms the gate, the same opt-in posture as every lookup in the family, so every existing test and allocation-less deployment is unaffected. The at-most-one-Active -invariant is enforced best-effort at grant/activate time; on an +invariant is enforced best-effort at activate time (activate_allocation +refuses with AllocationAlreadyActiveError when another envelope is +already Active); on an anomalous overlap the adapter answers with the newest-activated envelope (documented on `find_active`), so the gate keys on the most recent operator intent rather than refusing to answer. @@ -39,7 +41,7 @@ @dataclass(frozen=True) -class ActiveAllocation: +class AllocationLookupResult: """The deployment's currently Active spending envelope. `activated_at` is the spend-window start every instance-total @@ -56,7 +58,7 @@ class ActiveAllocation: class AllocationLookup(Protocol): """Cross-cutting port: resolve the deployment's single Active envelope.""" - async def find_active(self) -> ActiveAllocation | None: + async def find_active(self) -> AllocationLookupResult | None: """Return the Active allocation envelope, or None when none is Active. None disarms the envelope check entirely (no envelope, no @@ -78,12 +80,12 @@ class NoActiveAllocationLookup: posture: activating an envelope is what arms the gate). """ - async def find_active(self) -> ActiveAllocation | None: + async def find_active(self) -> AllocationLookupResult | None: return None __all__ = [ - "ActiveAllocation", "AllocationLookup", + "AllocationLookupResult", "NoActiveAllocationLookup", ] diff --git a/apps/api/tests/architecture/test_no_phase_markers.py b/apps/api/tests/architecture/test_no_phase_markers.py index f3ca459676f..86e971539e2 100644 --- a/apps/api/tests/architecture/test_no_phase_markers.py +++ b/apps/api/tests/architecture/test_no_phase_markers.py @@ -116,6 +116,16 @@ # this regex sees the line. r"\bStage[ -][0-9][a-z]?(?:-[a-z]+)?\b" r"|" + # Letter-only build-stage marker in prose (`stage-A`, `stage C`). + # The digit-first arm above requires a number, so a plan that labels + # its steps A/B/C instead of 1/2/3 slipped straight through. Bounded + # to a LOWERCASE `stage`: an inline build reference is written + # lowercase, whereas Title-Case `Stage` is the shape of real beamline + # device names (`Rotary Stage A`, an asset named `Stage-A`), so + # matching those would be a false positive. The lowercase form is + # false-positive-free across the tree. + r"\bstage[ -][A-D]\b" + r"|" # Implicit phase reference: prep word followed by a hyphenated phase # tag like `pre-7e`, `post-6g-c`, `from 6f-1`, `in 11a-c-3`, # `until 9b-b`. The first chunk is bounded to 1-2 letters so @@ -211,6 +221,9 @@ def _violations_for_line(line: str) -> str | None: ("Stage 2c-credential lands the rotation slices.", "Stage 2c-credential"), # Stage arm: bare digit (no letter). ("Picked per Stage 0 corpus survey.", "Stage 0"), + # Stage arm: letter-only build marker (the shape that leaked). + ("The check (stage-A) reads the ceiling.", "stage-A"), + ("Wired in stage C.", "stage C"), ], ) def test_stage_arm_matches_planning_markers(line: str, expected: str) -> None: @@ -252,6 +265,11 @@ def test_new_arms_match_known_drift(line: str, expected: str) -> None: "Refer to [[project-control-port-design]] (no stage tag).", # Capitalized "Stage" outside the marker shape (no digit) is fine. "Stage left to drop off the prop.", + # Lowercase prose after "stage" (a physical stage, not a marker). + "the sample stage a technician aligns by hand", + # Title-Case device name with a space (e.g. `Rotary Stage A`) is + # a real beamline component, not a build marker. + 'name="Rotary Stage A"', # Domain noun: ISA-88 Phase is allowed. "Phase IS a Procedure in ISA-88 terms.", # The substring "phase" inside a longer identifier should not trip: diff --git a/apps/api/tests/architecture/test_slice_test_coverage.py b/apps/api/tests/architecture/test_slice_test_coverage.py index a12273ac91f..715bd0733b8 100644 --- a/apps/api/tests/architecture/test_slice_test_coverage.py +++ b/apps/api/tests/architecture/test_slice_test_coverage.py @@ -166,7 +166,7 @@ # status-code mapping stay uncovered until the contract suite # lands. "cora.agent.features.list_at_risk_results", - # Allocation slices (budget BC, allocation arc stage A): REST + # Allocation slices (budget BC): REST # contract tests deferred so the family's REST + MCP suite can # be authored together (the LanguageModel / Frame / Facility # precedent). Decider + PBT + handler unit tests diff --git a/apps/api/tests/contract/test_append_reasoning_entries_endpoint.py b/apps/api/tests/contract/test_append_reasoning_entries_endpoint.py index b1aab644352..46eba425108 100644 --- a/apps/api/tests/contract/test_append_reasoning_entries_endpoint.py +++ b/apps/api/tests/contract/test_append_reasoning_entries_endpoint.py @@ -191,6 +191,32 @@ def test_post_reasoning_entries_rejects_missing_required_field_with_422() -> Non assert response.status_code == 422 +@pytest.mark.contract +def test_post_reasoning_entries_rejects_cost_usd_above_upper_bound_with_422() -> None: + """cost_usd is capped at 100000 USD per entry: no single LLM call + costs six figures, and an unbounded value would let one poisoned + entry exhaust any instrument envelope.""" + with TestClient(create_app()) as client: + decision_id = _seed_decision(client) + response = client.post( + f"/decisions/{decision_id}/inferences", + json={"entries": [_good_entry(cost_usd=100_000.01)]}, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_reasoning_entries_accepts_cost_usd_exactly_at_upper_bound() -> None: + """The bound is inclusive: a 100000 USD entry is the largest legal figure.""" + with TestClient(create_app()) as client: + decision_id = _seed_decision(client) + response = client.post( + f"/decisions/{decision_id}/inferences", + json={"entries": [_good_entry(cost_usd=100_000.0)]}, + ) + assert response.status_code == 200 + + @pytest.mark.contract def test_post_reasoning_entries_rejects_extra_fields_with_422() -> None: """`extra: forbid` on both request schemas.""" diff --git a/apps/api/tests/integration/test_allocation_lookup_postgres.py b/apps/api/tests/integration/test_allocation_lookup_postgres.py index 921665bb5bc..696cbb2ed53 100644 --- a/apps/api/tests/integration/test_allocation_lookup_postgres.py +++ b/apps/api/tests/integration/test_allocation_lookup_postgres.py @@ -23,7 +23,7 @@ _INSERT_SQL = """ INSERT INTO proj_budget_allocation_summary - (allocation_id, ceiling_usd, campaign_id, holder_note, status, + (allocation_id, ceiling_usd, campaign_id, note, status, granted_at, activated_at, created_at) VALUES ($1, $2, $3, 'FY26 imaging award', $4, $5, $6, $5) """ diff --git a/apps/api/tests/integration/test_postgres_allocation_summary_projection.py b/apps/api/tests/integration/test_postgres_allocation_summary_projection.py new file mode 100644 index 00000000000..4c32990ea99 --- /dev/null +++ b/apps/api/tests/integration/test_postgres_allocation_summary_projection.py @@ -0,0 +1,172 @@ +"""End-to-end: `proj_budget_allocation_summary` against real Postgres. + +Exercises every AllocationSummaryProjection arm against the real +projection table, so the column names, the status CHECK, the +`ceiling_usd > 0` CHECK, and the ON CONFLICT idempotency are validated +against the schema rather than an AsyncMock: + + - AllocationGranted -> INSERT row status=Granted, nullable + campaign_id, ceiling + note + - AllocationGranted (again) -> ON CONFLICT DO NOTHING (no duplicate, + no second row) + - AllocationActivated -> UPDATE status=Active, activated_at + - AllocationCeilingAmended -> UPDATE ceiling_usd (PUT semantics) + - AllocationSealed -> UPDATE status=Sealed, sealed_at, + spent_usd_at_seal + - AllocationVoided (own row) -> UPDATE status=Voided +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.budget.projections.allocation import AllocationSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent + +_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC) +_LATER = datetime(2026, 7, 13, 18, 0, 0, tzinfo=UTC) + + +def _event(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Allocation", + stream_id=UUID(payload["allocation_id"]), + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + recorded_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + +async def _fetch(conn: Any, allocation_id: UUID) -> Any: + return await conn.fetchrow( + "SELECT * FROM proj_budget_allocation_summary WHERE allocation_id = $1", + allocation_id, + ) + + +@pytest.mark.integration +async def test_lifecycle_arms_land_in_the_summary_table(db_pool: asyncpg.Pool) -> None: + proj = AllocationSummaryProjection() + allocation_id = uuid4() + aid = str(allocation_id) + campaign_id = uuid4() + + async with db_pool.acquire() as conn: + await proj.apply( + _event( + "AllocationGranted", + { + "allocation_id": aid, + "ceiling_usd": 500.0, + "campaign_id": str(campaign_id), + "note": "APS 2-BM award 2026-2", + "occurred_at": _NOW.isoformat(), + }, + ), + conn, + ) + # A re-delivered genesis must not duplicate or overwrite. + await proj.apply( + _event( + "AllocationGranted", + { + "allocation_id": aid, + "ceiling_usd": 999.0, + "campaign_id": None, + "note": "should be ignored", + "occurred_at": _NOW.isoformat(), + }, + ), + conn, + ) + row = await _fetch(conn, allocation_id) + assert row["status"] == "Granted" + assert float(row["ceiling_usd"]) == 500.0 + assert row["campaign_id"] == campaign_id + assert row["note"] == "APS 2-BM award 2026-2" + count = await conn.fetchval( + "SELECT count(*) FROM proj_budget_allocation_summary WHERE allocation_id = $1", + allocation_id, + ) + assert count == 1 + + await proj.apply( + _event( + "AllocationActivated", + {"allocation_id": aid, "occurred_at": _NOW.isoformat()}, + ), + conn, + ) + await proj.apply( + _event( + "AllocationCeilingAmended", + {"allocation_id": aid, "ceiling_usd": 300.0, "occurred_at": _NOW.isoformat()}, + ), + conn, + ) + await proj.apply( + _event( + "AllocationSealed", + {"allocation_id": aid, "spent_usd": 275.5, "occurred_at": _LATER.isoformat()}, + ), + conn, + ) + row = await _fetch(conn, allocation_id) + assert row["status"] == "Sealed" + assert float(row["ceiling_usd"]) == 300.0 # the amend won + assert row["activated_at"] is not None + assert row["sealed_at"] == _LATER + assert float(row["spent_usd_at_seal"]) == 275.5 + + # clean up so a re-run of the suite starts fresh + await conn.execute( + "DELETE FROM proj_budget_allocation_summary WHERE allocation_id = $1", + allocation_id, + ) + + +@pytest.mark.integration +async def test_voided_arm_updates_status(db_pool: asyncpg.Pool) -> None: + proj = AllocationSummaryProjection() + allocation_id = uuid4() + aid = str(allocation_id) + + async with db_pool.acquire() as conn: + await proj.apply( + _event( + "AllocationGranted", + { + "allocation_id": aid, + "ceiling_usd": 100.0, + "campaign_id": None, + "note": "mistaken grant", + "occurred_at": _NOW.isoformat(), + }, + ), + conn, + ) + await proj.apply( + _event( + "AllocationVoided", + {"allocation_id": aid, "occurred_at": _NOW.isoformat()}, + ), + conn, + ) + row = await _fetch(conn, allocation_id) + assert row["status"] == "Voided" + await conn.execute( + "DELETE FROM proj_budget_allocation_summary WHERE allocation_id = $1", + allocation_id, + ) diff --git a/apps/api/tests/unit/_helpers.py b/apps/api/tests/unit/_helpers.py index 01076be1be4..c1f20d78e19 100644 --- a/apps/api/tests/unit/_helpers.py +++ b/apps/api/tests/unit/_helpers.py @@ -209,7 +209,7 @@ def build_deps( `allocation_lookup` injects an envelope-lookup fake for the budget-gate and allocation-sealer tests (typically a fake - returning a chosen `ActiveAllocation`). Defaults to the kernel's + returning a chosen `AllocationLookupResult`). Defaults to the kernel's never-Active stub via `make_inmemory_kernel`, so tests that never declared an allocation keep the envelope check disarmed. """ diff --git a/apps/api/tests/unit/agent/_helpers.py b/apps/api/tests/unit/agent/_helpers.py index d1a8ff153a1..95441b7314f 100644 --- a/apps/api/tests/unit/agent/_helpers.py +++ b/apps/api/tests/unit/agent/_helpers.py @@ -28,7 +28,7 @@ from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore from cora.infrastructure.event_envelope import to_new_event from cora.infrastructure.ports import AgentInferenceTrace -from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult from cora.infrastructure.ports.spend_lookup import SpendLookupResult, TotalSpendResult from cora.infrastructure.signing import event_type_to_payload_type from cora.shared.content_hash import canonical_body_bytes, pae_bytes @@ -113,11 +113,11 @@ class FakeAllocationLookup: can pin that the disarmed path never reaches the total-spend sum. """ - def __init__(self, active: ActiveAllocation | None = None) -> None: + def __init__(self, active: AllocationLookupResult | None = None) -> None: self.active = active self.find_active_calls = 0 - async def find_active(self) -> ActiveAllocation | None: + async def find_active(self) -> AllocationLookupResult | None: self.find_active_calls += 1 return self.active diff --git a/apps/api/tests/unit/agent/test_budget_gate.py b/apps/api/tests/unit/agent/test_budget_gate.py index 0008d88fede..310453260f4 100644 --- a/apps/api/tests/unit/agent/test_budget_gate.py +++ b/apps/api/tests/unit/agent/test_budget_gate.py @@ -8,13 +8,13 @@ from cora.agent._budget_gate import ( calendar_day_window, calendar_month_window, + find_allocation_breach, find_budget_breach, - find_envelope_breach, ) from cora.agent.aggregates.agent import load_agent from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore from cora.infrastructure.ports.allocation_lookup import ( - ActiveAllocation, + AllocationLookupResult, NoActiveAllocationLookup, ) from tests.unit.agent._helpers import ( @@ -30,8 +30,8 @@ _ACTIVATED_AT = datetime(2026, 7, 1, 8, 0, 0, tzinfo=UTC) -def _envelope(*, ceiling_usd: float = 100.0) -> ActiveAllocation: - return ActiveAllocation( +def _envelope(*, ceiling_usd: float = 100.0) -> AllocationLookupResult: + return AllocationLookupResult( allocation_id=uuid4(), ceiling_usd=ceiling_usd, activated_at=_ACTIVATED_AT, @@ -198,7 +198,7 @@ async def test_zero_cap_refuses_every_call() -> None: assert breach.cap_kind == "monthly_usd_cap" -# ---------- find_envelope_breach ---------- +# ---------- find_allocation_breach ---------- @pytest.mark.unit @@ -207,7 +207,7 @@ async def test_no_active_envelope_permits_without_summing_spend() -> None: SUM is never issued on the disarmed path.""" spend = FakeSpendLookup(total_usd_spent=1_000_000.0) - breach = await find_envelope_breach( + breach = await find_allocation_breach( allocation_lookup=NoActiveAllocationLookup(), spend_lookup=spend, as_of=_NOW, @@ -224,7 +224,7 @@ async def test_envelope_spend_below_ceiling_permits_over_the_lifecycle_window() envelope = _envelope(ceiling_usd=100.0) spend = FakeSpendLookup(total_usd_spent=99.99) - breach = await find_envelope_breach( + breach = await find_allocation_breach( allocation_lookup=FakeAllocationLookup(envelope), spend_lookup=spend, as_of=_NOW, @@ -240,7 +240,7 @@ async def test_post_hoc_exactly_exhausted_envelope_breaches() -> None: exactly on the ceiling refuses the NEXT call (the >= arm).""" envelope = _envelope(ceiling_usd=100.0) - breach = await find_envelope_breach( + breach = await find_allocation_breach( allocation_lookup=FakeAllocationLookup(envelope), spend_lookup=FakeSpendLookup(total_usd_spent=100.0), as_of=_NOW, @@ -261,7 +261,7 @@ async def test_pre_estimate_projection_landing_exactly_on_ceiling_grants() -> No convention).""" envelope = _envelope(ceiling_usd=100.0) - breach = await find_envelope_breach( + breach = await find_allocation_breach( allocation_lookup=FakeAllocationLookup(envelope), spend_lookup=FakeSpendLookup(total_usd_spent=99.5), as_of=_NOW, @@ -275,7 +275,7 @@ async def test_pre_estimate_projection_landing_exactly_on_ceiling_grants() -> No async def test_pre_estimate_projection_over_ceiling_breaches() -> None: envelope = _envelope(ceiling_usd=100.0) - breach = await find_envelope_breach( + breach = await find_allocation_breach( allocation_lookup=FakeAllocationLookup(envelope), spend_lookup=FakeSpendLookup(total_usd_spent=99.5), as_of=_NOW, @@ -292,7 +292,7 @@ async def test_envelope_breach_describe_names_the_allocation() -> None: must name the envelope and its window start for operators.""" envelope = _envelope(ceiling_usd=100.0) - breach = await find_envelope_breach( + breach = await find_allocation_breach( allocation_lookup=FakeAllocationLookup(envelope), spend_lookup=FakeSpendLookup(total_usd_spent=250.0), as_of=_NOW, diff --git a/apps/api/tests/unit/agent/test_budget_spend_guard.py b/apps/api/tests/unit/agent/test_budget_spend_guard.py index 8482999f4b4..d69cce12b76 100644 --- a/apps/api/tests/unit/agent/test_budget_spend_guard.py +++ b/apps/api/tests/unit/agent/test_budget_spend_guard.py @@ -14,7 +14,7 @@ from cora.agent.adapters import BudgetSpendGuard from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore -from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult from tests.unit.agent._helpers import ( FakeAllocationLookup, FakeSpendLookup, @@ -30,8 +30,8 @@ _ENVELOPE_ACTIVATED_AT = datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC) -def _envelope(*, ceiling_usd: float = 100.0) -> ActiveAllocation: - return ActiveAllocation( +def _envelope(*, ceiling_usd: float = 100.0) -> AllocationLookupResult: + return AllocationLookupResult( allocation_id=uuid4(), ceiling_usd=ceiling_usd, activated_at=_ENVELOPE_ACTIVATED_AT, @@ -155,6 +155,23 @@ async def test_projection_landing_exactly_on_the_cap_grants() -> None: assert reason is None +@pytest.mark.unit +async def test_daily_token_projection_landing_exactly_on_the_cap_grants() -> None: + """The daily-token arm's equality edge mirrors the monthly USD arm: + spent + estimate == cap is the last permitted call, so the strict + `>` comparison must not refuse it.""" + store = InMemoryEventStore() + agent_id = uuid4() + await _versioned_agent(store, agent_id, daily_token_cap=50_000) + guard = _guard(store, FakeSpendLookup(tokens_spent=49_000)) + + reason = await guard.refusal_reason( + agent_id=agent_id, estimated_cost_usd=0.01, estimated_tokens=1_000, as_of=_NOW + ) + + assert reason is None + + @pytest.mark.unit async def test_projected_daily_token_breach_refuses_with_window_pinned() -> None: store = InMemoryEventStore() diff --git a/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py b/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py index 7f45d2d73fd..ffccf32fe03 100644 --- a/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py +++ b/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py @@ -46,7 +46,7 @@ LLMServerError, LLMUsage, ) -from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult from cora.infrastructure.ports.event_store import StoredEvent from cora.recipe.aggregates.plan import ( PlanDefined, @@ -543,7 +543,7 @@ async def test_apply_defers_noaction_when_allocation_envelope_exhausted() -> Non run_id = uuid4() await _seed_run(store, run_id) activated_at = datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC) - envelope = ActiveAllocation( + envelope = AllocationLookupResult( allocation_id=uuid4(), ceiling_usd=100.0, activated_at=activated_at, @@ -573,6 +573,53 @@ async def test_apply_defers_noaction_when_allocation_envelope_exhausted() -> Non assert spend_lookup.total_windows == [(activated_at, _LATER)] +@pytest.mark.unit +async def test_apply_agent_cap_breach_wins_over_the_envelope() -> None: + """Gate ordering: the per-agent cap is checked before the instrument + envelope, so a capped agent defers with AgentBudgetExhausted and the + envelope lookup is never consulted.""" + store = InMemoryEventStore() + llm = FakeLLM(responses=[_CANNED_NO_ACTION]) + recorder = FakeInferenceRecorder() + await _seed_caution_drafter_actor(store) + await seed_versioned_agent( + store, + agent_id=CAUTION_DRAFTER_AGENT_ID, + genesis_event_id=uuid4(), + version_event_id=uuid4(), + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + defined_at=_NOW, + versioned_at=_NOW, + monthly_usd_cap=120.0, + ) + await _seed_plan(store) + run_id = uuid4() + await _seed_run(store, run_id) + envelope = AllocationLookupResult( + allocation_id=uuid4(), + ceiling_usd=100.0, + activated_at=datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC), + campaign_id=None, + ) + allocation_lookup = FakeAllocationLookup(envelope) + spend_lookup = FakeSpendLookup(usd_spent=121.3, total_usd_spent=100.0) + subscriber = await _build_subscriber( + store, llm, recorder, spend_lookup=spend_lookup, allocation_lookup=allocation_lookup + ) + event = _terminal_event(event_type="RunCompleted", run_id=run_id) + + await subscriber.apply(event, conn=None) + + decision = await load_decision(store, _derive_decision_id(event.event_id)) + assert decision is not None + assert decision.inputs is not None + assert decision.inputs["failure_error_class"] == "AgentBudgetExhausted" + assert allocation_lookup.find_active_calls == 0 + assert spend_lookup.total_windows == [] + assert llm.received == [] + + @pytest.mark.unit async def test_apply_proceeds_normally_when_spend_is_under_cap() -> None: """A declared budget with headroom never interferes with the @@ -1055,6 +1102,7 @@ def test_make_caution_drafter_subscriber_constructs_when_llm_is_set() -> None: # The budget gate silently disables if the factory drops this wiring # (the constructor default is the permissive AlwaysZeroSpendLookup). assert subscriber.spend_lookup is deps.spend_lookup + assert subscriber.allocation_lookup is deps.allocation_lookup # ---------- Signer wiring ---------- diff --git a/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py b/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py index 86bdaea8e12..f219505f35d 100644 --- a/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py +++ b/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py @@ -50,7 +50,7 @@ LLMServerError, LLMUsage, ) -from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult from cora.infrastructure.ports.event_store import StoredEvent from cora.run.aggregates.run import ( RunStarted, @@ -563,7 +563,7 @@ async def test_apply_defers_debrief_when_allocation_envelope_exhausted() -> None run_id = uuid4() await _seed_run(store, run_id) activated_at = datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC) - envelope = ActiveAllocation( + envelope = AllocationLookupResult( allocation_id=uuid4(), ceiling_usd=100.0, activated_at=activated_at, @@ -595,6 +595,53 @@ async def test_apply_defers_debrief_when_allocation_envelope_exhausted() -> None assert spend_lookup.total_windows == [(activated_at, _LATER)] +@pytest.mark.unit +async def test_apply_agent_cap_breach_wins_over_the_envelope() -> None: + """Gate ordering: the per-agent cap is checked before the instrument + envelope, so a capped agent defers with AgentBudgetExhausted and the + envelope lookup is never consulted (dropping the ordering would flip + the marker to AllocationExhausted).""" + store = InMemoryEventStore() + llm = FakeLLM(responses=[_CANNED_OK]) + recorder = FakeInferenceRecorder() + await _seed_run_debrief_actor(store) + await seed_versioned_agent( + store, + agent_id=RUN_DEBRIEFER_AGENT_ID, + genesis_event_id=uuid4(), + version_event_id=uuid4(), + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + defined_at=_NOW, + versioned_at=_NOW, + monthly_usd_cap=120.0, + ) + run_id = uuid4() + await _seed_run(store, run_id) + envelope = AllocationLookupResult( + allocation_id=uuid4(), + ceiling_usd=100.0, + activated_at=datetime(2026, 5, 1, 8, 0, 0, tzinfo=UTC), + campaign_id=None, + ) + allocation_lookup = FakeAllocationLookup(envelope) + spend_lookup = FakeSpendLookup(usd_spent=121.3, total_usd_spent=100.0) + subscriber = await _build_subscriber( + store, llm, recorder, spend_lookup=spend_lookup, allocation_lookup=allocation_lookup + ) + event = _terminal_event(event_type="RunCompleted", run_id=run_id) + + await subscriber.apply(event, conn=None) + + decision = await load_decision(store, _derive_decision_id(event.event_id)) + assert decision is not None + assert decision.inputs is not None + assert decision.inputs["failure_error_class"] == "AgentBudgetExhausted" + assert allocation_lookup.find_active_calls == 0 + assert spend_lookup.total_windows == [] + assert llm.received == [] + + @pytest.mark.unit async def test_apply_skips_entirely_when_agent_deprecated() -> None: """The lifecycle gate is Versioned-only: Deprecated is terminal @@ -1679,3 +1726,4 @@ def test_make_run_debriefer_subscriber_wires_spend_lookup_from_kernel() -> None: subscriber = make_run_debriefer_subscriber(deps) assert isinstance(subscriber, RunDebrieferSubscriber) assert subscriber.spend_lookup is deps.spend_lookup + assert subscriber.allocation_lookup is deps.allocation_lookup diff --git a/apps/api/tests/unit/budget/_helpers.py b/apps/api/tests/unit/budget/_helpers.py index 8722acfe08b..cc06aaf8b3b 100644 --- a/apps/api/tests/unit/budget/_helpers.py +++ b/apps/api/tests/unit/budget/_helpers.py @@ -13,7 +13,7 @@ AllocationActivated, AllocationEvent, AllocationGranted, - AllocationHolderNote, + AllocationNote, AllocationStatus, event_type_name, to_payload, @@ -46,7 +46,7 @@ def make_allocation( return Allocation( id=allocation_id or uuid4(), ceiling_usd=ceiling_usd, - holder_note=AllocationHolderNote("FY26 imaging award"), + note=AllocationNote("FY26 imaging award"), campaign_id=campaign_id, granted_at=GRANTED_AT, granted_by=GRANTED_BY, @@ -66,7 +66,7 @@ def granted_event( allocation_id=allocation_id, ceiling_usd=ceiling_usd, campaign_id=campaign_id, - holder_note="FY26 imaging award", + note="FY26 imaging award", granted_by=GRANTED_BY, occurred_at=GRANTED_AT, ) diff --git a/apps/api/tests/unit/budget/test_activate_allocation_handler.py b/apps/api/tests/unit/budget/test_activate_allocation_handler.py index 0e4b5cf7600..76f2af17787 100644 --- a/apps/api/tests/unit/budget/test_activate_allocation_handler.py +++ b/apps/api/tests/unit/budget/test_activate_allocation_handler.py @@ -11,6 +11,7 @@ import pytest from cora.budget.aggregates.allocation import ( + AllocationAlreadyActiveError, AllocationCannotActivateError, AllocationNotFoundError, ) @@ -19,7 +20,9 @@ from cora.budget.features.activate_allocation import ActivateAllocation from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult from tests.unit._helpers import build_deps as _build_deps_shared +from tests.unit.agent._helpers import FakeAllocationLookup from tests.unit.budget._helpers import ( activated_event, granted_event, @@ -141,3 +144,91 @@ async def test_handler_denied_does_not_write_to_stream() -> None: assert version == 1 assert len(events) == 1 assert events[0].event_type == "AllocationGranted" + + +_OTHER_ACTIVE_ID = UUID("01900000-0000-7000-8000-00000000d0ff") + + +def _active_result(allocation_id: UUID) -> AllocationLookupResult: + return AllocationLookupResult( + allocation_id=allocation_id, + ceiling_usd=100.0, + activated_at=_NOW, + campaign_id=None, + ) + + +@pytest.mark.unit +async def test_handler_refuses_when_a_different_envelope_is_already_active() -> None: + """Single-Active guard: a second activation while another envelope is + Active must be refused, so the gates and the sealer can rely on one + Active allocation. The stream is not mutated.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + lookup = FakeAllocationLookup(active=_active_result(_OTHER_ACTIVE_ID)) + deps = _build_deps_shared( + ids=[_ACTIVATE_EVENT_ID], now=_NOW, event_store=store, allocation_lookup=lookup + ) + handler = activate_allocation.bind(deps) + + with pytest.raises(AllocationAlreadyActiveError) as exc: + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert exc.value.active_allocation_id == _OTHER_ACTIVE_ID + assert lookup.find_active_calls == 1 + _events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 1 # unchanged: only the seeded Granted event + + +@pytest.mark.unit +async def test_handler_activates_when_no_other_envelope_is_active() -> None: + """A sealed or voided prior envelope leaves nothing Active, so the + lookup returns None and activation proceeds.""" + store = InMemoryEventStore() + await seed_allocation_events(store, _ALLOCATION_ID, granted_event(_ALLOCATION_ID)) + lookup = FakeAllocationLookup(active=None) + deps = _build_deps_shared( + ids=[_ACTIVATE_EVENT_ID], now=_NOW, event_store=store, allocation_lookup=lookup + ) + handler = activate_allocation.bind(deps) + + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert lookup.find_active_calls == 1 + events, version = await store.load("Allocation", _ALLOCATION_ID) + assert version == 2 + assert events[-1].event_type == "AllocationActivated" + + +@pytest.mark.unit +async def test_handler_permits_reactivating_the_same_active_envelope_to_the_decider() -> None: + """When find_active returns THIS allocation (not a different one), the + guard does not fire; the decider's own Granted-only source set is + what rejects a re-activation.""" + store = InMemoryEventStore() + await seed_allocation_events( + store, + _ALLOCATION_ID, + granted_event(_ALLOCATION_ID), + activated_event(_ALLOCATION_ID), + ) + lookup = FakeAllocationLookup(active=_active_result(_ALLOCATION_ID)) + deps = _build_deps_shared( + ids=[_ACTIVATE_EVENT_ID], now=_NOW, event_store=store, allocation_lookup=lookup + ) + handler = activate_allocation.bind(deps) + + with pytest.raises(AllocationCannotActivateError): + await handler( + ActivateAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/apps/api/tests/unit/budget/test_allocation_evolver.py b/apps/api/tests/unit/budget/test_allocation_evolver.py index 4350eac0c64..b9937b21fc5 100644 --- a/apps/api/tests/unit/budget/test_allocation_evolver.py +++ b/apps/api/tests/unit/budget/test_allocation_evolver.py @@ -36,7 +36,7 @@ def _genesis( allocation_id=allocation_id or uuid4(), ceiling_usd=25000.0, campaign_id=campaign_id, - holder_note="FY26 imaging award", + note="FY26 imaging award", granted_by=_GRANTED_BY, occurred_at=_T0, ) @@ -55,7 +55,7 @@ def test_genesis_folds_to_granted_state() -> None: assert state.id == e.allocation_id assert state.status is AllocationStatus.GRANTED assert state.ceiling_usd == 25000.0 - assert state.holder_note.value == "FY26 imaging award" + assert state.note.value == "FY26 imaging award" assert state.campaign_id == _CAMPAIGN_ID assert state.granted_at == _T0 assert state.granted_by == _GRANTED_BY diff --git a/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py b/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py index c48509649d0..bd8f92df44b 100644 --- a/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py +++ b/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py @@ -7,17 +7,23 @@ projection row), and idempotent replay. """ +# pyright: reportPrivateUsage=false + from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock -from uuid import UUID, uuid4 +from uuid import UUID, uuid4, uuid5 import pytest from cora.budget.aggregates.allocation import AllocationStatus, load_allocation -from cora.budget.subscribers.allocation_sealer import AllocationSealerSubscriber +from cora.budget.subscribers.allocation_sealer import ( + _ALLOCATION_SEALER_NAMESPACE, + AllocationSealerSubscriber, +) from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore -from cora.infrastructure.ports.allocation_lookup import ActiveAllocation +from cora.infrastructure.ports import ConcurrencyError +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult from cora.infrastructure.ports.event_store import StoredEvent from cora.infrastructure.ports.spend_lookup import TotalSpendResult from cora.infrastructure.routing import SYSTEM_PRINCIPAL_ID @@ -33,10 +39,10 @@ class _FakeAllocationLookup: - def __init__(self, active: ActiveAllocation | None) -> None: + def __init__(self, active: AllocationLookupResult | None) -> None: self.active = active - async def find_active(self) -> ActiveAllocation | None: + async def find_active(self) -> AllocationLookupResult | None: return self.active @@ -79,8 +85,8 @@ def _campaign_closed(campaign_id: UUID) -> StoredEvent: ) -def _active_row(allocation_id: UUID, campaign_id: UUID | None) -> ActiveAllocation: - return ActiveAllocation( +def _active_row(allocation_id: UUID, campaign_id: UUID | None) -> AllocationLookupResult: + return AllocationLookupResult( allocation_id=allocation_id, ceiling_usd=25000.0, activated_at=ACTIVATED_AT, @@ -158,6 +164,11 @@ async def test_bound_active_allocation_is_sealed_with_ledger_snapshot() -> None: assert seal_envelope.event_type == "AllocationSealed" assert seal_envelope.correlation_id == trigger.correlation_id assert seal_envelope.causation_id == trigger.event_id + # Deterministic event id: a replay derives the same id, so the store's + # UNIQUE(event_id) backs the expected-version guard. + assert seal_envelope.event_id == uuid5( + _ALLOCATION_SEALER_NAMESPACE, f"seal:{trigger.event_id}:{allocation_id}" + ) @pytest.mark.unit @@ -289,7 +300,7 @@ async def test_non_trigger_event_type_is_ignored(monkeypatch: Any) -> None: called = False - async def _boom() -> ActiveAllocation | None: + async def _boom() -> AllocationLookupResult | None: nonlocal called called = True return None @@ -298,3 +309,63 @@ async def _boom() -> ActiveAllocation | None: await sub.apply(other, AsyncMock()) assert called is False + + +class _ConflictOnceStore: + """Wraps a store to raise ConcurrencyError on the FIRST append only, + simulating a benign concurrent write (an operator amend) landing + between the sealer's load and its append.""" + + def __init__(self, delegate: InMemoryEventStore) -> None: + self._delegate = delegate + self._append_calls = 0 + + async def load(self, stream_type: str, stream_id: UUID) -> Any: + return await self._delegate.load(stream_type, stream_id) + + async def append( + self, + *, + stream_type: str, + stream_id: UUID, + expected_version: int, + events: Any, + ) -> Any: + self._append_calls += 1 + if self._append_calls == 1: + raise ConcurrencyError( + stream_type=stream_type, + stream_id=stream_id, + expected=expected_version, + actual=expected_version + 1, + ) + return await self._delegate.append( + stream_type=stream_type, + stream_id=stream_id, + expected_version=expected_version, + events=events, + ) + + +@pytest.mark.unit +async def test_seal_retries_past_a_benign_concurrent_write() -> None: + """A concurrent amend bumps the version between load and append; the + sealer re-loads and retries rather than forfeiting the automatic + seal. The envelope ends Sealed after the retry.""" + store = InMemoryEventStore() + allocation_id = uuid4() + await _seed_active_allocation(store, allocation_id, campaign_id=_CAMPAIGN_ID) + conflict_store = _ConflictOnceStore(store) + sub = AllocationSealerSubscriber( + event_store=conflict_store, # type: ignore[arg-type] + allocation_lookup=_FakeAllocationLookup(_active_row(allocation_id, _CAMPAIGN_ID)), + spend_lookup=_FakeTotalSpendLookup(total_usd_spent=12.5), # type: ignore[arg-type] + ) + + await sub.apply(_campaign_closed(_CAMPAIGN_ID), AsyncMock()) + + assert conflict_store._append_calls == 2 # one conflict, one success + state = await load_allocation(store, allocation_id) + assert state is not None + assert state.status is AllocationStatus.SEALED + assert state.spent_usd_at_seal == 12.5 diff --git a/apps/api/tests/unit/budget/test_allocation_summary_projection.py b/apps/api/tests/unit/budget/test_allocation_summary_projection.py index 225b725e183..0fca1a58e78 100644 --- a/apps/api/tests/unit/budget/test_allocation_summary_projection.py +++ b/apps/api/tests/unit/budget/test_allocation_summary_projection.py @@ -65,7 +65,7 @@ async def test_allocation_granted_inserts_with_granted_status() -> None: "allocation_id": str(_ALLOCATION_ID), "ceiling_usd": 25000.0, "campaign_id": str(_CAMPAIGN_ID), - "holder_note": "FY26 imaging award", + "note": "FY26 imaging award", "granted_by": str(uuid4()), "occurred_at": _NOW.isoformat(), }, @@ -97,7 +97,7 @@ async def test_allocation_granted_without_campaign_inserts_null_binding() -> Non "allocation_id": str(_ALLOCATION_ID), "ceiling_usd": 100.0, "campaign_id": None, - "holder_note": "unbound envelope", + "note": "unbound envelope", "granted_by": str(uuid4()), "occurred_at": _NOW.isoformat(), }, diff --git a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py index 9a72340eb3d..3667bb329a4 100644 --- a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py +++ b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider.py @@ -6,7 +6,7 @@ import pytest from cora.budget.aggregates.allocation import ( - AllocationCannotAmendError, + AllocationCannotAmendCeilingError, AllocationCeilingAmended, AllocationNotFoundError, AllocationStatus, @@ -64,7 +64,7 @@ def test_not_found_when_state_is_none() -> None: @pytest.mark.parametrize("status", [AllocationStatus.SEALED, AllocationStatus.VOIDED]) def test_cannot_amend_terminal_envelope(status: AllocationStatus) -> None: envelope = make_allocation(status) - with pytest.raises(AllocationCannotAmendError): + with pytest.raises(AllocationCannotAmendCeilingError): decide( state=envelope, command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=100.0), diff --git a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py index 80a640ec08a..429061539f3 100644 --- a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py +++ b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_decider_properties.py @@ -12,7 +12,7 @@ command.allocation_id. - The source-state partition is total over `AllocationStatus`: Granted and Active accept; Sealed and Voided always raise - `AllocationCannotAmendError` carrying the current status. + `AllocationCannotAmendCeilingError` carrying the current status. - PUT idempotency: amending to the stored ceiling returns [] for every valid ceiling; amending to a different valid ceiling emits exactly one event carrying it verbatim with state.id. @@ -31,7 +31,7 @@ from hypothesis import strategies as st from cora.budget.aggregates.allocation import ( - AllocationCannotAmendError, + AllocationCannotAmendCeilingError, AllocationCeilingAmended, AllocationNotFoundError, AllocationStatus, @@ -142,7 +142,7 @@ def test_amend_from_terminal_source_always_raises_cannot_amend( ) -> None: """Sealed and Voided books cannot be rewritten; carries the current status.""" envelope = make_allocation(source) - with pytest.raises(AllocationCannotAmendError) as exc: + with pytest.raises(AllocationCannotAmendCeilingError) as exc: decide( state=envelope, command=AmendAllocationCeiling(allocation_id=envelope.id, ceiling_usd=ceiling), diff --git a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py index 8b6a83bd6f4..226ebfeb4e8 100644 --- a/apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py +++ b/apps/api/tests/unit/budget/test_amend_allocation_ceiling_handler.py @@ -6,7 +6,7 @@ import pytest from cora.budget.aggregates.allocation import ( - AllocationCannotAmendError, + AllocationCannotAmendCeilingError, AllocationNotFoundError, AllocationVoided, ) @@ -104,7 +104,7 @@ async def test_handler_raises_cannot_amend_after_void() -> None: ) deps = _build_deps(event_store=store) handler = amend_allocation_ceiling.bind(deps) - with pytest.raises(AllocationCannotAmendError): + with pytest.raises(AllocationCannotAmendCeilingError): await handler( AmendAllocationCeiling(allocation_id=_ALLOCATION_ID, ceiling_usd=100.0), principal_id=_PRINCIPAL_ID, diff --git a/apps/api/tests/unit/budget/test_grant_allocation_decider.py b/apps/api/tests/unit/budget/test_grant_allocation_decider.py index b2c8f180746..2cddf26aba1 100644 --- a/apps/api/tests/unit/budget/test_grant_allocation_decider.py +++ b/apps/api/tests/unit/budget/test_grant_allocation_decider.py @@ -6,12 +6,12 @@ import pytest from cora.budget.aggregates.allocation import ( - ALLOCATION_HOLDER_NOTE_MAX_LENGTH, + ALLOCATION_NOTE_MAX_LENGTH, AllocationAlreadyExistsError, AllocationGranted, AllocationStatus, InvalidAllocationCeilingError, - InvalidAllocationHolderNoteError, + InvalidAllocationNoteError, ) from cora.budget.features.grant_allocation.command import GrantAllocation from cora.budget.features.grant_allocation.decider import decide @@ -27,7 +27,7 @@ def _command(**overrides: object) -> GrantAllocation: base: dict[str, object] = { "ceiling_usd": 25000.0, - "holder_note": "FY26 imaging award", + "note": "FY26 imaging award", } base.update(overrides) return GrantAllocation(**base) # type: ignore[arg-type] @@ -43,7 +43,7 @@ def test_minimal_command_emits_single_allocation_granted() -> None: allocation_id=_NEW_ID, ceiling_usd=25000.0, campaign_id=None, - holder_note="FY26 imaging award", + note="FY26 imaging award", granted_by=_GRANTED_BY, occurred_at=_NOW, ) @@ -92,12 +92,12 @@ def test_non_positive_or_non_finite_ceiling_raises(bad_ceiling: float) -> None: @pytest.mark.unit -@pytest.mark.parametrize("bad_note", ["", " ", "x" * (ALLOCATION_HOLDER_NOTE_MAX_LENGTH + 1)]) -def test_invalid_holder_note_raises(bad_note: str) -> None: - with pytest.raises(InvalidAllocationHolderNoteError): +@pytest.mark.parametrize("bad_note", ["", " ", "x" * (ALLOCATION_NOTE_MAX_LENGTH + 1)]) +def test_invalid_note_raises(bad_note: str) -> None: + with pytest.raises(InvalidAllocationNoteError): decide( state=None, - command=_command(holder_note=bad_note), + command=_command(note=bad_note), now=_NOW, new_id=_NEW_ID, granted_by=_GRANTED_BY, @@ -105,16 +105,16 @@ def test_invalid_holder_note_raises(bad_note: str) -> None: @pytest.mark.unit -def test_holder_note_trim_propagates() -> None: +def test_note_trim_propagates() -> None: """The VO trims; the decider passes the trimmed value into the event.""" events = decide( state=None, - command=_command(holder_note=" FY26 imaging award "), + command=_command(note=" FY26 imaging award "), now=_NOW, new_id=_NEW_ID, granted_by=_GRANTED_BY, ) - assert events[0].holder_note == "FY26 imaging award" + assert events[0].note == "FY26 imaging award" @pytest.mark.unit diff --git a/apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py b/apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py index 74ec76d3e3c..62c4a0c5e38 100644 --- a/apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py +++ b/apps/api/tests/unit/budget/test_grant_allocation_decider_properties.py @@ -75,7 +75,7 @@ def test_grant_with_any_existing_state_always_raises_already_exists( with pytest.raises(AllocationAlreadyExistsError) as exc: decide( state=make_allocation(status, allocation_id=state_id), - command=GrantAllocation(ceiling_usd=100.0, holder_note="Envelope"), + command=GrantAllocation(ceiling_usd=100.0, note="Envelope"), now=now, new_id=new_id, granted_by=ActorId(granted_by), @@ -86,14 +86,14 @@ def test_grant_with_any_existing_state_always_raises_already_exists( @pytest.mark.unit @given( ceiling=_valid_ceilings, - holder_note=printable_ascii_text(max_size=200), + note=printable_ascii_text(max_size=200), new_id=st.uuids(), granted_by=st.uuids(), now=aware_datetimes(), ) def test_grant_happy_path_emits_single_event_with_injected_fields( ceiling: float, - holder_note: str, + note: str, new_id: UUID, granted_by: UUID, now: datetime, @@ -101,7 +101,7 @@ def test_grant_happy_path_emits_single_event_with_injected_fields( """Any finite positive ceiling grants; the event carries the inputs verbatim.""" events = decide( state=None, - command=GrantAllocation(ceiling_usd=ceiling, holder_note=holder_note), + command=GrantAllocation(ceiling_usd=ceiling, note=note), now=now, new_id=new_id, granted_by=ActorId(granted_by), @@ -110,7 +110,7 @@ def test_grant_happy_path_emits_single_event_with_injected_fields( event = events[0] assert event.allocation_id == new_id assert event.ceiling_usd == ceiling - assert event.holder_note == holder_note + assert event.note == note assert event.granted_by == ActorId(granted_by) assert event.occurred_at == now assert event.campaign_id is None @@ -134,7 +134,7 @@ def test_grant_non_positive_or_non_finite_ceiling_always_raises( with pytest.raises(InvalidAllocationCeilingError): decide( state=None, - command=GrantAllocation(ceiling_usd=ceiling, holder_note="Envelope"), + command=GrantAllocation(ceiling_usd=ceiling, note="Envelope"), now=now, new_id=new_id, granted_by=ActorId(granted_by), @@ -155,7 +155,7 @@ def test_grant_is_pure_same_input_same_output( now: datetime, ) -> None: """Two calls with identical args return equal events (no clock leakage).""" - command = GrantAllocation(ceiling_usd=ceiling, holder_note="Envelope") + command = GrantAllocation(ceiling_usd=ceiling, note="Envelope") first = decide( state=None, command=command, now=now, new_id=new_id, granted_by=ActorId(granted_by) ) diff --git a/apps/api/tests/unit/budget/test_grant_allocation_handler.py b/apps/api/tests/unit/budget/test_grant_allocation_handler.py index 823eae13cbb..183e72d0aac 100644 --- a/apps/api/tests/unit/budget/test_grant_allocation_handler.py +++ b/apps/api/tests/unit/budget/test_grant_allocation_handler.py @@ -57,7 +57,7 @@ def _build_deps( def _command(**overrides: object) -> GrantAllocation: base: dict[str, object] = { "ceiling_usd": 25000.0, - "holder_note": "FY26 imaging award", + "note": "FY26 imaging award", } base.update(overrides) return GrantAllocation(**base) # type: ignore[arg-type] @@ -68,7 +68,7 @@ async def _seed_allocation(store: InMemoryEventStore, allocation_id: UUID) -> No allocation_id=allocation_id, ceiling_usd=25000.0, campaign_id=None, - holder_note="Seeded envelope", + note="Seeded envelope", granted_by=ActorId(_PRINCIPAL_ID), occurred_at=_NOW, ) @@ -138,7 +138,7 @@ async def test_handler_appends_single_granted_event_with_principal_as_granted_by assert payload["allocation_id"] == str(_NEW_ID) assert payload["ceiling_usd"] == 25000.0 assert payload["campaign_id"] == "01900000-0000-7000-8000-000000000044" - assert payload["holder_note"] == "FY26 imaging award" + assert payload["note"] == "FY26 imaging award" assert payload["granted_by"] == str(_PRINCIPAL_ID) assert payload["occurred_at"] == _NOW.isoformat() diff --git a/apps/api/tests/unit/budget/test_seal_allocation_handler.py b/apps/api/tests/unit/budget/test_seal_allocation_handler.py index d4c0990b75a..a5d8d028ad5 100644 --- a/apps/api/tests/unit/budget/test_seal_allocation_handler.py +++ b/apps/api/tests/unit/budget/test_seal_allocation_handler.py @@ -5,7 +5,7 @@ `now` into the injected reader, and records the reader's answer as the seal's spend snapshot. The tests pin the window threading, the guard-path short-circuit (no ledger read for a window that never -opened), and the stage-A zero-reader posture. +opened), and the explicit-zero-reader posture. """ from datetime import UTC, datetime @@ -127,8 +127,9 @@ async def test_handler_carries_seal_reason_to_payload() -> None: @pytest.mark.unit async def test_handler_with_zero_reader_records_zero_snapshot() -> None: - """Stage-A posture: the bound zero-reader makes every seal record 0.0 - honestly until stage C wires the ledger-backed fold.""" + """Binding the zero reader explicitly, with no ledger fold, makes + every seal record 0.0 honestly as the reader's answer. Production + wiring binds make_ledger_total_spend; the reader is a seam.""" store = InMemoryEventStore() await _seed_active(store) deps = _build_deps(event_store=store) @@ -192,3 +193,32 @@ async def test_handler_denied_does_not_write_or_read_ledger() -> None: assert version == 2 assert len(events) == 2 assert reader.calls == [] + + +@pytest.mark.unit +async def test_wire_budget_binds_the_ledger_reader_not_the_zero_reader() -> None: + """The seam's whole point: `wire_budget` binds + `make_ledger_total_spend(deps.spend_lookup)`, so an operator seal + records the instance-total ledger sum, not 0.0. Rebinding + `zero_total_spend` in wire.py would make this record 0.0 and fail.""" + from cora.budget.wire import wire_budget + from tests.unit.agent._helpers import FakeSpendLookup + + store = InMemoryEventStore() + await _seed_active(store) + spend_lookup = FakeSpendLookup(total_usd_spent=777.0) + deps = _build_deps_shared( + ids=[_SEAL_EVENT_ID], now=_NOW, event_store=store, spend_lookup=spend_lookup + ) + handlers = wire_budget(deps) + + await handlers.seal_allocation( + SealAllocation(allocation_id=_ALLOCATION_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, _version = await store.load("Allocation", _ALLOCATION_ID) + assert events[-1].event_type == "AllocationSealed" + assert events[-1].payload["spent_usd"] == 777.0 + assert spend_lookup.total_windows == [(ACTIVATED_AT, _NOW)] diff --git a/apps/api/tests/unit/deployments/test_architecture_introspect.py b/apps/api/tests/unit/deployments/test_architecture_introspect.py index 8e1542b5ebd..75ab69e167e 100644 --- a/apps/api/tests/unit/deployments/test_architecture_introspect.py +++ b/apps/api/tests/unit/deployments/test_architecture_introspect.py @@ -77,7 +77,7 @@ def test_introspection_aggregates_match_filesystem() -> None: def test_counts_are_eighteen_bcs_and_forty_three_aggregates() -> None: # Anti-drift pins for the model.md headline; bump deliberately on a BC/aggregate add. # 18 BCs / 43 aggregates: the budget BC landed with Allocation (the - # beamline's spending envelope, the allocation arc's stage A). + # beamline's spending envelope, the budget BC). model = ai.introspect(_CORA) assert model.bc_count == 18 assert model.aggregate_count == 43 @@ -124,7 +124,7 @@ def test_in_process_stub_slice_has_no_surface() -> None: def test_bc_table_renders_full_membership() -> None: table = ap.render_bc_table(_MODEL, {}) - assert "`enclosure`" in table # the omitted 17th BC + assert "`enclosure`" in table # a BC easy to miss in a hand-count assert "`role`" in table # the omitted equipment aggregate # 18 Active rows since budget shipped; strategy is the one Planned row left. assert table.count("Active") == 18 diff --git a/apps/api/tests/unit/test_deps.py b/apps/api/tests/unit/test_deps.py index 51ce49e02de..b15e6ccd01d 100644 --- a/apps/api/tests/unit/test_deps.py +++ b/apps/api/tests/unit/test_deps.py @@ -27,6 +27,18 @@ from cora.trust.authorize import TrustAuthorize +@pytest.mark.unit +async def test_build_kernel_refuses_a_postgres_deployment_without_financial_lookups() -> None: + """The fail-loud financial control: a production-tier deployment that + omits the spend or allocation factory must stop at startup, not + silently meter zero and disarm the instrument envelope. The raise + fires before any pool is opened, so no real database is needed.""" + settings = Settings(app_env="production") # type: ignore[call-arg] + + with pytest.raises(ValueError, match="financial lookup"): + await build_kernel(authorize_factory=build_authorize, settings=settings) + + @pytest.mark.unit async def test_build_kernel_uses_in_memory_stores_in_test_env( monkeypatch: pytest.MonkeyPatch, diff --git a/infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql b/infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql index c48cf9a0e53..5fa5b41865b 100644 --- a/infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql +++ b/infra/atlas/migrations/20260713000000_init_proj_budget_allocation_summary.sql @@ -17,7 +17,7 @@ CREATE TABLE proj_budget_allocation_summary ( allocation_id UUID PRIMARY KEY, ceiling_usd DOUBLE PRECISION NOT NULL CHECK (ceiling_usd > 0), campaign_id UUID, - holder_note TEXT NOT NULL, + note TEXT NOT NULL, status TEXT NOT NULL CHECK ( status IN ('Granted', 'Active', 'Sealed', 'Voided') ), diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index e9964e806b1..e631fc20786 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:Hy+Kjffwh6U5zweMzYLKt0c3ZcDS8bhCwgphpJQx02I= +h1:aGg69UNeCVVCM6MRZAsHDLkNEVAD4wFlS6+P5VXy1ZU= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -158,4 +158,4 @@ h1:Hy+Kjffwh6U5zweMzYLKt0c3ZcDS8bhCwgphpJQx02I= 20260706000000_init_proj_trust_ratification_coverage.sql h1:YDYrypgQNOid1GnlfYbXI0j7300NUcGdu5L0Tsev/3I= 20260711000000_add_entries_decision_inferences_cost_usd.sql h1:Ebt1cyOO33cAzACL7Va1e8n7+kuPedHqVMln7mlWqHI= 20260712000000_init_proj_agent_language_model_summary.sql h1:HIICLCDFUvO5YP33NcgkpWSiU+Jo+PVSqUXUapnCtw0= -20260713000000_init_proj_budget_allocation_summary.sql h1:uwPaMErMybrGNUn8nwpiCC7md3ENdymmzl1MHh2QX3g= +20260713000000_init_proj_budget_allocation_summary.sql h1:72j+IH7DwQ9xmV1srtNszWNLhBmIu3nDljwIHYYnm8E=