diff --git a/apps/api/openapi.json b/apps/api/openapi.json index bf1170fc155..95d501acc60 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" + }, + "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": "Note", + "type": "string" + } + }, + "required": [ + "ceiling_usd", + "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": { @@ -11061,6 +11142,7 @@ "cost_usd": { "anyOf": [ { + "maximum": 100000.0, "minimum": 0.0, "type": "number" }, @@ -11068,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": { @@ -14228,6 +14310,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 +16465,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 +19209,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 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/agent/_budget_gate.py b/apps/api/src/cora/agent/_budget_gate.py index 25e6e342f82..0a376d16a5e 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_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 +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 AllocationBreach: + """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_allocation_breach( + *, + allocation_lookup: "AllocationLookup", + spend_lookup: "SpendLookup", + as_of: datetime, + pending_usd: float = 0.0, +) -> AllocationBreach | 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 AllocationBreach( + allocation_id=envelope.allocation_id, + ceiling_usd=envelope.ceiling_usd, + spent_usd=spend.usd_spent, + window_start=envelope.activated_at, + ) + return None + + __all__ = [ + "AllocationBreach", "BudgetBreach", "calendar_day_window", "calendar_month_window", + "find_allocation_breach", "find_budget_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..74620550593 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_allocation_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_allocation_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..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 +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 ( @@ -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,43 @@ 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_allocation_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 seal or void this envelope " + "and activate a new one." + ), + 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 +910,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..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 +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 ( @@ -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_allocation_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 seal or void this envelope " + "and activate a new one, 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 99dc6eb8017..1bc8072a825 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -86,6 +86,15 @@ 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_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, @@ -588,6 +597,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 +621,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() @@ -632,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, @@ -703,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) @@ -733,6 +755,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( @@ -824,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. @@ -832,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 @@ -1078,6 +1108,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..e010892ee6c --- /dev/null +++ b/apps/api/src/cora/budget/__init__.py @@ -0,0 +1,60 @@ +"""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 + 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 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). + +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 + 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 +from cora.budget.wire import BudgetHandlers, wire_budget + +__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/_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/_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..63361b1fc68 --- /dev/null +++ b/apps/api/src/cora/budget/adapters/postgres_allocation_lookup.py @@ -0,0 +1,53 @@ +"""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 +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 + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +from typing import TYPE_CHECKING + +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult + +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) -> AllocationLookupResult | None: + async with self._pool.acquire() as conn: + row = await conn.fetchrow(_FIND_ACTIVE_SQL) + if row is None: + return None + return AllocationLookupResult( + 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/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..036330b1102 --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/allocation/__init__.py @@ -0,0 +1,76 @@ +"""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, +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_NOTE_MAX_LENGTH, + Allocation, + AllocationAlreadyActiveError, + AllocationAlreadyExistsError, + AllocationCannotActivateError, + AllocationCannotAmendCeilingError, + AllocationCannotSealError, + AllocationCannotVoidError, + AllocationNote, + AllocationNotFoundError, + AllocationReason, + AllocationStatus, + InvalidAllocationCeilingError, + InvalidAllocationNoteError, + InvalidAllocationReasonError, + validate_allocation_ceiling, +) + +__all__ = [ + "ALLOCATION_NOTE_MAX_LENGTH", + "Allocation", + "AllocationActivated", + "AllocationAlreadyActiveError", + "AllocationAlreadyExistsError", + "AllocationCannotActivateError", + "AllocationCannotAmendCeilingError", + "AllocationCannotSealError", + "AllocationCannotVoidError", + "AllocationCeilingAmended", + "AllocationEvent", + "AllocationGranted", + "AllocationNotFoundError", + "AllocationNote", + "AllocationReason", + "AllocationSealed", + "AllocationStatus", + "AllocationVoided", + "InvalidAllocationCeilingError", + "InvalidAllocationNoteError", + "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..40ca1eaa568 --- /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 + CampaignClosed subscriber seals this envelope beside the + campaign's own books. + """ + + allocation_id: UUID + ceiling_usd: float + campaign_id: UUID | None + 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, + note=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, + "note": 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), + note=payload["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..9bdd304d700 --- /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, + AllocationNote, + 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, + note=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, + note=AllocationNote(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..5ac7af8df9d --- /dev/null +++ b/apps/api/src/cora/budget/aggregates/allocation/state.py @@ -0,0 +1,323 @@ +"""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, 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_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 InvalidAllocationNoteError(ValueError): + """The supplied note is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Allocation note must be 1-{ALLOCATION_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 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 + 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_NOTE_MAX_LENGTH, + error_class=InvalidAllocationNoteError, +) +@dataclass(frozen=True) +class AllocationNote: + """Operator-facing name for the envelope. Trimmed; 1-200 chars. + + The note labels the envelope for operators (award cycle, proposal + block). + """ + + 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 + note: AllocationNote + 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..c6db920267e --- /dev/null +++ b/apps/api/src/cora/budget/features/activate_allocation/handler.py @@ -0,0 +1,153 @@ +"""Application handler for the `activate_allocation` slice. + +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`. +""" + +from typing import Protocol +from uuid import UUID + +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): + """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.""" + + 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/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..4dc4d5a0a27 --- /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 + -> `AllocationCannotAmendCeilingError` + - `ceiling_usd` must be finite and strictly positive + -> `InvalidAllocationCeilingError` +""" + +from datetime import datetime + +from cora.budget.aggregates.allocation import ( + Allocation, + AllocationCannotAmendCeilingError, + 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 + -> 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 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. + 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..6326830a9c1 --- /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, + 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..526c87dea8d --- /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 note, and an optional +`campaign_id` that binds the award window to a Campaign's lifecycle +(the 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 + 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..2c7c5a77f73 --- /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` + - `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. + +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, + AllocationNote, + 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 + - Note must be valid -> InvalidAllocationNoteError + (via AllocationNote VO) + """ + if state is not None: + raise AllocationAlreadyExistsError(state.id) + + validate_allocation_ceiling(command.ceiling_usd) + note = AllocationNote(command.note) + + return [ + AllocationGranted( + allocation_id=new_id, + ceiling_usd=command.ceiling_usd, + campaign_id=command.campaign_id, + note=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..98741830297 --- /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 / 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_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.", + ) + note: str = Field( + ..., + min_length=1, + 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." + ), + ) + 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 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, + note=body.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..cfae36e01c0 --- /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_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, 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.", + ), + ], + note: Annotated[ + str, + Field( + min_length=1, + max_length=ALLOCATION_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, + note=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..8e8ac532e2f --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/__init__.py @@ -0,0 +1,40 @@ +"""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=...) + +`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 +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, + make_ledger_total_spend, + zero_total_spend, +) +from cora.budget.features.seal_allocation.route import router + +__all__ = [ + "Handler", + "SealAllocation", + "TotalSpendReader", + "bind", + "decide", + "make_ledger_total_spend", + "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..740be303239 --- /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 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..66512e932d8 --- /dev/null +++ b/apps/api/src/cora/budget/features/seal_allocation/handler.py @@ -0,0 +1,218 @@ +"""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: +`wire.py` binds `make_ledger_total_spend(deps.spend_lookup)` (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. + +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 TYPE_CHECKING, 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 + +if TYPE_CHECKING: + from cora.infrastructure.ports import SpendLookup + +_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. 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: + """Ledger-less reader: every seal records `spent_usd = 0.0`. + + 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). + 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.""" + + 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/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..28b2b9803f0 --- /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, 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["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/routes.py b/apps/api/src/cora/budget/routes.py new file mode 100644 index 00000000000..639490750d7 --- /dev/null +++ b/apps/api/src/cora/budget/routes.py @@ -0,0 +1,138 @@ +"""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, + InvalidAllocationNote, 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 ( + AllocationAlreadyActiveError, + AllocationAlreadyExistsError, + AllocationCannotActivateError, + AllocationCannotAmendCeilingError, + AllocationCannotSealError, + AllocationCannotVoidError, + AllocationNotFoundError, + InvalidAllocationCeilingError, + InvalidAllocationNoteError, + 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, + 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, + AllocationAlreadyActiveError, + ): + app.add_exception_handler(already_exists_cls, _handle_already_exists) + for cannot_transition_cls in ( + AllocationCannotActivateError, + AllocationCannotAmendCeilingError, + 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/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..dfffc954fe1 --- /dev/null +++ b/apps/api/src/cora/budget/subscribers/allocation_sealer.py @@ -0,0 +1,281 @@ +"""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. 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, 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 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 + +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") + +_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__) + + +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 + + 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, + ) + 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, + ) + + 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. 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.sealed", + allocation_id=str(state.id), + spent_usd=spent_usd, + ) + return + + +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/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..05eb0b4ebbc --- /dev/null +++ b/apps/api/src/cora/budget/wire.py @@ -0,0 +1,103 @@ +"""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) + +## TotalSpendReader + +`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 `zero_total_spend` seam promised). +""" + +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 make_ledger_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=make_ledger_total_spend(deps.spend_lookup) + ), + command_name="SealAllocation", + bc=_BC, + ), + void_allocation=with_tracing( + void_allocation.bind(deps), + command_name="VoidAllocation", + 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/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 492fab4ee58..6b04d1cf7df 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, @@ -241,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 @@ -402,6 +413,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 +457,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 +573,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 `AllocationLookupResult`. + `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 +662,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 +802,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 +1088,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, @@ -1120,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, @@ -1154,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 @@ -1167,6 +1223,8 @@ async def build_kernel( if model_usage_lookup_factory is not None else AlwaysEmptyModelUsageLookup() ) + # 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 @@ -1233,6 +1291,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..9403cfdcb9c 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 ( + AllocationLookup, + AllocationLookupResult, + 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, @@ -184,6 +190,8 @@ "AgentInferenceTrace", "AllBeamOpenLookup", "AllSatisfiedSupplyLookup", + "AllocationLookup", + "AllocationLookupResult", "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..3b659708e27 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/allocation_lookup.py @@ -0,0 +1,91 @@ +"""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_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 +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 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. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol +from uuid import UUID + + +@dataclass(frozen=True) +class AllocationLookupResult: + """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) -> AllocationLookupResult | 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) -> AllocationLookupResult | None: + return None + + +__all__ = [ + "AllocationLookup", + "AllocationLookupResult", + "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 bd4ce006f81..aa0f76cadab 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,19 @@ 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 (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"] + # API entrypoint composes every BC. [[modules]] path = "cora.api" @@ -404,6 +421,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_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 1d8ad54500d..715bd0733b8 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): 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/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 new file mode 100644 index 00000000000..696cbb2ed53 --- /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, 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_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/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..c1f20d78e19 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 `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. """ 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..95441b7314f 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 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 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: AllocationLookupResult | None = None) -> None: + self.active = active + self.find_active_calls = 0 + + async def find_active(self) -> AllocationLookupResult | 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..310453260f4 100644 --- a/apps/api/tests/unit/agent/test_budget_gate.py +++ b/apps/api/tests/unit/agent/test_budget_gate.py @@ -8,16 +8,36 @@ from cora.agent._budget_gate import ( calendar_day_window, calendar_month_window, + find_allocation_breach, find_budget_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 ( + AllocationLookupResult, + 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) -> AllocationLookupResult: + return AllocationLookupResult( + 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_allocation_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_allocation_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_allocation_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_allocation_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_allocation_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_allocation_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_allocation_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..d69cce12b76 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 AllocationLookupResult 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) -> AllocationLookupResult: + return AllocationLookupResult( + allocation_id=uuid4(), + ceiling_usd=ceiling_usd, + activated_at=_ENVELOPE_ACTIVATED_AT, + campaign_id=None, + ) + async def _versioned_agent( store: InMemoryEventStore, @@ -141,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() @@ -219,3 +250,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..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,6 +46,7 @@ LLMServerError, LLMUsage, ) +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult 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,107 @@ 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 = AllocationLookupResult( + 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_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 @@ -997,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 9c5c2252c6b..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,6 +50,7 @@ LLMServerError, LLMUsage, ) +from cora.infrastructure.ports.allocation_lookup import AllocationLookupResult 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,109 @@ 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 = AllocationLookupResult( + 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_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 @@ -1619,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/__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..cc06aaf8b3b --- /dev/null +++ b/apps/api/tests/unit/budget/_helpers.py @@ -0,0 +1,111 @@ +"""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, + AllocationNote, + 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, + note=AllocationNote("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, + *, + ceiling_usd: float = 25000.0, + campaign_id: UUID | None = None, +) -> AllocationGranted: + return AllocationGranted( + allocation_id=allocation_id, + ceiling_usd=ceiling_usd, + campaign_id=campaign_id, + 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..76f2af17787 --- /dev/null +++ b/apps/api/tests/unit/budget/test_activate_allocation_handler.py @@ -0,0 +1,234 @@ +"""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 ( + AllocationAlreadyActiveError, + 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 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, + 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" + + +_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 new file mode 100644 index 00000000000..b9937b21fc5 --- /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, + 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.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_allocation_sealer_subscriber.py b/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py new file mode 100644 index 00000000000..bd8f92df44b --- /dev/null +++ b/apps/api/tests/unit/budget/test_allocation_sealer_subscriber.py @@ -0,0 +1,371 @@ +"""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. +""" + +# pyright: reportPrivateUsage=false + +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock +from uuid import UUID, uuid4, uuid5 + +import pytest + +from cora.budget.aggregates.allocation import AllocationStatus, load_allocation +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 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 +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: AllocationLookupResult | None) -> None: + self.active = active + + async def find_active(self) -> AllocationLookupResult | 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) -> AllocationLookupResult: + return AllocationLookupResult( + 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 + # 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 +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() -> AllocationLookupResult | None: + nonlocal called + called = True + return None + + monkeypatch.setattr(lookup, "find_active", _boom) + 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 new file mode 100644 index 00000000000..0fca1a58e78 --- /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), + "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, + "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/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..3667bb329a4 --- /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 ( + AllocationCannotAmendCeilingError, + 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(AllocationCannotAmendCeilingError): + 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..429061539f3 --- /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 + `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. + - 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 ( + AllocationCannotAmendCeilingError, + 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(AllocationCannotAmendCeilingError) 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..226ebfeb4e8 --- /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 ( + AllocationCannotAmendCeilingError, + 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(AllocationCannotAmendCeilingError): + 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..2cddf26aba1 --- /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_NOTE_MAX_LENGTH, + AllocationAlreadyExistsError, + AllocationGranted, + AllocationStatus, + InvalidAllocationCeilingError, + InvalidAllocationNoteError, +) +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, + "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, + 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_NOTE_MAX_LENGTH + 1)]) +def test_invalid_note_raises(bad_note: str) -> None: + with pytest.raises(InvalidAllocationNoteError): + decide( + state=None, + command=_command(note=bad_note), + now=_NOW, + new_id=_NEW_ID, + granted_by=_GRANTED_BY, + ) + + +@pytest.mark.unit +def test_note_trim_propagates() -> None: + """The VO trims; the decider passes the trimmed value into the event.""" + events = decide( + state=None, + command=_command(note=" FY26 imaging award "), + now=_NOW, + new_id=_NEW_ID, + granted_by=_GRANTED_BY, + ) + assert events[0].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..62c4a0c5e38 --- /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, 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, + 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, + 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, note=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.note == 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, 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, 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..183e72d0aac --- /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, + "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, + 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["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..a5d8d028ad5 --- /dev/null +++ b/apps/api/tests/unit/budget/test_seal_allocation_handler.py @@ -0,0 +1,224 @@ +"""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 explicit-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: + """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) + 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 == [] + + +@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/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..75ab69e167e 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 budget BC). 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: @@ -123,11 +124,13 @@ 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 - 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/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 new file mode 100644 index 00000000000..5fa5b41865b --- /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, + 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..e631fc20786 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:eIoAo5tEj7taBRkIG9fTABxQsaSy8V0uJ9FtUb7fz2Y= +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,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:72j+IH7DwQ9xmV1srtNszWNLhBmIu3nDljwIHYYnm8E= 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", } )