diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 585a4d9ca..34261da04 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -4658,6 +4658,52 @@ "title": "DefinePracticeResponse", "type": "object" }, + "DefineRecipeRequest": { + "description": "Body for `POST /recipes`.", + "properties": { + "capability_id": { + "description": "Capability this Recipe realizes. REQUIRED and IMMUTABLE across versions; re-binding requires authoring a new Recipe.", + "format": "uuid", + "title": "Capability Id", + "type": "string" + }, + "name": { + "description": "Display name for the new Recipe.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "steps": { + "additionalProperties": true, + "description": "Wire-format step sequence: `{steps: [{kind: setpoint|action|check, ...}]}`. Each `value` or `params[k]` position may carry `{__binding__: name}` to reference a Capability parameter.", + "title": "Steps", + "type": "object" + } + }, + "required": [ + "name", + "capability_id", + "steps" + ], + "title": "DefineRecipeRequest", + "type": "object" + }, + "DefineRecipeResponse": { + "description": "Response body for `POST /recipes`.", + "properties": { + "recipe_id": { + "format": "uuid", + "title": "Recipe Id", + "type": "string" + } + }, + "required": [ + "recipe_id" + ], + "title": "DefineRecipeResponse", + "type": "object" + }, "DefineSurfaceRequest": { "description": "Body for `POST /surfaces`.", "properties": { @@ -4836,6 +4882,26 @@ "title": "DeprecateModelRequest", "type": "object" }, + "DeprecateRecipeRequest": { + "description": "Body for `POST /recipes/{recipe_id}/deprecate`.\n\nOptional `replaced_by_recipe_id` pointer for the successor\nRecipe. Omit entirely for deprecated-without-replacement.", + "properties": { + "replaced_by_recipe_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional pointer to a successor Recipe (LOINC `MAP_TO` precedent). None means deprecated-without-replacement.", + "title": "Replaced By Recipe Id" + } + }, + "title": "DeprecateRecipeRequest", + "type": "object" + }, "DeregisterSupplyRequest": { "description": "Body for `POST /supplies/{supply_id}/deregister`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining why the supply is being deregistered. Examples:\n\"typo on scope at registration; re-registering correctly\",\n\"beamline retired\", \"duplicate of supply \".", "properties": { @@ -7815,6 +7881,105 @@ "title": "ReceiptKind", "type": "string" }, + "RecipeResponse": { + "description": "Read-side DTO at the API boundary.\n\nCarries primitives, not domain VOs. `status` is the StrEnum's\nstring value (Defined / Versioned / Deprecated). `version` is\nthe operator-supplied label of the most recent `version_recipe`\ncall (null until first version). `steps` is the wire-format\ndict (BindingRef sentinels serialize as `{__binding__: name}`).\n`replaced_by_recipe_id` is null on Defined / Versioned /\nDeprecated-without-replacement; populated when a deprecation\nsupplied a successor pointer. `created_at` / `versioned_at` /\n`deprecated_at` are projection-sourced lifecycle timestamps\n(Path C); see module docstring for null semantics.", + "properties": { + "capability_id": { + "format": "uuid", + "title": "Capability Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "deprecated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deprecated At" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "maxLength": 200, + "title": "Name", + "type": "string" + }, + "replaced_by_recipe_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Replaced By Recipe Id" + }, + "status": { + "title": "Status", + "type": "string" + }, + "steps": { + "additionalProperties": true, + "title": "Steps", + "type": "object" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + }, + "versioned_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Versioned At" + } + }, + "required": [ + "id", + "name", + "capability_id", + "status", + "version", + "steps", + "replaced_by_recipe_id" + ], + "title": "RecipeResponse", + "type": "object" + }, "ReferenceSurface": { "description": "The physical feature of a part that a Placement is measured FROM.\n\nClosed enum; CORA precedent is the Affordance StrEnum. Widening\nrules and GD&T-term-collision rationale live in the module docstring.", "enum": [ @@ -8798,6 +8963,81 @@ "title": "RegisterMountResponse", "type": "object" }, + "RegisterProcedureFromRecipeRequest": { + "description": "Body for `POST /procedures/from-recipe`.", + "properties": { + "bindings": { + "additionalProperties": true, + "description": "Operator-supplied parameter values keyed by the parameter names declared in the bound Recipe's Capability's parameters_schema. Substituted into BindingRef sentinels at expansion time. Empty dict valid when the Recipe carries no BindingRefs.", + "title": "Bindings", + "type": "object" + }, + "kind": { + "description": "Free-form ISA-106 procedure-kind discriminator (bakeout, calibration, alignment, recovery, etc.). Mirrors register_procedure's kind field exactly.", + "maxLength": 50, + "minLength": 1, + "title": "Kind", + "type": "string" + }, + "name": { + "description": "Operator-readable display name for the procedure. Mirrors register_procedure's name field exactly.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "parent_run_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional parent Run binding. None = standalone procedure. Set = Phase-of-Run procedure.", + "title": "Parent Run Id" + }, + "recipe_id": { + "description": "Recipe whose templated steps will be expanded into this Procedure. Loaded cross-BC at handler time; missing -> 404.", + "format": "uuid", + "title": "Recipe Id", + "type": "string" + }, + "target_asset_ids": { + "description": "Asset ids this procedure acts on. May be empty (valid for facility-envelope procedures). Eventual-consistency: ids are NOT verified at register time.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Target Asset Ids", + "type": "array" + } + }, + "required": [ + "name", + "kind", + "recipe_id" + ], + "title": "RegisterProcedureFromRecipeRequest", + "type": "object" + }, + "RegisterProcedureFromRecipeResponse": { + "description": "Response body for `POST /procedures/from-recipe`.", + "properties": { + "procedure_id": { + "format": "uuid", + "title": "Procedure Id", + "type": "string" + } + }, + "required": [ + "procedure_id" + ], + "title": "RegisterProcedureFromRecipeResponse", + "type": "object" + }, "RegisterProcedureRequest": { "description": "Body for `POST /procedures`.", "properties": { @@ -11472,6 +11712,30 @@ "title": "VersionPracticeRequest", "type": "object" }, + "VersionRecipeRequest": { + "description": "Body for `POST /recipes/{recipe_id}/version`.", + "properties": { + "steps": { + "additionalProperties": true, + "description": "Replacement step sequence for the new version (wholesale replace; the prior steps are dropped). BindingRef sentinels are re-validated against the CURRENT Capability.parameters_schema.", + "title": "Steps", + "type": "object" + }, + "version_tag": { + "description": "Operator-supplied label for this revision (for example 'v2', '2026-Q3'). Free text; institution-specific. NOT constrained UNIQUE across versions; same tag + same steps re-emits the event as a re-attestation audit signal.", + "maxLength": 50, + "minLength": 1, + "title": "Version Tag", + "type": "string" + } + }, + "required": [ + "version_tag", + "steps" + ], + "title": "VersionRecipeRequest", + "type": "object" + }, "VisitType": { "description": "Closed enum classifying the operational nature of a Visit.\n\nFive values per `[[project_visit_aggregate_design]]`. Replaces the\nsentinel-value anti-pattern (DMagic `--gup 0`, NICOS demo=0).\n\n - `user` -- proposal-driven user beamtime\n - `commissioning`-- detector / mechanism commissioning (nested as\n partOf a parent user Visit per S2 scenario)\n - `maintenance` -- preventative or corrective maintenance window\n - `calibration` -- standalone calibration block (CalibrationRevision\n is the data-side counterpart in Calibration BC)\n - `staff` -- staff-only work outside a proposal envelope", "enum": [ @@ -27591,20 +27855,26 @@ ] } }, - "/procedures/{procedure_id}": { - "get": { - "operationId": "get_procedures_procedures__procedure_id__get", + "/procedures/from-recipe": { + "post": { + "operationId": "post_procedures_from_recipe_procedures_from_recipe_post", "parameters": [ { - "description": "Target procedure's id.", - "in": "path", - "name": "procedure_id", - "required": true, + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the procedure.", + "in": "header", + "name": "Idempotency-Key", + "required": false, "schema": { - "description": "Target procedure's id.", - "format": "uuid", - "title": "Procedure Id", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the procedure.", + "title": "Idempotency-Key" } }, { @@ -27627,18 +27897,135 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterProcedureFromRecipeRequest" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProcedureResponse" + "$ref": "#/components/schemas/RegisterProcedureFromRecipeResponse" } } }, "description": "Successful Response" }, - "404": { + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (whitespace-only name or kind)." + }, + "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": "Referenced Recipe does not exist OR Recipe's bound Capability does not exist." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Capability.executor_shapes does not include Procedure (cross-BC executor-shape guard)." + }, + "422": { + "description": "Request body failed schema validation, OR Recipe's BindingRefs are stale against the current Capability parameters_schema, OR operator-supplied bindings did not validate against the Capability's parameters_schema, OR the expansion produced more than the configured cap." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Expansion port returned different results for the same (steps, bindings) input (server-side determinism bug)." + } + }, + "summary": "Register a new Procedure by expanding a Recipe with operator bindings", + "tags": [ + "operation" + ] + } + }, + "/procedures/{procedure_id}": { + "get": { + "operationId": "get_procedures_procedures__procedure_id__get", + "parameters": [ + { + "description": "Target procedure's id.", + "in": "path", + "name": "procedure_id", + "required": true, + "schema": { + "description": "Target procedure's id.", + "format": "uuid", + "title": "Procedure 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": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProcedureResponse" + } + } + }, + "description": "Successful Response" + }, + "404": { "content": { "application/json": { "schema": { @@ -28217,6 +28604,366 @@ ] } }, + "/recipes": { + "post": { + "operationId": "post_recipes_recipes_post", + "parameters": [ + { + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the Recipe.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the Recipe.", + "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/DefineRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefineRecipeResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (whitespace-only name, empty steps)." + }, + "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": "Referenced Capability does not exist." + }, + "422": { + "description": "Request body failed schema validation OR BindingRef in steps references a parameter not declared in the Capability's parameters_schema OR steps contain BindingRefs but the Capability has no parameters_schema." + } + }, + "summary": "Define a new Recipe against an existing Capability", + "tags": [ + "recipe" + ] + } + }, + "/recipes/{recipe_id}": { + "get": { + "operationId": "get_recipes_recipes__recipe_id__get", + "parameters": [ + { + "description": "Target Recipe's id.", + "in": "path", + "name": "recipe_id", + "required": true, + "schema": { + "description": "Target Recipe's id.", + "format": "uuid", + "title": "Recipe 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": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipeResponse" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No Recipe exists with the given id." + }, + "422": { + "description": "Path parameter failed schema validation." + } + }, + "summary": "Get a Recipe by id", + "tags": [ + "recipe" + ] + } + }, + "/recipes/{recipe_id}/deprecate": { + "post": { + "operationId": "post_recipes_deprecate_recipes__recipe_id__deprecate_post", + "parameters": [ + { + "description": "Target Recipe's id.", + "in": "path", + "name": "recipe_id", + "required": true, + "schema": { + "description": "Target Recipe's id.", + "format": "uuid", + "title": "Recipe 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/DeprecateRecipeRequest" + } + } + }, + "required": true + }, + "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 Recipe exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Recipe is already Deprecated." + }, + "422": { + "description": "Path parameter or request body failed schema validation." + } + }, + "summary": "Deprecate an existing Recipe", + "tags": [ + "recipe" + ] + } + }, + "/recipes/{recipe_id}/version": { + "post": { + "operationId": "post_recipes_version_recipes__recipe_id__version_post", + "parameters": [ + { + "description": "Target Recipe's id.", + "in": "path", + "name": "recipe_id", + "required": true, + "schema": { + "description": "Target Recipe's id.", + "format": "uuid", + "title": "Recipe 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/VersionRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (whitespace-only version_tag, empty steps)." + }, + "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 Recipe exists with the given id OR referenced Capability does not exist." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Recipe is currently Deprecated." + }, + "422": { + "description": "Path parameter or request body failed schema validation OR BindingRef in steps references a parameter not declared in the current Capability.parameters_schema." + } + }, + "summary": "Issue a new version label + replacement steps for a Recipe", + "tags": [ + "recipe" + ] + } + }, "/runs": { "get": { "operationId": "list_runs_runs_get", diff --git a/apps/api/src/cora/infrastructure/canonical_json.py b/apps/api/src/cora/infrastructure/canonical_json.py new file mode 100644 index 000000000..00e4e5149 --- /dev/null +++ b/apps/api/src/cora/infrastructure/canonical_json.py @@ -0,0 +1,36 @@ +"""Single-source canonical JSON encoder for deterministic content hashing. + +Stable byte output for the same logical value: sorted keys, no +whitespace, UTF-8 encoded. Per [[project-run-procedure-replay-design]] +both write-time hashing (decider) and replay-time hashing (handler) +call this helper so recorded content-address pins reproduce across +processes. Lives in infrastructure because the aggregates layer +(which produces canonical bytes for event payload persistence) cannot +import from BC-local helper modules; infrastructure is the lowest +common denominator across `cora.operation.aggregates` + handlers + the +shared `_recipe_expansion` helper. + +The architecture fitness in tests/architecture restricts +`json.dumps(sort_keys=True)` co-occurrence in the `cora.operation` and +`cora.recipe` trees to the few sites that re-export this helper. +Callers needing a dict-typed JSON value for persistence wrap as +`json.loads(canonical_json_bytes(...))`; the wrapper stays inline at +each call site rather than being hoisted so non-persisting callers do +not pay a parse-then-stringify roundtrip. See replay-design Anti-hook 18. +""" + +import json + + +def canonical_json_bytes(value: object) -> bytes: + """Encode `value` as canonical JSON bytes. + + Equivalent to `json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")`. + Use this helper everywhere a deterministic byte representation is + needed for hashing or content-addressed storage in the operation + + recipe BC trees. + """ + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +__all__ = ["canonical_json_bytes"] diff --git a/apps/api/src/cora/operation/_recipe_expansion.py b/apps/api/src/cora/operation/_recipe_expansion.py new file mode 100644 index 000000000..96f3a8e30 --- /dev/null +++ b/apps/api/src/cora/operation/_recipe_expansion.py @@ -0,0 +1,139 @@ +"""Pure `expand` for Recipe step tuples -> Conductor `Step` lists. + +Cross-BC bridge: the Recipe BC's `RecipeStep` union + `BindingRef` +sentinel describe parameterized scan recipes; the Operation BC's `Step` +union (`SetpointStep | ActionStep | CheckStep`) is what the Conductor +walks. The direction Operation -> Recipe is the allowed dependency +edge (tach-enforced), so this expansion bridge lives here. + +Per [[project-recipe-aggregate-design]] the expansion contract is +pure: no clock, no port I/O, no randomness, no module-global state. +Same inputs `(steps, bindings)` yield identical outputs. The +`register_procedure_from_recipe` slice re-runs `expand` once at +validation time and compares results to enforce determinism via the +`RecipeExpansionDeterminismError` rejection. +""" + +from collections.abc import Mapping +from typing import Any + +from cora.infrastructure.canonical_json import canonical_json_bytes +from cora.operation.conductor import ( + ActionStep, + CheckStep, + EqualsCriterion, + SetpointStep, + Step, + WithinToleranceCriterion, +) +from cora.recipe.aggregates.recipe import ( + RecipeActionStep, + RecipeSetpointStep, + RecipeStep, +) +from cora.recipe.aggregates.recipe.body import resolve_value + + +def _criterion_from_wire( + payload: Mapping[str, Any], +) -> EqualsCriterion | WithinToleranceCriterion: + """Translate a `RecipeCheckStep.criterion` wire dict to the typed union. + + Mirrors the Conductor's `_criterion_to_dict` serialization shape + arm-for-arm. Extension: a new criterion kind lands in three places: + the Conductor's `_criterion_to_dict` / `_criterion_matches` arms, + this function's arms, and the matching test in + `test_recipe_step_variants_match_step_union`. + """ + kind = payload["kind"] + if kind == "equals": + return EqualsCriterion(expected=payload["expected"]) + if kind == "within_tolerance": + return WithinToleranceCriterion( + expected=payload["expected"], tolerance=payload["tolerance"] + ) + msg = f"unknown criterion kind: {kind!r}" + raise ValueError(msg) + + +def _expand_step(step: RecipeStep, bindings: Mapping[str, Any]) -> Step: + """Expand one recipe step into a concrete `Step` per the union arm.""" + if isinstance(step, RecipeSetpointStep): + return SetpointStep( + address=step.address, + value=resolve_value(step.value, bindings), + verify=step.verify, + ) + if isinstance(step, RecipeActionStep): + return ActionStep( + name=step.name, + params={key: resolve_value(val, bindings) for key, val in step.params.items()}, + ) + # RecipeCheckStep: criterion is a wire-format dict (kept dict-shaped + # in Recipe BC to avoid an Operation -> Recipe import). + return CheckStep( + address=step.address, + criterion=_criterion_from_wire(step.criterion), + ) + + +def expand(steps: tuple[RecipeStep, ...], bindings: Mapping[str, Any]) -> tuple[Step, ...]: + """Expand `steps` against `bindings` to a flat tuple of Conductor `Step`s. + + Pure function: same inputs yield identical outputs. Order of `steps` + is preserved. + + Raises `UnboundRecipeBindingError` (from `cora.recipe.aggregates.recipe`) + if any `BindingRef.name` in `steps` is missing from `bindings`. Raises + `ValueError` for unknown criterion kinds in a `RecipeCheckStep`. Extra + bindings (keys in `bindings` that no `BindingRef` references) are + silently ignored. + """ + return tuple(_expand_step(step, bindings) for step in steps) + + +def _criterion_to_wire( + criterion: EqualsCriterion | WithinToleranceCriterion, +) -> dict[str, Any]: + """Mirrors `_criterion_from_wire`: typed -> wire dict.""" + if isinstance(criterion, EqualsCriterion): + return {"kind": "equals", "expected": criterion.expected} + return { + "kind": "within_tolerance", + "expected": criterion.expected, + "tolerance": criterion.tolerance, + } + + +def _step_to_wire(step: Step) -> dict[str, Any]: + if isinstance(step, SetpointStep): + return { + "kind": "setpoint", + "address": step.address, + "value": step.value, + "verify": step.verify, + } + if isinstance(step, ActionStep): + return { + "kind": "action", + "name": step.name, + "params": dict(step.params), + } + return { + "kind": "check", + "address": step.address, + "criterion": _criterion_to_wire(step.criterion), + } + + +def steps_to_wire(steps: tuple[Step, ...]) -> list[dict[str, Any]]: + """Canonical list-of-dicts for hashing or persisting expanded Steps. + + Downstream re-expansion (run-time replay) reuses this serializer + to recompute `steps_hash` from a freshly-expanded Recipe and + confirm it matches the `RecipeExpansionRecorded.steps_hash` pin. + """ + return [_step_to_wire(step) for step in steps] + + +__all__ = ["canonical_json_bytes", "expand", "steps_to_wire"] diff --git a/apps/api/src/cora/operation/_recipe_replay.py b/apps/api/src/cora/operation/_recipe_replay.py new file mode 100644 index 000000000..577d5bb9b --- /dev/null +++ b/apps/api/src/cora/operation/_recipe_replay.py @@ -0,0 +1,148 @@ +"""Recipe-expansion replay helpers for the `conduct_procedure` handler. + +Per [[project-run-procedure-replay-design]] the run-time replay path +locates the genesis `RecipeExpansionRecorded` provenance event in a +Procedure stream, extracts the pinned hash + bindings + port-version +tuple, then verifies a freshly-re-expanded `tuple[Step, ...]` matches +the recorded pins. This module collects the pure helpers; the handler +threads them after authz + Procedure load. + +This is the FIRST handler-tier site in CORA that reads +`StoredEvent.payload` directly outside a projection. Per replay-design +§Locks the rule-of-three threshold gates promoting the helper to a +shared module: when a SECOND handler (any BC) needs payload-direct +access, hoist `find_recipe_expansion_record` to a generic +`cora.infrastructure.event_payload` helper. For comparison, projections +also read `.payload` but at projection-fold time, not at +handler-orchestration time. See replay-design Anti-hook 12. +""" + +import hashlib +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Any, Literal +from uuid import UUID + +from cora.infrastructure.canonical_json import canonical_json_bytes +from cora.infrastructure.ports.event_store import StoredEvent +from cora.operation._recipe_expansion import steps_to_wire +from cora.operation.aggregates.procedure import ( + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, +) +from cora.operation.conductor import Step + + +@dataclass(frozen=True) +class RecipeExpansionPins: + """The replay-pinned subset of a `RecipeExpansionRecorded` payload. + + Constructed by `pins_from_payload`. Carries only the fields the + replay path needs (control flow), NOT the audit-only fields + (procedure_id, recipe_id, capability_id, capability_version, + step_count, occurred_at) which are read directly at the handler + entry for logging. + """ + + recipe_version: str | None + bindings: Mapping[str, Any] + bindings_hash: str + steps_hash: str + expansion_port_version: str + + +def find_recipe_expansion_record( + stored_events: Iterable[StoredEvent], +) -> StoredEvent | None: + """Locate the `RecipeExpansionRecorded` event in a Procedure stream. + + Scans linearly from head, returns the first match, early-exits on + first hit. In well-formed Recipe-driven Procedure streams the match + lands at index 1 (the second event in the genesis 2-event block + emitted by `register_procedure_from_recipe`); the unit test pins + this position invariant. Tail-scan is wrong: only the genesis + `RecipeExpansionRecorded` defines the replay snapshot. + + Returns `None` when no match. The caller decides whether None is + expected (legacy Procedure with `recipe_id is None`) or an error + (recipe-driven Procedure missing its provenance event, raised as + `RecipeExpansionRecordNotFoundError` by the handler). + """ + for event in stored_events: + if event.event_type == "RecipeExpansionRecorded": + return event + return None + + +_REQUIRED_PINS_KEYS = ( + "bindings", + "bindings_hash", + "expansion_port_version", + "steps_hash", +) + + +def pins_from_payload(procedure_id: UUID, payload: Mapping[str, Any]) -> RecipeExpansionPins: + """Extract the replay-pinned subset from a `RecipeExpansionRecorded` payload. + + Defensive: raises `RecipeExpansionRecordNotFoundError(procedure_id)` + if any required key is missing (covers the corrupt-payload case + distinct from missing-event case; both surface the same error + family per the replay-design lock on triage simplicity). + """ + missing = [key for key in _REQUIRED_PINS_KEYS if key not in payload] + if missing: + raise RecipeExpansionRecordNotFoundError(procedure_id) + return RecipeExpansionPins( + recipe_version=payload.get("recipe_version"), + bindings=dict(payload["bindings"]), + bindings_hash=payload["bindings_hash"], + steps_hash=payload["steps_hash"], + expansion_port_version=payload["expansion_port_version"], + ) + + +def verify_bindings_hash(procedure_id: UUID, pins: RecipeExpansionPins) -> None: + """Verify the recorded `bindings` payload still hashes to `bindings_hash`. + + Raises `RecipeExpansionReplayMismatchError(procedure_id, "bindings")` + on mismatch. Bindings drift is input drift (the recorded payload + no longer canonicalizes to its recorded hash, i.e. payload + corruption); failing it BEFORE the steps check isolates the failure + mode in the discriminator value, easier to triage than a downstream + steps mismatch caused by upstream binding corruption. + """ + recomputed = hashlib.sha256(canonical_json_bytes(dict(pins.bindings))).hexdigest() + if recomputed != pins.bindings_hash: + raise RecipeExpansionReplayMismatchError(procedure_id, "bindings") + + +def verify_steps_hash( + procedure_id: UUID, + steps: tuple[Step, ...], + pins: RecipeExpansionPins, +) -> None: + """Verify the re-expanded steps still hash to the recorded `steps_hash`. + + Raises `RecipeExpansionReplayMismatchError(procedure_id, "steps")` + on mismatch. Steps drift is expansion-logic drift (the port + produces different output for the same input than at write time); + runs AFTER `verify_bindings_hash` because steps drift downstream + of bindings is a confusing diagnostic. + """ + recomputed = hashlib.sha256(canonical_json_bytes(steps_to_wire(steps))).hexdigest() + if recomputed != pins.steps_hash: + raise RecipeExpansionReplayMismatchError(procedure_id, "steps") + + +MismatchField = Literal["bindings", "steps"] + + +__all__ = [ + "MismatchField", + "RecipeExpansionPins", + "find_recipe_expansion_record", + "pins_from_payload", + "verify_bindings_hash", + "verify_steps_hash", +] diff --git a/apps/api/src/cora/operation/adapters/in_memory_recipe_expansion_port.py b/apps/api/src/cora/operation/adapters/in_memory_recipe_expansion_port.py new file mode 100644 index 000000000..c53185592 --- /dev/null +++ b/apps/api/src/cora/operation/adapters/in_memory_recipe_expansion_port.py @@ -0,0 +1,46 @@ +"""Default `RecipeExpansionPort` adapter: pure delegation to `expand`. + +Wraps the module-level `cora.operation._recipe_expansion.expand` function +in a Protocol-conforming object and pins a stable `version` string. The +version is recorded in `RecipeExpansionRecorded` provenance events so +replay can verify which expander produced a given step sequence. + +A new expander version is a code change here: bump `version` to the +next stable tag when expansion semantics change in a way that affects +already-recorded provenance events. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + + from cora.operation.conductor import Step + from cora.recipe.aggregates.recipe import RecipeStep + +_DEFAULT_VERSION = "v1" + + +@dataclass(frozen=True) +class InMemoryRecipeExpansionPort: + """Pure-function `RecipeExpansionPort` backed by the default `expand`. + + `version` defaults to `"v1"` and is rarely overridden in production; + tests pass a different version when they need to assert provenance + carries the expander identity. + """ + + version: str = _DEFAULT_VERSION + + def expand( + self, steps: tuple[RecipeStep, ...], bindings: Mapping[str, Any] + ) -> tuple[Step, ...]: + from cora.operation._recipe_expansion import expand as _expand + + return _expand(steps, bindings) + + +__all__ = ["InMemoryRecipeExpansionPort"] diff --git a/apps/api/src/cora/operation/aggregates/procedure/__init__.py b/apps/api/src/cora/operation/aggregates/procedure/__init__.py index a974e0e22..af68eb2b5 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/__init__.py +++ b/apps/api/src/cora/operation/aggregates/procedure/__init__.py @@ -24,18 +24,23 @@ ProcedureStarted, ProcedureStepsLogbookOpened, ProcedureTruncated, + RecipeExpansionRecorded, event_type_name, from_stored, to_payload, ) from cora.operation.aggregates.procedure.evolver import evolve, fold -from cora.operation.aggregates.procedure.read import load_procedure +from cora.operation.aggregates.procedure.read import ( + load_procedure, + load_procedure_with_events, +) from cora.operation.aggregates.procedure.state import ( LOGBOOK_KIND_STEPS, PROCEDURE_ABORT_REASON_MAX_LENGTH, PROCEDURE_KIND_MAX_LENGTH, PROCEDURE_NAME_MAX_LENGTH, PROCEDURE_TRUNCATE_REASON_MAX_LENGTH, + RECIPE_EXPANSION_STEP_MAX, STEP_KIND_VALUES, STEPS_LOGBOOK_SCHEMA, InvalidProcedureAbortReasonError, @@ -43,6 +48,7 @@ InvalidProcedureKindError, InvalidProcedureNameError, InvalidProcedureTruncateReasonError, + InvalidRecipeBindingsError, InvalidStepKindError, Procedure, ProcedureAbortReason, @@ -57,9 +63,16 @@ ProcedurePlanAssetDecommissionedError, ProcedureRequiresAvailableSupplyError, ProcedureStatus, + ProcedureStepsForbiddenForRecipeDrivenError, ProcedureStepsLogbookClosedError, ProcedureSupplyCoverageMismatchError, ProcedureTruncateReason, + RecipeBindingsStaleAgainstCurrentCapabilityError, + RecipeExpansionDeterminismError, + RecipeExpansionOverflowError, + RecipeExpansionPortVersionMismatchError, + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, StepKind, ) @@ -69,6 +82,7 @@ "PROCEDURE_KIND_MAX_LENGTH", "PROCEDURE_NAME_MAX_LENGTH", "PROCEDURE_TRUNCATE_REASON_MAX_LENGTH", + "RECIPE_EXPANSION_STEP_MAX", "STEPS_LOGBOOK_SCHEMA", "STEP_KIND_VALUES", "InMemoryStepStore", @@ -77,6 +91,7 @@ "InvalidProcedureKindError", "InvalidProcedureNameError", "InvalidProcedureTruncateReasonError", + "InvalidRecipeBindingsError", "InvalidStepKindError", "PostgresStepStore", "Procedure", @@ -98,11 +113,19 @@ "ProcedureStarted", "ProcedureStatus", "ProcedureStep", + "ProcedureStepsForbiddenForRecipeDrivenError", "ProcedureStepsLogbookClosedError", "ProcedureStepsLogbookOpened", "ProcedureSupplyCoverageMismatchError", "ProcedureTruncateReason", "ProcedureTruncated", + "RecipeBindingsStaleAgainstCurrentCapabilityError", + "RecipeExpansionDeterminismError", + "RecipeExpansionOverflowError", + "RecipeExpansionPortVersionMismatchError", + "RecipeExpansionRecordNotFoundError", + "RecipeExpansionRecorded", + "RecipeExpansionReplayMismatchError", "StepKind", "StepStore", "event_type_name", @@ -110,5 +133,6 @@ "fold", "from_stored", "load_procedure", + "load_procedure_with_events", "to_payload", ] diff --git a/apps/api/src/cora/operation/aggregates/procedure/events.py b/apps/api/src/cora/operation/aggregates/procedure/events.py index 93deb14b4..e85861623 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/events.py +++ b/apps/api/src/cora/operation/aggregates/procedure/events.py @@ -47,11 +47,14 @@ `SupplyRegistered` / `SubjectMounted`. """ +import json +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from typing import Any, assert_never from uuid import UUID +from cora.infrastructure.canonical_json import canonical_json_bytes from cora.infrastructure.event_payload import deserialize_or_raise from cora.infrastructure.logbook import LogbookSchema from cora.infrastructure.ports.event_store import StoredEvent @@ -71,11 +74,21 @@ class ProcedureRegistered: `parent_run_id` carries the optional Run binding (None for standalone procedures, set for Phase-of-Run procedures). - `capability_id` is the optional cross-BC - binding to the universal Capability template (Recipe BC) - this Procedure realizes as a Procedure-shaped executor. None for - legacy Procedures and for ceremony Procedures with no template - binding. Same additive shape as Method.capability_id. + `capability_id` is the optional cross-BC binding to the universal + Capability template (Recipe BC) this Procedure realizes as a + Procedure-shaped executor. None for legacy Procedures and for + ceremony Procedures with no template binding. Same additive shape + as Method.capability_id. + + `recipe_id` is the optional cross-BC binding to the Recipe whose + steps were expanded into this Procedure via the + `register_procedure_from_recipe` slice. None for legacy Procedures + (registered via `register_procedure` with inline steps) and for + ceremony Procedures with no Recipe binding. When set, + `capability_id` carries the Recipe's `capability_id` as a denorm + for audit-by-Capability read paths without requiring a Recipe + join. Additive payload field; pre-rewrite streams fold via + `payload.get("recipe_id")` -> None. """ procedure_id: UUID @@ -85,6 +98,62 @@ class ProcedureRegistered: parent_run_id: UUID | None occurred_at: datetime capability_id: UUID | None = None + recipe_id: UUID | None = None + + +@dataclass(frozen=True) +class RecipeExpansionRecorded: + """Provenance event: a Recipe's steps were expanded into this Procedure. + + Emitted alongside `ProcedureRegistered` by the + `register_procedure_from_recipe` slice, NOT by `register_procedure`. + Captures the template-invocation grain provenance per the design + lock ([[project-recipe-aggregate-design]]): one event per Recipe + invocation, NOT one per expanded step. Per-step records live in + `entries_operation_procedure_steps` via the existing + `append_procedure_steps` handler; this event lifts the binding + context above the per-step granularity so PROV-O / 21 CFR Part 11 + audit trails point at the activity that produced the entity, not + at every intermediate state. + + `recipe_id` is the Recipe whose steps were expanded. `recipe_version` + pins which Recipe-version's steps were active at expansion time + (without this, replay after a `version_recipe` call would resolve + to different steps and lose determinism). + + `capability_id` + `capability_version` are denormalized for + audit-by-Capability read paths (find all Procedures expanded from + this Capability) without requiring a Recipe join. Recipe.capability_id + is the source of truth; the denorm here mirrors the Procedure + aggregate state pin per anti-hook 15 of [[project-recipe-aggregate-design]]. + + `bindings` carries the operator-supplied parameter values verbatim + for replay (serialized via `json.dumps(..., sort_keys=True)` for + canonical-JSON content hashing). `expansion_port_version` records + which expander emitted the steps (the design memo's "non-determinism + captured via port injection" principle). `steps_hash` (renamed from + the worktree's `template_hash`) + `bindings_hash` are content-hashes + enabling cheap equality checks at projection time; `step_count` is + the number of expanded Steps the slice paginated through. + + Provenance-only: the evolver leaves `Procedure` state unchanged + when this event arrives. Replay of `(recipe_id, recipe_version, + bindings, expansion_port_version)` reconstructs the step sequence + deterministically by re-loading Recipe at the recorded version and + re-running expand. + """ + + procedure_id: UUID + recipe_id: UUID + recipe_version: str | None + capability_id: UUID + capability_version: str | None + bindings: Mapping[str, Any] + expansion_port_version: str + steps_hash: str + bindings_hash: str + step_count: int + occurred_at: datetime @dataclass(frozen=True) @@ -230,6 +299,7 @@ class ProcedureAborted: | ProcedureAborted | ProcedureTruncated | ProcedureStepsLogbookOpened + | RecipeExpansionRecorded ) @@ -255,6 +325,7 @@ def to_payload(event: ProcedureEvent) -> dict[str, Any]: parent_run_id=parent_run_id, occurred_at=occurred_at, capability_id=capability_id, + recipe_id=recipe_id, ): return { "procedure_id": str(procedure_id), @@ -262,10 +333,16 @@ def to_payload(event: ProcedureEvent) -> dict[str, Any]: "kind": kind, "target_asset_ids": sorted(str(a) for a in target_asset_ids), "parent_run_id": str(parent_run_id) if parent_run_id is not None else None, - # None when register_procedure omits - # capability_id. Pre-10d streams fold via `.get("capability_id")` - # in from_stored. Mirrors Method.capability_id (6l-additive). + # None when register_procedure omits capability_id. + # Pre-10d streams fold via `.get("capability_id")` in + # from_stored. Mirrors Method.capability_id (6l-additive). "capability_id": str(capability_id) if capability_id is not None else None, + # None when register_procedure (legacy slice) omits + # recipe_id. register_procedure_from_recipe sets both + # `recipe_id` and the denorm `capability_id` to the + # Recipe's capability_id. Pre-rewrite streams fold via + # `.get("recipe_id")` in from_stored. + "recipe_id": str(recipe_id) if recipe_id is not None else None, "occurred_at": occurred_at.isoformat(), } case ProcedureStarted(procedure_id=procedure_id, occurred_at=occurred_at): @@ -311,6 +388,39 @@ def to_payload(event: ProcedureEvent) -> dict[str, Any]: "schema": schema.to_dict(), "occurred_at": occurred_at.isoformat(), } + case RecipeExpansionRecorded( + procedure_id=procedure_id, + recipe_id=recipe_id, + recipe_version=recipe_version, + capability_id=capability_id, + capability_version=capability_version, + bindings=bindings, + expansion_port_version=expansion_port_version, + steps_hash=steps_hash, + bindings_hash=bindings_hash, + step_count=step_count, + occurred_at=occurred_at, + ): + # Canonical-JSON bytes via the shared `canonical_json_bytes` + # helper, then `json.loads` to keep the persisted `bindings` + # field a dict (matches `from_stored`'s `dict(payload['bindings'])` + # consumer at line 528). The single-source canonicalizer keeps + # `sha256(payload['bindings'])` reproducible against the + # decider's at-write `bindings_hash`. Recipe.steps wire-format + # is JSON-friendly by construction (no UUID values inside). + return { + "procedure_id": str(procedure_id), + "recipe_id": str(recipe_id), + "recipe_version": recipe_version, + "capability_id": str(capability_id), + "capability_version": capability_version, + "bindings": json.loads(canonical_json_bytes(dict(bindings))), + "expansion_port_version": expansion_port_version, + "steps_hash": steps_hash, + "bindings_hash": bindings_hash, + "step_count": step_count, + "occurred_at": occurred_at.isoformat(), + } case _: # pragma: no cover # exhaustiveness guard assert_never(event) @@ -338,10 +448,12 @@ def from_stored(stored: StoredEvent) -> ProcedureEvent: def _build_registered() -> ProcedureRegistered: raw_parent = payload["parent_run_id"] - # capability_id is OPTIONAL on the payload. - # Pre-10d streams omit the key entirely; fold via `.get` → - # None default. Mirrors Method.capability_id (6l-additive). + # capability_id and recipe_id are OPTIONAL on the payload. + # Pre-binding streams omit capability_id; pre-Recipe-rewrite + # streams omit recipe_id. Fold via `.get` -> None default. + # Mirrors Method.capability_id additive-evolution pattern. raw_capability = payload.get("capability_id") + raw_recipe = payload.get("recipe_id") return ProcedureRegistered( procedure_id=UUID(payload["procedure_id"]), name=payload["name"], @@ -349,6 +461,7 @@ def _build_registered() -> ProcedureRegistered: target_asset_ids=tuple(UUID(a) for a in payload["target_asset_ids"]), parent_run_id=UUID(raw_parent) if raw_parent is not None else None, capability_id=UUID(raw_capability) if raw_capability is not None else None, + recipe_id=UUID(raw_recipe) if raw_recipe is not None else None, occurred_at=datetime.fromisoformat(payload["occurred_at"]), ) @@ -405,6 +518,24 @@ def _build_truncated() -> ProcedureTruncated: occurred_at=datetime.fromisoformat(payload["occurred_at"]), ), ) + case "RecipeExpansionRecorded": + return deserialize_or_raise( + "RecipeExpansionRecorded", + lambda: RecipeExpansionRecorded( + procedure_id=UUID(payload["procedure_id"]), + recipe_id=UUID(payload["recipe_id"]), + recipe_version=payload.get("recipe_version"), + capability_id=UUID(payload["capability_id"]), + capability_version=payload.get("capability_version"), + bindings=dict(payload["bindings"]), + expansion_port_version=payload["expansion_port_version"], + steps_hash=payload["steps_hash"], + bindings_hash=payload["bindings_hash"], + step_count=int(payload["step_count"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + extra=(ValueError,), + ) case _: msg = f"Unknown ProcedureEvent event_type: {stored.event_type!r}" raise ValueError(msg) @@ -418,6 +549,7 @@ def _build_truncated() -> ProcedureTruncated: "ProcedureStarted", "ProcedureStepsLogbookOpened", "ProcedureTruncated", + "RecipeExpansionRecorded", "event_type_name", "from_stored", "to_payload", diff --git a/apps/api/src/cora/operation/aggregates/procedure/evolver.py b/apps/api/src/cora/operation/aggregates/procedure/evolver.py index 9551f1664..d4834dc97 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/evolver.py +++ b/apps/api/src/cora/operation/aggregates/procedure/evolver.py @@ -55,6 +55,7 @@ ProcedureStarted, ProcedureStepsLogbookOpened, ProcedureTruncated, + RecipeExpansionRecorded, ) from cora.operation.aggregates.procedure.state import ( Procedure, @@ -73,6 +74,7 @@ def evolve(state: Procedure | None, event: ProcedureEvent) -> Procedure: target_asset_ids=target_asset_ids, parent_run_id=parent_run_id, capability_id=capability_id, + recipe_id=recipe_id, ): _ = state # ProcedureRegistered is the genesis event; prior state ignored return Procedure( @@ -84,6 +86,7 @@ def evolve(state: Procedure | None, event: ProcedureEvent) -> Procedure: parent_run_id=parent_run_id, steps_logbook_id=None, capability_id=capability_id, + recipe_id=recipe_id, ) case ProcedureStarted(): prior = require_state(state, "ProcedureStarted") @@ -96,6 +99,7 @@ def evolve(state: Procedure | None, event: ProcedureEvent) -> Procedure: parent_run_id=prior.parent_run_id, steps_logbook_id=prior.steps_logbook_id, capability_id=prior.capability_id, + recipe_id=prior.recipe_id, ) case ProcedureCompleted(): prior = require_state(state, "ProcedureCompleted") @@ -108,6 +112,7 @@ def evolve(state: Procedure | None, event: ProcedureEvent) -> Procedure: parent_run_id=prior.parent_run_id, steps_logbook_id=prior.steps_logbook_id, capability_id=prior.capability_id, + recipe_id=prior.recipe_id, ) case ProcedureAborted(): prior = require_state(state, "ProcedureAborted") @@ -120,6 +125,7 @@ def evolve(state: Procedure | None, event: ProcedureEvent) -> Procedure: parent_run_id=prior.parent_run_id, steps_logbook_id=prior.steps_logbook_id, capability_id=prior.capability_id, + recipe_id=prior.recipe_id, ) case ProcedureTruncated(): prior = require_state(state, "ProcedureTruncated") @@ -132,6 +138,7 @@ def evolve(state: Procedure | None, event: ProcedureEvent) -> Procedure: parent_run_id=prior.parent_run_id, steps_logbook_id=prior.steps_logbook_id, capability_id=prior.capability_id, + recipe_id=prior.recipe_id, ) case ProcedureStepsLogbookOpened(logbook_id=logbook_id): # Lazy open-on-first-write: preserve all @@ -148,7 +155,17 @@ def evolve(state: Procedure | None, event: ProcedureEvent) -> Procedure: parent_run_id=prior.parent_run_id, steps_logbook_id=logbook_id, capability_id=prior.capability_id, + recipe_id=prior.recipe_id, ) + case RecipeExpansionRecorded(): + # Provenance-only event: leaves Procedure state unchanged. + # The full denormalized payload (recipe_id, recipe_version, + # capability_id, capability_version, bindings, + # expansion_port_version, steps_hash, bindings_hash, + # step_count) lives in the event stream for audit-replay; + # there is no projection-folded denorm onto Procedure state + # beyond what `ProcedureRegistered.recipe_id` already pins. + return require_state(state, "RecipeExpansionRecorded") case _: # pragma: no cover # exhaustiveness guard assert_never(event) diff --git a/apps/api/src/cora/operation/aggregates/procedure/read.py b/apps/api/src/cora/operation/aggregates/procedure/read.py index b1da26848..a02c831c2 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/read.py +++ b/apps/api/src/cora/operation/aggregates/procedure/read.py @@ -4,11 +4,22 @@ mirrors `load_supply` / `load_family` / `load_subject`. Used by the `get_procedure` query slice (10c-a) and update-style command handlers (10c-b transition slices). + +`load_procedure_with_events(event_store, procedure_id)` returns +`tuple[Procedure | None, list[StoredEvent]]`; extends the load shape +for handlers that need both the folded state +AND access to the raw `StoredEvent` payloads (the `conduct_procedure` +handler reads the `RecipeExpansionRecorded` provenance event payload +directly per [[project-run-procedure-replay-design]]). Single +underlying `event_store.load` call; `load_procedure` becomes a thin +wrapper that discards the StoredEvent list to preserve every existing +call site untouched. """ from uuid import UUID from cora.infrastructure.ports import EventStore +from cora.infrastructure.ports.event_store import StoredEvent from cora.operation.aggregates.procedure.events import from_stored from cora.operation.aggregates.procedure.evolver import fold from cora.operation.aggregates.procedure.state import Procedure @@ -16,8 +27,29 @@ _STREAM_TYPE = "Procedure" -async def load_procedure(event_store: EventStore, procedure_id: UUID) -> Procedure | None: - """Load and fold a Procedure's event stream into current state.""" +async def load_procedure_with_events( + event_store: EventStore, + procedure_id: UUID, +) -> tuple[Procedure | None, list[StoredEvent]]: + """Load Procedure state AND return the raw StoredEvent list. + + Single `event_store.load` call. Returns the folded `Procedure | None` + AND the raw `list[StoredEvent]` so handlers needing payload-direct + access (per [[project-run-procedure-replay-design]] §Operation BC + seam additions) do not double-IO. Most callers want + `load_procedure` instead; this entry point exists for the recipe + replay path that scans for `RecipeExpansionRecorded.payload`. + """ stored, _version = await event_store.load(_STREAM_TYPE, procedure_id) events = [from_stored(s) for s in stored] - return fold(events) + return fold(events), list(stored) + + +async def load_procedure(event_store: EventStore, procedure_id: UUID) -> Procedure | None: + """Load and fold a Procedure's event stream into current state. + + Thin wrapper over `load_procedure_with_events` that discards the + raw StoredEvent list. Existing call sites stay untouched. + """ + state, _events = await load_procedure_with_events(event_store, procedure_id) + return state diff --git a/apps/api/src/cora/operation/aggregates/procedure/state.py b/apps/api/src/cora/operation/aggregates/procedure/state.py index 661221fd6..333432cf2 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/state.py +++ b/apps/api/src/cora/operation/aggregates/procedure/state.py @@ -302,6 +302,196 @@ def __init__(self, procedure_id: UUID, capability_id: UUID) -> None: self.capability_id = capability_id +# Cap on the expanded step list at register_procedure_from_recipe time. +# Beyond this, the design memo's v2 lazy-walk reconsideration triggers +# (4D-tomography helical 150k-step case). v1 keeps a hard cap to bound +# the materialized expansion + the paginated append load. +RECIPE_EXPANSION_STEP_MAX = 10_000 + + +class RecipeExpansionOverflowError(Exception): + """The expanded flat step list exceeded `RECIPE_EXPANSION_STEP_MAX`. + + Carries the offending step count for operator diagnostics. v2 trigger: + when a real consumer's single Recipe template legitimately exceeds + the cap, the design memo's lazy-walk reconsideration fires. Mapped + to HTTP 422 (parse-shape failure past the Pydantic boundary). + """ + + def __init__(self, step_count: int, cap: int) -> None: + super().__init__(f"recipe expansion produced {step_count} steps; cap is {cap}") + self.step_count = step_count + self.cap = cap + + +class RecipeExpansionDeterminismError(Exception): + """Expansion port returned different results for the same `(steps, bindings)`. + + The `(steps, bindings) -> tuple[Step, ...]` contract is pure + (no clock, no port I/O, no randomness). The slice re-runs `expand` + once at validation time and compares; a mismatch is a server-side + bug in the expansion port or the recipe body, not operator error. + Single-arg constructor (recipe_id) per the design memo lock; + the diagnostic hashes go into the error message body. + Mapped to HTTP 500. + """ + + def __init__(self, recipe_id: UUID) -> None: + super().__init__(f"recipe expansion for Recipe {recipe_id} is non-deterministic") + self.recipe_id = recipe_id + + +class ProcedureStepsForbiddenForRecipeDrivenError(Exception): + """A non-empty `steps` list was supplied for a recipe-driven Procedure. + + Recipe-driven Procedures (created via `register_procedure_from_recipe`) + have their step list pinned by `RecipeExpansionRecorded`; the + `conduct_procedure` handler re-expands deterministically from the + pinned Recipe + bindings and ignores any caller-supplied steps. + Rather than silently override (which masks client bugs), the + handler rejects up front per [[project-run-procedure-replay-design]] + Anti-hook 7. Mapped to HTTP 400. + """ + + def __init__(self, procedure_id: UUID) -> None: + super().__init__( + f"Procedure {procedure_id} is recipe-driven; steps must be empty. " + f"The conduct_procedure handler re-expands from RecipeExpansionRecorded." + ) + self.procedure_id = procedure_id + + +class RecipeExpansionPortVersionMismatchError(Exception): + """The currently-wired `RecipeExpansionPort.version` differs from the pin. + + The `RecipeExpansionRecorded` event pins `expansion_port_version`; + the replay path runs a strict-equals guard against the live port's + `version` so a future v2 port cannot silently re-expand a v1-pinned + Procedure with potentially different outputs. Today only v1 exists; + this guard is the placeholder until a v2 expansion port lands with + its routing layer. Mapped to HTTP 500. + """ + + def __init__(self, procedure_id: UUID, recorded_version: str, current_version: str) -> None: + super().__init__( + f"Procedure {procedure_id} recipe expansion was recorded with " + f"port version {recorded_version!r}; the currently-wired port " + f"reports {current_version!r}. Re-expansion would be unsafe." + ) + self.procedure_id = procedure_id + self.recorded_version = recorded_version + self.current_version = current_version + + +class RecipeExpansionRecordNotFoundError(Exception): + """The recipe-driven Procedure cannot locate the pinned expansion record. + + Raised by the `conduct_procedure` recipe-replay path + (per [[project-run-procedure-replay-design]]) in any of three cases: + + - The Procedure stream carries no `RecipeExpansionRecorded` + event (stream truncation or a direct event-store write left + the genesis pair incomplete). + - The `RecipeExpansionRecorded` payload is corrupt: one or more + required keys are missing (caught by `pins_from_payload`'s + defensive check). + - The pinned Recipe stream itself is wholly empty when the + handler calls `load_recipe_at_version` (the operator-pinned + `recipe_id` references a Recipe with no genesis event). + + `register_procedure_from_recipe` emits both genesis events + atomically so the first two cases are unreachable in normal + operation; the third is unreachable while the event log stays + append-only. The error covers operator escape hatches around + stream truncation, manual event-store writes, or partial-write + failures. Mapped to HTTP 500. + """ + + def __init__(self, procedure_id: UUID) -> None: + super().__init__( + f"Procedure {procedure_id} has recipe_id set but the pinned " + f"RecipeExpansionRecorded event or the pinned Recipe stream " + f"could not be located; replay cannot proceed." + ) + self.procedure_id = procedure_id + + +class RecipeExpansionReplayMismatchError(Exception): + """Replay-time hash drift on a recipe-driven Procedure. + + Raised when the recorded bindings no longer hash to + `bindings_hash` (input drift, `mismatch_field='bindings'`) OR + the freshly re-expanded steps no longer hash to `steps_hash` + (expansion-logic drift, `mismatch_field='steps'`). Either case + indicates the expansion port regressed or the recorded payload + was mutated since write time, neither operator-correctable. + Closed Literal discriminator instead of two error classes per + [[project-run-procedure-replay-design]] Anti-hook 3. Mapped to + HTTP 500. + """ + + def __init__(self, procedure_id: UUID, mismatch_field: Literal["bindings", "steps"]) -> None: + super().__init__( + f"Procedure {procedure_id} recipe expansion replay produced a " + f"{mismatch_field}_hash mismatch against the recorded pin." + ) + self.procedure_id = procedure_id + self.mismatch_field = mismatch_field + + +class RecipeBindingsStaleAgainstCurrentCapabilityError(Exception): + """The Recipe's BindingRefs no longer resolve against the current Capability schema. + + Cross-BC race: Capability was versioned independently after the + Recipe's last write, and a binding name dropped (or the schema + transitioned to None while the Recipe still carries BindingRefs). + Operators resolve by versioning the Recipe (re-validating against + the current Capability) or by versioning the Capability back if + the schema change was unintended. + + Distinct from `RecipeBindingReferencesUnknownParameterError` (which + fires at Recipe-write time against the schema-at-write-time): this + error fires at register_procedure_from_recipe time against the + CURRENT Capability state. + + Mapped to HTTP 422 (parse-shape failure past the Pydantic boundary). + """ + + def __init__( + self, + recipe_id: UUID, + capability_id: UUID, + missing_binding_names: frozenset[str], + ) -> None: + names = sorted(missing_binding_names) + super().__init__( + f"Recipe {recipe_id} BindingRefs are stale against the current " + f"Capability {capability_id} schema; missing parameter(s): {names!r}. " + f"Re-version the Recipe to align with the current Capability schema." + ) + self.recipe_id = recipe_id + self.capability_id = capability_id + self.missing_binding_names = missing_binding_names + + +class InvalidRecipeBindingsError(ValueError): + """`bindings` did not validate against `Capability.parameters_schema`. + + Raised by the JSON-Schema validator inside the + `register_procedure_from_recipe` decider when operator-supplied + `bindings` do not satisfy the bound Capability's declared schema. + Distinct from `UnboundRecipeBindingError` (a BindingRef.name in the + Recipe's steps has no entry in `bindings`); this error fires when + `bindings` values fail the shape check. + + Mapped to HTTP 422 (parse-shape failure past the Pydantic boundary). + """ + + def __init__(self, reason: str) -> None: + super().__init__(f"invalid recipe bindings: {reason}") + self.reason = reason + + class ProcedureRequiresAvailableSupplyError(Exception): """No Supply registered for one of the parent Run's Method.needed_supplies kinds. @@ -667,3 +857,16 @@ class Procedure: Pattern P (or accept that ceremony Procedures stay un-bound when no Capability template applies). Same additive-state shape as Method.capability_id.""" + recipe_id: UUID | None = field(default=None) + """Optional pointer to the Recipe (Recipe BC) whose steps were + expanded into this Procedure via the + `register_procedure_from_recipe` slice. None for legacy Procedures + (registered via `register_procedure` with an inline step list) and + for ceremony Procedures with no Recipe binding. + + The Recipe is the source of truth for the expansion (Recipe.capability_id + points at the Capability that supplied the parameters_schema this + expansion was bound against). `capability_id` above is preserved as + a denorm for audit-by-Capability read paths without requiring a + Recipe join. Both fields are set by `register_procedure_from_recipe` + to the same logical binding.""" diff --git a/apps/api/src/cora/operation/features/conduct_procedure/handler.py b/apps/api/src/cora/operation/features/conduct_procedure/handler.py index 6558fbe45..0b92e1fb5 100644 --- a/apps/api/src/cora/operation/features/conduct_procedure/handler.py +++ b/apps/api/src/cora/operation/features/conduct_procedure/handler.py @@ -5,8 +5,9 @@ itself does not own: command-level authorization (the per-step `append_procedure_steps` calls already authz internally, but the ConductProcedure entry point gates the entire invocation), envelope -threading, and result conversion from `ConductorResult` to the -slice's `ConductProcedureResult` contract. +threading, recipe-replay re-expansion when the Procedure was created +via `register_procedure_from_recipe`, and result conversion from +`ConductorResult` to the slice's `ConductProcedureResult` contract. ## Why no `_decider` @@ -17,6 +18,18 @@ an orchestration entry point, NOT an aggregate-state-mutating decider. Therefore no `decider.py`, no `context.py`. +## Recipe-driven re-expansion + +When the loaded Procedure has `recipe_id is not None`, the handler +treats it as recipe-driven and runs the five-step replay gate +specified by [[project-run-procedure-replay-design]]: +forbid-non-empty-caller-steps -> find_recipe_expansion_record -> +pins_from_payload -> port-version strict-equals guard -> +load_recipe_at_version -> verify_bindings_hash -> expand -> +verify_steps_hash -> hand fresh steps to Conductor. Legacy +Procedures (`recipe_id is None`) hand `command.steps` to the +Conductor unchanged. + ## Authorization scope `ConductProcedure` is authz-checked as a distinct command. The wrapped @@ -25,23 +38,40 @@ `ConductProcedure` is NOT automatically authorized for each of those individually. That's correct: `ConductProcedure` is the operator-friendly entry; the underlying per-FSM-transition -authorization is what the policy engine actually evaluates at -each call site. +authorization is what the policy engine actually evaluates at each +call site. """ +from collections.abc import Sequence from typing import Protocol from uuid import UUID from cora.infrastructure.kernel import Kernel from cora.infrastructure.logging import get_logger -from cora.infrastructure.ports import Deny +from cora.infrastructure.ports import Deny, EventStore +from cora.infrastructure.ports.event_store import StoredEvent from cora.infrastructure.routing import NIL_SENTINEL_ID -from cora.operation.conductor import Conductor +from cora.operation._recipe_replay import ( + find_recipe_expansion_record, + pins_from_payload, + verify_bindings_hash, + verify_steps_hash, +) +from cora.operation.aggregates.procedure import ( + ProcedureNotFoundError, + ProcedureStepsForbiddenForRecipeDrivenError, + RecipeExpansionPortVersionMismatchError, + RecipeExpansionRecordNotFoundError, + load_procedure_with_events, +) +from cora.operation.conductor import Conductor, Step from cora.operation.errors import UnauthorizedError from cora.operation.features.conduct_procedure.command import ( ConductProcedure, ConductProcedureResult, ) +from cora.operation.ports.recipe_expansion_port import RecipeExpansionPort +from cora.recipe.aggregates.recipe import load_recipe_at_version _COMMAND_NAME = "ConductProcedure" @@ -62,12 +92,21 @@ async def __call__( ) -> ConductProcedureResult: ... -def bind(deps: Kernel, *, conductor: Conductor) -> Handler: - """Build a conduct_procedure handler closed over deps + the Conductor. +def bind( + deps: Kernel, + *, + conductor: Conductor, + expansion_port: RecipeExpansionPort, +) -> Handler: + """Build a conduct_procedure handler closed over deps + Conductor + port. `conductor` is BC-internal: wire_operation constructs it from the bound FSM handlers + ControlPort + Kernel infra ports. - Not promoted to the Kernel since it is Operation-BC-local. + `expansion_port` is the same instance wired for + `register_procedure_from_recipe`; replay reads its `version` + attribute and calls `expand` against the pinned bindings. The + `event_store` is read via `deps.event_store` at the + `load_procedure_with_events` call site (no separate kwarg). """ async def handler( @@ -105,13 +144,31 @@ async def handler( ) raise UnauthorizedError(authz.reason) + procedure, stored_events = await load_procedure_with_events( + deps.event_store, command.procedure_id + ) + if procedure is None: + raise ProcedureNotFoundError(command.procedure_id) + + if procedure.recipe_id is not None: + steps = await _re_expand_steps( + procedure_id=procedure.id, + recipe_id=procedure.recipe_id, + caller_steps=command.steps, + stored_events=stored_events, + event_store=deps.event_store, + expansion_port=expansion_port, + ) + else: + steps = tuple(command.steps) + result = await conductor.conduct( procedure_id=command.procedure_id, principal_id=principal_id, correlation_id=correlation_id, causation_id=causation_id, surface_id=surface_id, - steps=command.steps, + steps=steps, ) _log.info( @@ -131,3 +188,52 @@ async def handler( ) return handler + + +async def _re_expand_steps( + *, + procedure_id: UUID, + recipe_id: UUID, + caller_steps: Sequence[Step], + stored_events: list[StoredEvent], + event_store: EventStore, + expansion_port: RecipeExpansionPort, +) -> tuple[Step, ...]: + """Run the recipe-replay gate per [[project-run-procedure-replay-design]]. + + Five steps: reject non-empty caller steps -> find_recipe_expansion_record + (raise RecipeExpansionRecordNotFoundError on None) -> pins_from_payload + -> port-version strict-equals (raise RecipeExpansionPortVersionMismatchError + on drift) -> load_recipe_at_version (raise RecipeExpansionRecordNotFoundError + when None on a recipe-driven Procedure; RecipeVersionNotFoundError + propagates from helper) -> verify_bindings_hash -> expand -> verify_steps_hash + -> return the re-expanded tuple. + """ + if list(caller_steps): + raise ProcedureStepsForbiddenForRecipeDrivenError(procedure_id) + + record = find_recipe_expansion_record(stored_events) + if record is None: + raise RecipeExpansionRecordNotFoundError(procedure_id) + + pins = pins_from_payload(procedure_id, record.payload) + + if pins.expansion_port_version != expansion_port.version: + raise RecipeExpansionPortVersionMismatchError( + procedure_id, + pins.expansion_port_version, + expansion_port.version, + ) + + recipe = await load_recipe_at_version( + event_store, + recipe_id, + pins.recipe_version, + ) + if recipe is None: + raise RecipeExpansionRecordNotFoundError(procedure_id) + + verify_bindings_hash(procedure_id, pins) + expanded = expansion_port.expand(recipe.steps, dict(pins.bindings)) + verify_steps_hash(procedure_id, expanded, pins) + return expanded diff --git a/apps/api/src/cora/operation/features/register_procedure_from_recipe/__init__.py b/apps/api/src/cora/operation/features/register_procedure_from_recipe/__init__.py new file mode 100644 index 000000000..96c68af9c --- /dev/null +++ b/apps/api/src/cora/operation/features/register_procedure_from_recipe/__init__.py @@ -0,0 +1,39 @@ +"""Slice: register a new Procedure by expanding a Recipe's templated steps. + +Vertical slice. Mirrors `register_procedure` shape (create-style, +idempotency-wrappable) plus a cross-aggregate Recipe + Capability +fan-out: the handler loads the Recipe, then the Recipe's Capability, +then re-validates BindingRef integrity against the CURRENT Capability +schema (raises `RecipeBindingsStaleAgainstCurrentCapabilityError` on +drift), validates operator-supplied `bindings` against +`Capability.parameters_schema`, runs the expansion port twice for +overflow + determinism gates, and emits a 2-event genesis block: +`ProcedureRegistered` (with `recipe_id` + denorm `capability_id` set) +plus `RecipeExpansionRecorded` (template-invocation-grain provenance). + +Per anti-hook 18 of [[project-recipe-aggregate-design]]: missing +Capability re-uses the existing `CapabilityNotFoundError` from Recipe +BC, no new error class invented. +""" + +from cora.operation.features.register_procedure_from_recipe import tool +from cora.operation.features.register_procedure_from_recipe.command import ( + RegisterProcedureFromRecipe, +) +from cora.operation.features.register_procedure_from_recipe.decider import decide +from cora.operation.features.register_procedure_from_recipe.handler import ( + Handler, + IdempotentHandler, + bind, +) +from cora.operation.features.register_procedure_from_recipe.route import router + +__all__ = [ + "Handler", + "IdempotentHandler", + "RegisterProcedureFromRecipe", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/operation/features/register_procedure_from_recipe/command.py b/apps/api/src/cora/operation/features/register_procedure_from_recipe/command.py new file mode 100644 index 000000000..f0c1ae36e --- /dev/null +++ b/apps/api/src/cora/operation/features/register_procedure_from_recipe/command.py @@ -0,0 +1,34 @@ +"""The `RegisterProcedureFromRecipe` command, intent dataclass for this slice. + +Carries the operator-supplied Recipe reference + bindings; the +Procedure-shape facets (name, kind, target_asset_ids, parent_run_id) +mirror `RegisterProcedure` exactly so a Procedure registered via this +slice presents the same shape as one registered via the legacy slice. +The `recipe_id` resolves cross-aggregate at handler time before the +decider runs; a missing Recipe raises `RecipeNotFoundError`. The +Recipe's `capability_id` is loaded transitively for BindingRef +re-validation + executor-shape + bindings-shape validation. + +`bindings` is a free-form dict of operator-supplied parameter values +keyed by the names declared in the bound Capability's +`parameters_schema.properties`. Substituted into the Recipe's +`BindingRef` sentinels at expansion time. Empty dict is valid when +the Recipe carries no BindingRefs. +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +from uuid import UUID + + +@dataclass(frozen=True) +class RegisterProcedureFromRecipe: + """Register a new Procedure by expanding a Recipe with operator bindings.""" + + name: str + kind: str + target_asset_ids: tuple[UUID, ...] + parent_run_id: UUID | None + recipe_id: UUID + bindings: Mapping[str, Any] diff --git a/apps/api/src/cora/operation/features/register_procedure_from_recipe/decider.py b/apps/api/src/cora/operation/features/register_procedure_from_recipe/decider.py new file mode 100644 index 000000000..c60d74a8e --- /dev/null +++ b/apps/api/src/cora/operation/features/register_procedure_from_recipe/decider.py @@ -0,0 +1,168 @@ +"""Pure decider for the `RegisterProcedureFromRecipe` command. + +Drives the Procedure's step list from a Recipe's templated `steps` + +operator-supplied parameter bindings (instead of an inline step list +per `register_procedure`). Emits a 2-event genesis block: + - `ProcedureRegistered`: standard Procedure genesis (with `recipe_id` + set and `capability_id` denormalized from + Recipe.capability_id) + - `RecipeExpansionRecorded`: template-invocation-grain provenance + +The expanded `tuple[Step, ...]` is computed locally for the cap + +determinism validation gates but NOT persisted at v1: the provenance +event records `(recipe_id, recipe_version, capability_id, +capability_version, bindings, expansion_port_version, steps_hash, +bindings_hash, step_count)`, sufficient to re-expand deterministically +at run time. The handler loads BOTH Recipe and Capability (the Recipe +has the steps; the Capability has the parameters_schema for binding +shape validation + executor_shapes for the cross-BC guard). + +Pure-function contract: `port.expand` is called TWICE here (once for +the real expansion, once for the determinism check). The port wraps a +pure function so this is cheap. + +Invariants: + - Procedure stream must be fresh (state is None) + -> ProcedureAlreadyExistsError + - Recipe must be present (handler raises RecipeNotFoundError first) + - Capability must be present (handler raises CapabilityNotFoundError first) + - Capability.executor_shapes must contain PROCEDURE + -> ProcedureCapabilityExecutorMismatchError + - bindings must validate against Capability.parameters_schema + (delegates to validate_values_against_schema; STRICT-when-no-schema + via the existing infra) + -> InvalidRecipeBindingsError (wraps SchemaValidationError) + - kind: 1-50 chars via the shared validate_bounded_text helper + -> InvalidProcedureKindError + - name: 1-200 chars via ProcedureName VO + -> InvalidProcedureNameError + - Expanded step count must not exceed RECIPE_EXPANSION_STEP_MAX + -> RecipeExpansionOverflowError + - Two consecutive expansion calls must yield identical results + -> RecipeExpansionDeterminismError +""" + +import hashlib +from collections.abc import Mapping +from datetime import datetime +from typing import Any +from uuid import UUID + +from cora.infrastructure.bounded_text import validate_bounded_text +from cora.infrastructure.json_schema_validation import validate_values_against_schema +from cora.operation._recipe_expansion import canonical_json_bytes, steps_to_wire +from cora.operation.aggregates.procedure import ( + PROCEDURE_KIND_MAX_LENGTH, + RECIPE_EXPANSION_STEP_MAX, + InvalidProcedureKindError, + InvalidRecipeBindingsError, + Procedure, + ProcedureAlreadyExistsError, + ProcedureCapabilityExecutorMismatchError, + ProcedureEvent, + ProcedureName, + ProcedureRegistered, + RecipeExpansionDeterminismError, + RecipeExpansionOverflowError, + RecipeExpansionRecorded, +) +from cora.operation.conductor import Step +from cora.operation.features.register_procedure_from_recipe.command import ( + RegisterProcedureFromRecipe, +) +from cora.operation.ports.recipe_expansion_port import RecipeExpansionPort +from cora.recipe.aggregates.capability import Capability, ExecutorShape +from cora.recipe.aggregates.recipe import Recipe + + +def _hash_steps(steps: tuple[Step, ...]) -> str: + """Content-address the expanded Step tuple per memo §RecipeExpansionRecorded. + + Hashing the expanded steps (not the unexpanded Recipe template) + pins what the Conductor will actually execute, so a Recipe + re-version that produces equivalent expanded steps still hashes + identically. + """ + return hashlib.sha256(canonical_json_bytes(steps_to_wire(steps))).hexdigest() + + +def _hash_bindings(bindings: Mapping[str, Any]) -> str: + return hashlib.sha256(canonical_json_bytes(dict(bindings))).hexdigest() + + +def decide( + state: Procedure | None, + command: RegisterProcedureFromRecipe, + *, + recipe: Recipe, + capability: Capability, + expansion_port: RecipeExpansionPort, + now: datetime, + new_id: UUID, +) -> list[ProcedureEvent]: + """Decide the genesis event block for a Recipe-driven Procedure registration. + + Returns `[ProcedureRegistered, RecipeExpansionRecorded]`. The expanded + step tuple is computed locally for the overflow + determinism gates + but is NOT carried out of the decider; the provenance event's + `(recipe_id, recipe_version, capability_id, capability_version, + bindings, expansion_port_version, steps_hash, bindings_hash, + step_count)` are sufficient for deterministic re-expansion. + """ + if state is not None: + raise ProcedureAlreadyExistsError(state.id) + if ExecutorShape.PROCEDURE not in capability.executor_shapes: + raise ProcedureCapabilityExecutorMismatchError(new_id, capability.id) + + bindings_dict = dict(command.bindings) + validate_values_against_schema( + bindings_dict, + capability.parameters_schema, + error_class=InvalidRecipeBindingsError, + ) + + kind = validate_bounded_text( + command.kind, + max_length=PROCEDURE_KIND_MAX_LENGTH, + error_class=InvalidProcedureKindError, + ) + name = ProcedureName(command.name) + + steps_first = expansion_port.expand(recipe.steps, bindings_dict) + if len(steps_first) > RECIPE_EXPANSION_STEP_MAX: + raise RecipeExpansionOverflowError( + step_count=len(steps_first), cap=RECIPE_EXPANSION_STEP_MAX + ) + + # Determinism check: re-expand and compare. The port wraps a pure + # function; any divergence is a server-side bug in the port or the + # recipe body. + steps_second = expansion_port.expand(recipe.steps, bindings_dict) + if steps_first != steps_second: + raise RecipeExpansionDeterminismError(recipe.id) + + return [ + ProcedureRegistered( + procedure_id=new_id, + name=name.value, + kind=kind, + target_asset_ids=tuple(command.target_asset_ids), + parent_run_id=command.parent_run_id, + capability_id=recipe.capability_id, + recipe_id=recipe.id, + occurred_at=now, + ), + RecipeExpansionRecorded( + procedure_id=new_id, + recipe_id=recipe.id, + recipe_version=recipe.version, + capability_id=capability.id, + capability_version=capability.version, + bindings=bindings_dict, + expansion_port_version=expansion_port.version, + steps_hash=_hash_steps(steps_first), + bindings_hash=_hash_bindings(bindings_dict), + step_count=len(steps_first), + occurred_at=now, + ), + ] diff --git a/apps/api/src/cora/operation/features/register_procedure_from_recipe/handler.py b/apps/api/src/cora/operation/features/register_procedure_from_recipe/handler.py new file mode 100644 index 000000000..7682a8fb2 --- /dev/null +++ b/apps/api/src/cora/operation/features/register_procedure_from_recipe/handler.py @@ -0,0 +1,209 @@ +"""Application handler for the `register_procedure_from_recipe` slice. + +Create-style handler with TWO cross-aggregate fan-out steps preceding +the decider call: + + 1. Load Recipe via `load_recipe(deps.event_store, command.recipe_id)`. + Missing -> `RecipeNotFoundError` (re-used cross-BC; mapped to 404). + 2. Load Capability via `load_capability(deps.event_store, + recipe.capability_id)`. Missing -> `CapabilityNotFoundError` + (re-used cross-BC per anti-hook 18 of + [[project-recipe-aggregate-design]]: do NOT invent a new error + class for missing-Capability). + 3. Re-validate BindingRef integrity against the CURRENT + `Capability.parameters_schema` (closes the Capability-re-version + race per anti-hook 5). Drift -> `RecipeBindingsStaleAgainstCurrentCapabilityError`. + +The handler then invokes the decider with both `recipe` and +`capability` in scope; the decider runs the executor-shape guard, +binding-value shape validation, overflow + determinism gates, and +emits `[ProcedureRegistered, RecipeExpansionRecorded]`. + +Receives a `RecipeExpansionPort` from `bind()`'s captured deps so the +decider can run the cap + determinism gates without re-importing +infrastructure inside the pure layer. +""" + +from typing import Protocol +from uuid import UUID + +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.operation.aggregates.procedure import ( + RecipeBindingsStaleAgainstCurrentCapabilityError, + event_type_name, + to_payload, +) +from cora.operation.errors import UnauthorizedError +from cora.operation.features.register_procedure_from_recipe.command import ( + RegisterProcedureFromRecipe, +) +from cora.operation.features.register_procedure_from_recipe.decider import decide +from cora.operation.ports.recipe_expansion_port import RecipeExpansionPort +from cora.recipe.aggregates.capability import ( + CapabilityNotFoundError, + load_capability, +) +from cora.recipe.aggregates.recipe import ( + RecipeNotFoundError, + collect_binding_names, + load_recipe, +) + +_STREAM_TYPE = "Procedure" +_COMMAND_NAME = "RegisterProcedureFromRecipe" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare register_procedure_from_recipe handler, the shape `bind()` returns.""" + + async def __call__( + self, + command: RegisterProcedureFromRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +class IdempotentHandler(Protocol): + """register_procedure_from_recipe handler with Idempotency-Key support.""" + + async def __call__( + self, + command: RegisterProcedureFromRecipe, + *, + 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, *, expansion_port: RecipeExpansionPort) -> Handler: + """Build a register_procedure_from_recipe handler closed over deps + port.""" + + async def handler( + command: RegisterProcedureFromRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "register_procedure_from_recipe.start", + command_name=_COMMAND_NAME, + recipe_id=str(command.recipe_id), + kind=command.kind, + target_asset_count=len(command.target_asset_ids), + 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( + "register_procedure_from_recipe.denied", + command_name=_COMMAND_NAME, + 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) + + recipe = await load_recipe(deps.event_store, command.recipe_id) + if recipe is None: + raise RecipeNotFoundError(command.recipe_id) + + capability = await load_capability(deps.event_store, recipe.capability_id) + if capability is None: + raise CapabilityNotFoundError(recipe.capability_id) + + # Re-validate BindingRef integrity against the CURRENT + # Capability.parameters_schema. If the Capability has been + # versioned after the Recipe was last written and a binding + # name dropped, raise RecipeBindingsStaleAgainstCurrentCapabilityError + # so the operator re-versions the Recipe. + binding_names = collect_binding_names(recipe.steps) + declared: frozenset[str] = frozenset() + if capability.parameters_schema is not None: + raw_properties: object = capability.parameters_schema.get("properties", {}) + if isinstance(raw_properties, dict): + # `properties` is dict[str, Any] in JSON Schema; the key + # type is enforced by the validator at write time. Cast + # the keys view explicitly so pyright sees `Iterable[str]`. + declared = frozenset( + str(k) # pyright: ignore[reportUnknownArgumentType] + for k in raw_properties # pyright: ignore[reportUnknownVariableType] + ) + missing = binding_names - declared + if missing: + raise RecipeBindingsStaleAgainstCurrentCapabilityError( + recipe_id=recipe.id, + capability_id=capability.id, + missing_binding_names=missing, + ) + + new_id = deps.id_generator.new_id() + now = deps.clock.now() + + domain_events = decide( + state=None, + command=command, + recipe=recipe, + capability=capability, + expansion_port=expansion_port, + now=now, + new_id=new_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( + "register_procedure_from_recipe.success", + command_name=_COMMAND_NAME, + procedure_id=str(new_id), + recipe_id=str(command.recipe_id), + capability_id=str(recipe.capability_id), + 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/operation/features/register_procedure_from_recipe/route.py b/apps/api/src/cora/operation/features/register_procedure_from_recipe/route.py new file mode 100644 index 000000000..e36504bba --- /dev/null +++ b/apps/api/src/cora/operation/features/register_procedure_from_recipe/route.py @@ -0,0 +1,192 @@ +"""HTTP route for the `register_procedure_from_recipe` slice. + +Pydantic request/response schemas + APIRouter for +`POST /procedures/from-recipe`. The slice's BC-level wiring +(`cora.operation.routes.register_operation_routes`) includes this +router on the FastAPI app. + +`target_asset_ids` accepts a list of UUIDs at the API boundary (JSON +arrays don't have set semantics); the handler converts to a tuple +before threading into the command. Empty list is allowed (maps to +empty tuple, valid for facility-envelope procedures that don't act +on a specific Asset). + +`bindings` is a free-form JSON object; the keys correspond to the +parameter names declared in the bound Recipe's Capability's +`parameters_schema.properties`. Validated cross-aggregate at the +decider boundary; the API surface does not enforce a schema since +the schema lives on the Capability (cross-BC, loaded at handler time). +""" + +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request, status +from pydantic import BaseModel, Field + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.operation.aggregates.procedure import ( + PROCEDURE_KIND_MAX_LENGTH, + PROCEDURE_NAME_MAX_LENGTH, +) +from cora.operation.features.register_procedure_from_recipe.command import ( + RegisterProcedureFromRecipe, +) +from cora.operation.features.register_procedure_from_recipe.handler import ( + IdempotentHandler, +) + + +class RegisterProcedureFromRecipeRequest(BaseModel): + """Body for `POST /procedures/from-recipe`.""" + + name: str = Field( + ..., + min_length=1, + max_length=PROCEDURE_NAME_MAX_LENGTH, + description=( + "Operator-readable display name for the procedure. Mirrors " + "register_procedure's name field exactly." + ), + ) + kind: str = Field( + ..., + min_length=1, + max_length=PROCEDURE_KIND_MAX_LENGTH, + description=( + "Free-form ISA-106 procedure-kind discriminator (bakeout, " + "calibration, alignment, recovery, etc.). Mirrors " + "register_procedure's kind field exactly." + ), + ) + target_asset_ids: list[UUID] = Field( + default_factory=list[UUID], + description=( + "Asset ids this procedure acts on. May be empty (valid for " + "facility-envelope procedures). Eventual-consistency: ids " + "are NOT verified at register time." + ), + ) + parent_run_id: UUID | None = Field( + default=None, + description=( + "Optional parent Run binding. None = standalone procedure. " + "Set = Phase-of-Run procedure." + ), + ) + recipe_id: UUID = Field( + ..., + description=( + "Recipe whose templated steps will be expanded into this " + "Procedure. Loaded cross-BC at handler time; missing -> 404." + ), + ) + bindings: dict[str, Any] = Field( + default_factory=dict[str, Any], + description=( + "Operator-supplied parameter values keyed by the parameter " + "names declared in the bound Recipe's Capability's " + "parameters_schema. Substituted into BindingRef sentinels at " + "expansion time. Empty dict valid when the Recipe carries no " + "BindingRefs." + ), + ) + + +class RegisterProcedureFromRecipeResponse(BaseModel): + """Response body for `POST /procedures/from-recipe`.""" + + procedure_id: UUID + + +def _get_handler(request: Request) -> IdempotentHandler: + handler: IdempotentHandler = request.app.state.operation.register_procedure_from_recipe + return handler + + +router = APIRouter(tags=["operation"]) + + +@router.post( + "/procedures/from-recipe", + status_code=status.HTTP_201_CREATED, + response_model=RegisterProcedureFromRecipeResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ("Domain invariant violated (whitespace-only name or kind)."), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": ( + "Referenced Recipe does not exist OR Recipe's bound Capability does not exist." + ), + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Capability.executor_shapes does not include Procedure " + "(cross-BC executor-shape guard)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation, OR Recipe's " + "BindingRefs are stale against the current Capability " + "parameters_schema, OR operator-supplied bindings did " + "not validate against the Capability's parameters_schema, " + "OR the expansion produced more than the configured cap." + ), + }, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "model": ErrorResponse, + "description": ( + "Expansion port returned different results for the same " + "(steps, bindings) input (server-side determinism bug)." + ), + }, + }, + summary="Register a new Procedure by expanding a Recipe with operator bindings", +) +async def post_procedures_from_recipe( + body: RegisterProcedureFromRecipeRequest, + 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", + description=( + "Optional client-supplied unique key per logical request. " + "Retries with the same key + same body return the cached " + "response instead of re-creating the procedure." + ), + ), + ] = None, +) -> RegisterProcedureFromRecipeResponse: + procedure_id = await handler( + RegisterProcedureFromRecipe( + name=body.name, + kind=body.kind, + target_asset_ids=tuple(body.target_asset_ids), + parent_run_id=body.parent_run_id, + recipe_id=body.recipe_id, + bindings=dict(body.bindings), + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + idempotency_key=idempotency_key, + ) + return RegisterProcedureFromRecipeResponse(procedure_id=procedure_id) diff --git a/apps/api/src/cora/operation/features/register_procedure_from_recipe/tool.py b/apps/api/src/cora/operation/features/register_procedure_from_recipe/tool.py new file mode 100644 index 000000000..13bf8d11f --- /dev/null +++ b/apps/api/src/cora/operation/features/register_procedure_from_recipe/tool.py @@ -0,0 +1,111 @@ +"""MCP tool for the `register_procedure_from_recipe` 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.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.operation.aggregates.procedure import ( + PROCEDURE_KIND_MAX_LENGTH, + PROCEDURE_NAME_MAX_LENGTH, +) +from cora.operation.features.register_procedure_from_recipe.command import ( + RegisterProcedureFromRecipe, +) +from cora.operation.features.register_procedure_from_recipe.handler import ( + IdempotentHandler, +) + + +class RegisterProcedureFromRecipeOutput(BaseModel): + """Structured output of the `register_procedure_from_recipe` MCP tool.""" + + procedure_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> None: + """Register the `register_procedure_from_recipe` tool on the given MCP server.""" + + @mcp.tool( + name="register_procedure_from_recipe", + description=( + "Register a new Procedure by expanding a Recipe with " + "operator-supplied parameter bindings. The handler loads the " + "Recipe, then the Recipe's Capability, re-validates BindingRef " + "integrity against the CURRENT Capability schema, validates " + "bindings, runs the expansion port twice for overflow + " + "determinism gates, then emits ProcedureRegistered + " + "RecipeExpansionRecorded provenance events." + ), + ) + async def register_procedure_from_recipe_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + name: Annotated[ + str, + Field( + min_length=1, + max_length=PROCEDURE_NAME_MAX_LENGTH, + description="Operator-readable display name for the procedure.", + ), + ], + kind: Annotated[ + str, + Field( + min_length=1, + max_length=PROCEDURE_KIND_MAX_LENGTH, + description="Free-form ISA-106 procedure-kind discriminator.", + ), + ], + recipe_id: Annotated[ + UUID, + Field( + description=("Recipe whose templated steps will be expanded into this Procedure."), + ), + ], + target_asset_ids: Annotated[ + list[UUID] | None, + Field( + default=None, + description=("Asset ids this procedure acts on (may be omitted or empty)."), + ), + ] = None, + parent_run_id: Annotated[ + UUID | None, + Field( + default=None, + description=("Optional parent Run binding (None = standalone procedure)."), + ), + ] = None, + bindings: Annotated[ + dict[str, Any] | None, + Field( + default=None, + description=( + "Operator-supplied parameter values keyed by the " + "parameter names declared in the bound Recipe's " + "Capability's parameters_schema. Omit or pass {} " + "when the Recipe carries no BindingRefs." + ), + ), + ] = None, + ) -> RegisterProcedureFromRecipeOutput: + handler = get_handler() + procedure_id = await handler( + RegisterProcedureFromRecipe( + name=name, + kind=kind, + target_asset_ids=tuple(target_asset_ids or []), + parent_run_id=parent_run_id, + recipe_id=recipe_id, + bindings=dict(bindings or {}), + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return RegisterProcedureFromRecipeOutput(procedure_id=procedure_id) diff --git a/apps/api/src/cora/operation/ports/recipe_expansion_port.py b/apps/api/src/cora/operation/ports/recipe_expansion_port.py new file mode 100644 index 000000000..29fe4565f --- /dev/null +++ b/apps/api/src/cora/operation/ports/recipe_expansion_port.py @@ -0,0 +1,58 @@ +"""RecipeExpansionPort: pure function from Recipe step tuple + bindings to Step tuple. + +Per [[project-recipe-aggregate-design]] the expansion is a PURE function: +no clock, no port I/O, no randomness, no module-global state. The port +carries a `version` attribute so `RecipeExpansionRecorded` provenance +events capture which expander emitted a given expansion, enabling +replay even if a deployment later swaps the default for a custom +expander. + +The default adapter (`InMemoryRecipeExpansionPort`) delegates to the +pure `expand` function in `cora.operation._recipe_expansion`. Future +custom expanders (a deployment-specific DSL or a memoizing cache) +implement the same Protocol and ship their own `version` string. + +Errors propagate unchanged: `UnboundRecipeBindingError` (from Recipe BC) +when a `BindingRef.name` is missing from `bindings`; `ValueError` for +unknown criterion kinds in a check step. + +## 2-arg pure-substitution contract + +Schema validation does NOT belong on this port. BindingRef-vs-schema +integrity lives in `cora.recipe.aggregates.recipe.steps_validation` +(called by the slice handler before expansion); operator-binding-value +shape validation lives in the slice decider via +`validate_values_against_schema` (raises `InvalidRecipeBindingsError`). +The port is pure substitution + ordering of typed Step VOs. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Mapping + + from cora.operation.conductor import Step + from cora.recipe.aggregates.recipe import RecipeStep + + +@runtime_checkable +class RecipeExpansionPort(Protocol): + """Pure expansion of a Recipe's step tuple to a Conductor `Step` tuple. + + `version` is a stable string identifying the expander (default impl + pins to `"v1"`). Provenance events capture `version` so the same + `(steps, bindings)` inputs reproduce the same outputs even after a + deployment swaps expanders. + """ + + @property + def version(self) -> str: ... + + def expand( + self, steps: tuple[RecipeStep, ...], bindings: Mapping[str, Any] + ) -> tuple[Step, ...]: ... + + +__all__ = ["RecipeExpansionPort"] diff --git a/apps/api/src/cora/operation/projections/procedure.py b/apps/api/src/cora/operation/projections/procedure.py index a9a979047..6a8fc29f1 100644 --- a/apps/api/src/cora/operation/projections/procedure.py +++ b/apps/api/src/cora/operation/projections/procedure.py @@ -40,8 +40,9 @@ INSERT INTO proj_operation_procedure_summary (procedure_id, name, kind, target_asset_ids, parent_run_id, status, steps_logbook_id, registered_at, - last_status_changed_at, last_status_reason, interrupted_at) -VALUES ($1, $2, $3, $4::uuid[], $5, 'Defined', NULL, $6, NULL, NULL, NULL) + last_status_changed_at, last_status_reason, interrupted_at, + recipe_id) +VALUES ($1, $2, $3, $4::uuid[], $5, 'Defined', NULL, $6, NULL, NULL, NULL, $7) ON CONFLICT (procedure_id) DO NOTHING """ @@ -113,6 +114,8 @@ async def apply( target_asset_ids = [UUID(a) for a in payload.get("target_asset_ids", [])] raw_parent = payload.get("parent_run_id") parent_run_id = UUID(raw_parent) if raw_parent is not None else None + raw_recipe = payload.get("recipe_id") + recipe_id = UUID(raw_recipe) if raw_recipe is not None else None await conn.execute( _INSERT_PROCEDURE_SQL, UUID(payload["procedure_id"]), @@ -121,6 +124,7 @@ async def apply( target_asset_ids, parent_run_id, datetime.fromisoformat(payload["occurred_at"]), + recipe_id, ) return diff --git a/apps/api/src/cora/operation/routes.py b/apps/api/src/cora/operation/routes.py index d00d1217f..22d0f2854 100644 --- a/apps/api/src/cora/operation/routes.py +++ b/apps/api/src/cora/operation/routes.py @@ -27,7 +27,7 @@ - 409 (defensive guard for AlreadyExists): ProcedureAlreadyExists - 409 (transition guards): ProcedureCannotStart, ProcedureCannotComplete, ProcedureCannotAbort, - ProcedureCannotTruncate, ProcedureAssetDecommissioned, + ProcedureCannotTruncate, ProcedurePlanAssetDecommissioned, ProcedureStepsLogbookClosed """ @@ -40,6 +40,7 @@ InvalidProcedureKindError, InvalidProcedureNameError, InvalidProcedureTruncateReasonError, + InvalidRecipeBindingsError, InvalidStepKindError, ProcedureAlreadyExistsError, ProcedureCannotAbortError, @@ -50,8 +51,15 @@ ProcedureNotFoundError, ProcedurePlanAssetDecommissionedError, ProcedureRequiresAvailableSupplyError, + ProcedureStepsForbiddenForRecipeDrivenError, ProcedureStepsLogbookClosedError, ProcedureSupplyCoverageMismatchError, + RecipeBindingsStaleAgainstCurrentCapabilityError, + RecipeExpansionDeterminismError, + RecipeExpansionOverflowError, + RecipeExpansionPortVersionMismatchError, + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, ) from cora.operation.errors import UnauthorizedError from cora.operation.features import ( @@ -62,6 +70,7 @@ get_procedure, list_procedures, register_procedure, + register_procedure_from_recipe, start_procedure, truncate_procedure, ) @@ -120,7 +129,7 @@ async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONRes Covers ProcedureCannotStart / ProcedureCannotComplete / ProcedureCannotAbort / ProcedureCannotTruncate (FSM source-state - guards), ProcedureAssetDecommissioned (cross-aggregate precondition + guards), ProcedurePlanAssetDecommissioned (cross-aggregate precondition guard at start_procedure), AND ProcedureStepsLogbookClosed (logbook write guard for non-Running Procedures). All map to HTTP 409 + `{"detail": str(exc)}`. @@ -132,9 +141,45 @@ async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONRes ) +async def _handle_unprocessable(request: Request, exc: Exception) -> JSONResponse: + """Shared 422 handler for parse-shape failures past the Pydantic boundary. + + Covers the Recipe-expansion family raised at + register_procedure_from_recipe time: stale-Capability schema drift, + operator-supplied bindings shape failures, and the expansion + overflow cap. The 400 Invalid family is reserved for VO + constructor failures (name / kind); 422 is reserved for + downstream parse-shape or schema-cross-check failures that pass + Pydantic but fail at the cross-aggregate boundary. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content={"detail": str(exc)}, + ) + + +async def _handle_internal_server_error(request: Request, exc: Exception) -> JSONResponse: + """Shared 500 handler for server-side determinism / port-contract bugs. + + Covers RecipeExpansionDeterminismError (expansion port returned + different results for the same `(steps, bindings)` input, + indicating a non-pure expander or a non-canonical bindings dict). + Distinct from operator error: the operator's request is well-formed; + the server-side bug means re-trying with the same payload will + likely fail the same way. Mapped to HTTP 500. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": str(exc)}, + ) + + def register_operation_routes(app: FastAPI) -> None: """Attach Operation slice routers and exception handlers to the FastAPI app.""" app.include_router(register_procedure.router) + app.include_router(register_procedure_from_recipe.router) app.include_router(start_procedure.router) app.include_router(complete_procedure.router) app.include_router(abort_procedure.router) @@ -150,6 +195,10 @@ def register_operation_routes(app: FastAPI) -> None: InvalidProcedureTruncateReasonError, InvalidProcedureInterruptedAtError, InvalidStepKindError, + # Recipe-driven conduct_procedure path: caller-supplied steps with + # recipe_id set are rejected up front per the replay-design lock + # ([[project-run-procedure-replay-design]] Anti-hook 7). + ProcedureStepsForbiddenForRecipeDrivenError, ): app.add_exception_handler(validation_cls, _handle_validation_error) for not_found_cls in (ProcedureNotFoundError,): @@ -177,4 +226,36 @@ def register_operation_routes(app: FastAPI) -> None: ProcedureSupplyCoverageMismatchError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) + for unprocessable_cls in ( + # Recipe-expansion family at register_procedure_from_recipe time. + # All fire AFTER Pydantic body validation: stale-Capability schema + # drift, operator-bindings shape failure, expansion overflow cap. + RecipeBindingsStaleAgainstCurrentCapabilityError, + InvalidRecipeBindingsError, + RecipeExpansionOverflowError, + ): + app.add_exception_handler(unprocessable_cls, _handle_unprocessable) + # Server-side determinism bugs / data corruption: HTTP 500. Distinct + # from operator-error 422 because re-trying with the same payload + # will fail the same way. Replay-time bugs land here too per + # [[project-run-procedure-replay-design]] Rejections (alphabetical): + # - RecipeExpansionDeterminismError (at-write determinism bug). + # - RecipeExpansionPortVersionMismatchError (pinned port v differs + # from currently-wired; placeholder until a v2 expansion port + # lands with its routing layer). + # - RecipeExpansionRecordNotFoundError (data corruption guard: + # recipe_id set but the pinned RecipeExpansionRecorded event or + # the pinned Recipe stream cannot be located). + # - RecipeExpansionReplayMismatchError (replay-time hash drift). + for internal_cls in ( + RecipeExpansionDeterminismError, + RecipeExpansionPortVersionMismatchError, + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, + ): + app.add_exception_handler(internal_cls, _handle_internal_server_error) + # NOT registered here: RecipeVersionNotFoundError (Recipe BC owns; + # raised from conduct_procedure handler via load_recipe_at_version + # but HTTP mapping lives in recipe/routes.py per the same cross-BC + # single-registration rule as CapabilityNotFoundError above). app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/operation/tools.py b/apps/api/src/cora/operation/tools.py index 4ae2f8bb5..f18e33066 100644 --- a/apps/api/src/cora/operation/tools.py +++ b/apps/api/src/cora/operation/tools.py @@ -18,6 +18,9 @@ from cora.operation.features.get_procedure import tool as get_procedure_tool from cora.operation.features.list_procedures import tool as list_procedures_tool from cora.operation.features.register_procedure import tool as register_procedure_tool +from cora.operation.features.register_procedure_from_recipe import ( + tool as register_procedure_from_recipe_tool, +) from cora.operation.features.start_procedure import tool as start_procedure_tool from cora.operation.features.truncate_procedure import tool as truncate_procedure_tool from cora.operation.wire import OperationHandlers @@ -33,6 +36,10 @@ def register_operation_tools( mcp, get_handler=lambda: get_handlers().register_procedure, ) + register_procedure_from_recipe_tool.register( + mcp, + get_handler=lambda: get_handlers().register_procedure_from_recipe, + ) start_procedure_tool.register( mcp, get_handler=lambda: get_handlers().start_procedure, diff --git a/apps/api/src/cora/operation/wire.py b/apps/api/src/cora/operation/wire.py index 004c70809..bbf47b2ab 100644 --- a/apps/api/src/cora/operation/wire.py +++ b/apps/api/src/cora/operation/wire.py @@ -58,6 +58,9 @@ from cora.infrastructure.observability import with_tracing from cora.operation.acquisitions import collect, continuous, discrete from cora.operation.adapters.control_port_config import build_control_port +from cora.operation.adapters.in_memory_recipe_expansion_port import ( + InMemoryRecipeExpansionPort, +) from cora.operation.aggregates.procedure import ( InMemoryStepStore, PostgresStepStore, @@ -72,6 +75,7 @@ get_procedure, list_procedures, register_procedure, + register_procedure_from_recipe, start_procedure, truncate_procedure, ) @@ -91,6 +95,7 @@ class OperationHandlers: """ register_procedure: register_procedure.IdempotentHandler + register_procedure_from_recipe: register_procedure_from_recipe.IdempotentHandler start_procedure: start_procedure.Handler complete_procedure: complete_procedure.Handler abort_procedure: abort_procedure.Handler @@ -128,6 +133,12 @@ def wire_operation(deps: Kernel) -> OperationHandlers: step_store: StepStore = ( PostgresStepStore(deps.pool) if deps.pool is not None else InMemoryStepStore() ) + # Recipe expansion port: default pure adapter. Per the design memo + # ([[project-recipe-aggregate-design]] Locks), the port is + # 2-arg pure substitution; future deployment-specific expanders + # implement the same Protocol and bump `version` to invalidate + # cached expansions on substantive semantic changes. + recipe_expansion_port = InMemoryRecipeExpansionPort() start_handler = with_tracing( start_procedure.bind(deps), command_name="StartProcedure", @@ -177,6 +188,18 @@ def wire_operation(deps: Kernel) -> OperationHandlers: command_name="RegisterProcedure", bc=_BC, ), + register_procedure_from_recipe=with_tracing( + with_idempotency( + register_procedure_from_recipe.bind(deps, expansion_port=recipe_expansion_port), + deps.idempotency_store, + command_name="RegisterProcedureFromRecipe", + serialize_result=str, + deserialize_result=UUID, + lock_stale_seconds=deps.settings.idempotency_lock_stale_seconds, + ), + command_name="RegisterProcedureFromRecipe", + bc=_BC, + ), start_procedure=start_handler, complete_procedure=complete_handler, abort_procedure=abort_handler, @@ -199,7 +222,7 @@ def wire_operation(deps: Kernel) -> OperationHandlers: kind="query", ), conduct_procedure=with_tracing( - conduct_procedure.bind(deps, conductor=conductor), + conduct_procedure.bind(deps, conductor=conductor, expansion_port=recipe_expansion_port), command_name="ConductProcedure", bc=_BC, ), diff --git a/apps/api/src/cora/recipe/_projections.py b/apps/api/src/cora/recipe/_projections.py index b04d6d6ac..6df1fd042 100644 --- a/apps/api/src/cora/recipe/_projections.py +++ b/apps/api/src/cora/recipe/_projections.py @@ -4,7 +4,7 @@ `register_recipe_projections(registry, deps)` during the FastAPI lifespan to populate the worker's registry. Recipe is the second multi-aggregate BC after Equipment: each of Method / Practice / -Plan has its own projection module under +Plan / Capability / Recipe has its own projection module under `cora.recipe.projections`, all registered here. """ @@ -15,6 +15,7 @@ MethodSummaryProjection, PlanSummaryProjection, PracticeSummaryProjection, + RecipeSummaryProjection, ) @@ -28,6 +29,7 @@ def register_recipe_projections( registry.register(PracticeSummaryProjection()) registry.register(PlanSummaryProjection()) registry.register(CapabilitySummaryProjection()) + registry.register(RecipeSummaryProjection()) __all__ = ["register_recipe_projections"] diff --git a/apps/api/src/cora/recipe/aggregates/recipe/__init__.py b/apps/api/src/cora/recipe/aggregates/recipe/__init__.py new file mode 100644 index 000000000..be636d7de --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/__init__.py @@ -0,0 +1,110 @@ +"""Recipe aggregate: state, status, errors, events, evolver, read. + +The deployment-bound executable step sequence at the operations layer +per [[project-recipe-aggregate-design]]; sits beside Capability rather +than absorbing Method/Plan per the [[capability-naming-split-lock]] +Shape 2 decision. References `capability_id` (REQUIRED + IMMUTABLE +across versions); carries the templated `steps` tuple that expands to +a flat `Step` list at `register_procedure_from_recipe` time. + +Vertical slices that operate on this aggregate live under +`cora.recipe.features._recipe/` and import from here for state, +events, and error types. +""" + +from cora.recipe.aggregates.recipe.body import ( + BindingRef, + InvalidRecipeStepShapeError, + RecipeActionStep, + RecipeCheckStep, + RecipeSetpointStep, + RecipeStep, + UnboundRecipeBindingError, + resolve_value, +) +from cora.recipe.aggregates.recipe.body import ( + from_dict as steps_from_dict, +) +from cora.recipe.aggregates.recipe.body import ( + to_dict as steps_to_dict, +) +from cora.recipe.aggregates.recipe.events import ( + RecipeDefined, + RecipeDeprecated, + RecipeEvent, + RecipeVersioned, + event_type_name, + from_stored, + to_payload, +) +from cora.recipe.aggregates.recipe.evolver import evolve, fold +from cora.recipe.aggregates.recipe.read import ( + RecipeLifecycleTimestamps, + load_recipe, + load_recipe_at_version, + load_recipe_timestamps, +) +from cora.recipe.aggregates.recipe.state import ( + RECIPE_NAME_MAX_LENGTH, + RECIPE_VERSION_TAG_MAX_LENGTH, + EmptyRecipeStepsError, + InvalidRecipeNameError, + InvalidRecipeVersionTagError, + Recipe, + RecipeAlreadyExistsError, + RecipeCannotDeprecateError, + RecipeCannotVersionError, + RecipeName, + RecipeNotFoundError, + RecipeStatus, + RecipeVersionNotFoundError, +) +from cora.recipe.aggregates.recipe.steps_validation import ( + RecipeBindingReferencesUnknownParameterError, + RecipeRequiresCapabilityParametersSchemaError, + collect_binding_names, + validate_recipe_steps_against_capability_schema, +) + +__all__ = [ + "RECIPE_NAME_MAX_LENGTH", + "RECIPE_VERSION_TAG_MAX_LENGTH", + "BindingRef", + "EmptyRecipeStepsError", + "InvalidRecipeNameError", + "InvalidRecipeStepShapeError", + "InvalidRecipeVersionTagError", + "Recipe", + "RecipeActionStep", + "RecipeAlreadyExistsError", + "RecipeBindingReferencesUnknownParameterError", + "RecipeCannotDeprecateError", + "RecipeCannotVersionError", + "RecipeCheckStep", + "RecipeDefined", + "RecipeDeprecated", + "RecipeEvent", + "RecipeLifecycleTimestamps", + "RecipeName", + "RecipeNotFoundError", + "RecipeRequiresCapabilityParametersSchemaError", + "RecipeSetpointStep", + "RecipeStatus", + "RecipeStep", + "RecipeVersionNotFoundError", + "RecipeVersioned", + "UnboundRecipeBindingError", + "collect_binding_names", + "event_type_name", + "evolve", + "fold", + "from_stored", + "load_recipe", + "load_recipe_at_version", + "load_recipe_timestamps", + "resolve_value", + "steps_from_dict", + "steps_to_dict", + "to_payload", + "validate_recipe_steps_against_capability_schema", +] diff --git a/apps/api/src/cora/recipe/aggregates/recipe/body.py b/apps/api/src/cora/recipe/aggregates/recipe/body.py new file mode 100644 index 000000000..5da20e2fb --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/body.py @@ -0,0 +1,282 @@ +"""Typed `RecipeStep` union and wire-format helpers for Recipe step bodies. + +A `Recipe` carries an ordered tuple of `RecipeStep` instances that +expands to a flat sequence of Conductor `Step`s when an operator +binds parameter values via the `register_procedure_from_recipe` +slice (Operation BC). This module ships the type vocabulary and the +wire-format round-trip; the `expand` function that consumes these +VOs into Operation BC `Step`s lives in `cora.operation._recipe_expansion` +because the dependency direction is Operation -> Recipe (Recipe must +not depend on Operation per BC isolation enforced by tach). + +## Substitution shape: typed `BindingRef`, not textual `${var}` + +Three-pass corpus research established that production beamline and +factory automation systems use typed structures, not textual +interpolation. CORA's codebase convention is frozen dataclasses for +domain models, so `BindingRef(name="dwell")` is a structurally +distinct sentinel from the string `"dwell"`: a literal address +`"2bma:rot:val"` is just a `str`; a binding reference is a +`BindingRef`. The expansion function dispatches on +`isinstance(value, BindingRef)`. + +## v1 scope: values bind, addresses do not + +Each deployment's Recipe HARDCODES its PV addresses and only +parameterizes operator-tunable VALUES (dwell, repetitions, +angle_start, etc.). At v1 the parameterized positions are: + + - `RecipeSetpointStep.value` + - `RecipeActionStep.params` (per-key values) + - `RecipeCheckStep.criterion` thresholds stay literal at v1 + (operators do not tune pass/fail; the criterion is part of the + Recipe contract) + +Addresses + action-body `name` + check-step criterion shapes stay +LITERAL. A v2 trigger to widen address-binding fires when a +deployment ships two near-identical Recipes that differ only in PV +prefix. + +## Criterion carrier shape + +`RecipeCheckStep.criterion` is a dict-shaped wire payload (the same +`{kind: ..., expected: ...}` shape the Conductor uses for its +CheckStep criterion serialization). The translation to the typed +`EqualsCriterion | WithinToleranceCriterion` union happens in +`cora.operation._recipe_expansion.expand`. This keeps Recipe BC free +of any Operation BC import. + +## No `RecipeBody` wrapper VO + +Non-emptiness on the step sequence is enforced inside +`Recipe.__post_init__`, not by a wrapper carrier. `to_dict` and +`from_dict` operate on `tuple[RecipeStep, ...]` directly. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, cast + + +@dataclass(frozen=True) +class BindingRef: + """Sentinel value: substitute with `bindings[name]` at expansion time. + + `name` must match a property name in the referenced + `Capability.parameters_schema`. Binding-reference validation lives + in the define-recipe-time decider (and re-runs at version_recipe + and expansion time per [[project-recipe-aggregate-design]] Locks); + this VO carries the reference shape only. + """ + + name: str + + +@dataclass(frozen=True) +class RecipeSetpointStep: + """Setpoint step template: `value` may be a literal or a `BindingRef`. + + `address` is hardcoded per Recipe (no parameterization at v1 per + the v1 scope note in the module docstring); `value` is the only + bindable position. `verify` mirrors `SetpointStep.verify` exactly. + """ + + address: str + value: int | float | bool | str | tuple[Any, ...] | BindingRef + verify: bool = False + + +@dataclass(frozen=True) +class RecipeActionStep: + """Action step template: each `params` value may be a literal or `BindingRef`. + + `name` is the registered action-body name; not parameterized. + `params` values may individually be `BindingRef` sentinels; the + expansion function walks the mapping and substitutes per-key. + """ + + name: str + params: Mapping[str, Any | BindingRef] = field(default_factory=dict[str, Any]) + + +@dataclass(frozen=True) +class RecipeCheckStep: + """Check step template: `criterion` is the wire-format dict. + + Carrying the criterion as a `{kind: ..., expected: ..., tolerance?: ...}` + dict lets Recipe BC define the step VO without importing the typed + criterion classes from Operation BC. The expansion function in + `cora.operation._recipe_expansion` translates the dict to the typed + `EqualsCriterion | WithinToleranceCriterion` union at runtime. + + Recognized kinds today: `"equals"`, `"within_tolerance"`. The + expansion function raises `ValueError` for unknown kinds. + """ + + address: str + criterion: Mapping[str, Any] + + +RecipeStep = RecipeSetpointStep | RecipeActionStep | RecipeCheckStep +"""Closed discriminated union of templated step shapes; parallels `Step` arm-for-arm.""" + + +class UnboundRecipeBindingError(Exception): + """A `BindingRef.name` did not resolve in the supplied `bindings` mapping. + + Family: `Invalid`. The central REST handler maps this to HTTP 422. + Renamed from the worktree's `UnboundBindingError` as part of the + Recipe rename pass. + """ + + def __init__(self, name: str) -> None: + super().__init__(f"unbound binding reference: {name!r}") + self.name = name + + +class InvalidRecipeStepShapeError(Exception): + """Wire-format dict could not be parsed into a `RecipeStep`. + + Raised by `from_dict` for unknown step kinds, missing required + keys, or structurally malformed payloads. Family: `Invalid`. + HTTP 422 (parse failure after Pydantic boundary). + """ + + def __init__(self, reason: str) -> None: + super().__init__(f"recipe step wire shape invalid: {reason}") + self.reason = reason + + +_BINDING_KEY = "__binding__" +"""Wire-format key distinguishing a `BindingRef` from a literal dict value. + +A literal `dict` value in `params` MUST NOT carry this key at v1; the +wire format does not currently support escaping. If a future deployment +needs to bind a dict-typed parameter that happens to carry this exact +key, widen the escape rule then (no current consumer).""" + + +def _value_to_wire(value: Any | BindingRef) -> Any: + """Serialize one value (literal or BindingRef) to a JSON-friendly form.""" + if isinstance(value, BindingRef): + return {_BINDING_KEY: value.name} + return value + + +def _value_from_wire(value: Any) -> Any: + """Deserialize one wire value; reconstruct BindingRef from sentinel dict. + + Returns either the original value (literal) or a `BindingRef` + instance. Signature widens to `Any` because callers store the + result into mappings whose value-type is also `Any`; narrowing to + `Any | BindingRef` does not help downstream type-checking. + """ + if isinstance(value, dict): + typed = cast("dict[str, Any]", value) + if set(typed.keys()) == {_BINDING_KEY}: + return BindingRef(name=typed[_BINDING_KEY]) + return cast("Any", value) + + +def _step_to_wire(step: RecipeStep) -> dict[str, Any]: + if isinstance(step, RecipeSetpointStep): + return { + "kind": "setpoint", + "address": step.address, + "value": _value_to_wire(step.value), + "verify": step.verify, + } + if isinstance(step, RecipeActionStep): + return { + "kind": "action", + "name": step.name, + "params": {key: _value_to_wire(val) for key, val in step.params.items()}, + } + return { + "kind": "check", + "address": step.address, + "criterion": dict(step.criterion), + } + + +def _step_from_wire(payload: dict[str, Any]) -> RecipeStep: + try: + kind = payload["kind"] + except (KeyError, TypeError) as exc: + raise InvalidRecipeStepShapeError("step missing 'kind'") from exc + try: + if kind == "setpoint": + return RecipeSetpointStep( + address=payload["address"], + value=_value_from_wire(payload["value"]), + verify=payload.get("verify", False), + ) + if kind == "action": + return RecipeActionStep( + name=payload["name"], + params={key: _value_from_wire(val) for key, val in payload["params"].items()}, + ) + if kind == "check": + return RecipeCheckStep( + address=payload["address"], + criterion=dict(payload["criterion"]), + ) + except (KeyError, AttributeError, TypeError) as exc: + raise InvalidRecipeStepShapeError(f"step kind {kind!r}: {exc}") from exc + raise InvalidRecipeStepShapeError(f"unknown recipe step kind: {kind!r}") + + +def to_dict(steps: tuple[RecipeStep, ...]) -> dict[str, Any]: + """Serialize a Recipe step sequence to a JSON-friendly dict for event storage. + + Returns a wrapper dict with a single `steps` list, mirroring the + worktree wire format. Callers store the result directly in a + `RecipeDefined` or `RecipeVersioned` payload. + """ + return {"steps": [_step_to_wire(step) for step in steps]} + + +def from_dict(payload: dict[str, Any]) -> tuple[RecipeStep, ...]: + """Rebuild a Recipe step sequence from its wire-format dict. + + Returns `tuple[RecipeStep, ...]` directly; does NOT enforce + non-emptiness (that invariant is carried by `Recipe.__post_init__` + when the steps are folded into a `Recipe(...)` instance). + + Raises `InvalidRecipeStepShapeError` for unknown step kinds or + structurally malformed payloads. + """ + try: + raw_steps = payload["steps"] + except (KeyError, TypeError) as exc: + raise InvalidRecipeStepShapeError("payload missing 'steps'") from exc + return tuple(_step_from_wire(s) for s in raw_steps) + + +def resolve_value(value: Any | BindingRef, bindings: Mapping[str, Any]) -> Any: + """Resolve a single value (literal or BindingRef) against `bindings`. + + Public helper the Operation BC `expand` function uses to substitute + one value at a time without duplicating the BindingRef-aware lookup + logic. Raises `UnboundRecipeBindingError` when a BindingRef name is + missing from `bindings`. + """ + if isinstance(value, BindingRef): + if value.name not in bindings: + raise UnboundRecipeBindingError(value.name) + return bindings[value.name] + return value + + +__all__ = [ + "BindingRef", + "InvalidRecipeStepShapeError", + "RecipeActionStep", + "RecipeCheckStep", + "RecipeSetpointStep", + "RecipeStep", + "UnboundRecipeBindingError", + "from_dict", + "resolve_value", + "to_dict", +] diff --git a/apps/api/src/cora/recipe/aggregates/recipe/events.py b/apps/api/src/cora/recipe/aggregates/recipe/events.py new file mode 100644 index 000000000..eaaf30304 --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/events.py @@ -0,0 +1,233 @@ +"""Domain events emitted by the Recipe aggregate, plus the discriminated union. + +New aggregate (no rename history -> no dual-match arms). Events: + + - RecipeDefined (genesis; carries name + capability_id + steps) + - RecipeVersioned (declarative replacement: new version_tag + new + steps; name and capability_id PRESERVED across + versions per the immutable-capability_id lock) + - RecipeDeprecated (terminal state; carries optional replaced_by + pointer per LOINC `MAP_TO`) + +Status is NOT carried in event payloads; the event type itself +encodes the state change. Same precedent as `CapabilityStatus` / +`FamilyStatus`. + +## Replacement semantics + +A new version IS a new declaration: `RecipeVersioned` carries the +FULL replacement step sequence. No diff/merge semantics. Matches the +replace-on-version precedent across the Family/Method/Plan/Practice/ +Capability family. + +## Re-attestation emits + +Re-versioning with identical `(version_tag, steps)` SUCCEEDS and emits +the event. The duplicate is the audit signal, not a bug. Mirrors +`version_capability` / `version_method` deciders which both emit on +byte-equal re-call. + +## Payload field ordering + +Steps serialize via `body.to_dict` (canonical wire format with +`__binding__` sentinel for BindingRef). `replaced_by_recipe_id` +serializes as string-or-None. +""" + +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.recipe.aggregates.recipe.body import InvalidRecipeStepShapeError, RecipeStep +from cora.recipe.aggregates.recipe.body import from_dict as steps_from_dict +from cora.recipe.aggregates.recipe.body import to_dict as steps_to_dict + +# ValueError covers UUID() / datetime.fromisoformat() parse failures; +# InvalidRecipeStepShapeError covers steps_from_dict unknown-kind / missing-key +# paths. Both must wrap into the canonical Malformed payload +# envelope per [[project-from-stored-wrap-convention]] so audit dispatch +# groups them uniformly with the default KeyError / TypeError / AttributeError +# cases that deserialize_or_raise already catches. +_PAYLOAD_PARSE_EXTRA: tuple[type[BaseException], ...] = ( + ValueError, + InvalidRecipeStepShapeError, +) + + +@dataclass(frozen=True) +class RecipeDefined: + """A new Recipe was defined against a Capability. + + Status is implicit (`Defined`); the evolver sets it. All + declarative fields are present in the genesis payload. + """ + + recipe_id: UUID + name: str + capability_id: UUID + steps: tuple[RecipeStep, ...] + occurred_at: datetime + + +@dataclass(frozen=True) +class RecipeVersioned: + """A Recipe's step sequence was revised; a new version label was issued. + + Multi-source transition: `Defined | Versioned -> Versioned`. The + full step sequence REPLACES wholesale (a new version IS a new + declaration). `name` and `capability_id` are PRESERVED across + versions; re-binding to a different Capability requires a new + Recipe. + """ + + recipe_id: UUID + version_tag: str + steps: tuple[RecipeStep, ...] + occurred_at: datetime + + +@dataclass(frozen=True) +class RecipeDeprecated: + """A Recipe was marked as no longer recommended for new bindings. + + Multi-source transition: `Defined | Versioned -> Deprecated`. + `replaced_by_recipe_id` is an optional pointer to a successor + Recipe (LOINC `MAP_TO` precedent); None means deprecated- + without-replacement. Existing Procedures already expanded from + this Recipe are NOT automatically invalidated (advisory at BC + layer). + """ + + recipe_id: UUID + occurred_at: datetime + replaced_by_recipe_id: UUID | None = None + + +RecipeEvent = RecipeDefined | RecipeVersioned | RecipeDeprecated + + +def event_type_name(event: RecipeEvent) -> str: + """Discriminator string written into StoredEvent.event_type.""" + return type(event).__name__ + + +def to_payload(event: RecipeEvent) -> dict[str, Any]: + """Serialize a Recipe event to a JSON-friendly dict for jsonb storage. + + UUIDs become strings; datetimes ISO-8601; step sequences serialize + via `body.to_dict` so the wire-format `__binding__` sentinel + survives the round-trip. + """ + match event: + case RecipeDefined( + recipe_id=recipe_id, + name=name, + capability_id=capability_id, + steps=steps, + occurred_at=occurred_at, + ): + return { + "recipe_id": str(recipe_id), + "name": name, + "capability_id": str(capability_id), + "steps": steps_to_dict(steps), + "occurred_at": occurred_at.isoformat(), + } + case RecipeVersioned( + recipe_id=recipe_id, + version_tag=version_tag, + steps=steps, + occurred_at=occurred_at, + ): + return { + "recipe_id": str(recipe_id), + "version_tag": version_tag, + "steps": steps_to_dict(steps), + "occurred_at": occurred_at.isoformat(), + } + case RecipeDeprecated( + recipe_id=recipe_id, + replaced_by_recipe_id=replaced_by_recipe_id, + occurred_at=occurred_at, + ): + return { + "recipe_id": str(recipe_id), + "replaced_by_recipe_id": ( + str(replaced_by_recipe_id) if replaced_by_recipe_id is not None else None + ), + "occurred_at": occurred_at.isoformat(), + } + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def from_stored(stored: StoredEvent) -> RecipeEvent: + """Rebuild a Recipe event from a StoredEvent loaded from the event store. + + Single-match arms; no legacy / dual-match (this aggregate is new, + not a rename of a prior aggregate). Step-sequence payloads round- + trip through `body.from_dict`, which raises + `InvalidRecipeStepShapeError` on malformed shapes; the + `deserialize_or_raise` wrapper translates any exception during + rebuild into a `Malformed Recipe` error per the + `from_stored` wrap convention. + """ + payload = stored.payload + match stored.event_type: + case "RecipeDefined": + return deserialize_or_raise( + "RecipeDefined", + lambda: RecipeDefined( + recipe_id=UUID(payload["recipe_id"]), + name=payload["name"], + capability_id=UUID(payload["capability_id"]), + steps=steps_from_dict(payload["steps"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + extra=_PAYLOAD_PARSE_EXTRA, + ) + case "RecipeVersioned": + return deserialize_or_raise( + "RecipeVersioned", + lambda: RecipeVersioned( + recipe_id=UUID(payload["recipe_id"]), + version_tag=payload["version_tag"], + steps=steps_from_dict(payload["steps"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + extra=_PAYLOAD_PARSE_EXTRA, + ) + case "RecipeDeprecated": + + def _build_recipe_deprecated() -> RecipeDeprecated: + replaced_raw = payload.get("replaced_by_recipe_id") + return RecipeDeprecated( + recipe_id=UUID(payload["recipe_id"]), + replaced_by_recipe_id=( + UUID(replaced_raw) if replaced_raw is not None else None + ), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + return deserialize_or_raise( + "RecipeDeprecated", + _build_recipe_deprecated, + extra=_PAYLOAD_PARSE_EXTRA, + ) + case _: + msg = f"Unknown RecipeEvent event_type: {stored.event_type!r}" + raise ValueError(msg) + + +__all__ = [ + "RecipeDefined", + "RecipeDeprecated", + "RecipeEvent", + "RecipeVersioned", + "event_type_name", + "from_stored", + "to_payload", +] diff --git a/apps/api/src/cora/recipe/aggregates/recipe/evolver.py b/apps/api/src/cora/recipe/aggregates/recipe/evolver.py new file mode 100644 index 000000000..0351c4721 --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/evolver.py @@ -0,0 +1,97 @@ +"""Evolver: replay events to reconstruct Recipe state. + +Status mapping per event type: + - `RecipeDefined` -> DEFINED (genesis; version=None) + - `RecipeVersioned` -> VERSIONED (version=event.version_tag; + steps REPLACE wholesale; + name + capability_id PRESERVED) + - `RecipeDeprecated` -> DEPRECATED (steps + capability_id + name + PRESERVED for audit; + replaced_by_recipe_id captured + if supplied) + +The mapping is hardcoded per match arm; the event type IS the +state-change indicator (no status field in event payloads). Same +precedent as `CapabilityVersioned` / `FamilyVersioned`. + +## Replace vs preserve on each arm + +- `RecipeVersioned` REPLACES `steps` with the new event's tuple (a + new version IS a new declaration). PRESERVES `name`, + `capability_id`, and `replaced_by_recipe_id`. +- `RecipeDeprecated` PRESERVES all declarative fields (steps, + capability_id, name, version) and ADDS the + `replaced_by_recipe_id` pointer. Operators reading a deprecated + Recipe still see what it declared (audit-critical). + +Transition events applied to empty state raise ValueError via +`require_state`; they can never appear before `RecipeDefined` in a +well-formed stream. +""" + +from collections.abc import Sequence +from typing import assert_never + +from cora.infrastructure.evolver import require_state +from cora.recipe.aggregates.recipe.events import ( + RecipeDefined, + RecipeDeprecated, + RecipeEvent, + RecipeVersioned, +) +from cora.recipe.aggregates.recipe.state import ( + Recipe, + RecipeName, + RecipeStatus, +) + + +def evolve(state: Recipe | None, event: RecipeEvent) -> Recipe: + """Apply one event to the current state.""" + match event: + case RecipeDefined( + recipe_id=recipe_id, + name=name, + capability_id=capability_id, + steps=steps, + ): + _ = state # genesis event; prior state ignored + return Recipe( + id=recipe_id, + name=RecipeName(name), + capability_id=capability_id, + steps=steps, + status=RecipeStatus.DEFINED, + ) + case RecipeVersioned(version_tag=version_tag, steps=steps): + prior = require_state(state, "RecipeVersioned") + return Recipe( + id=prior.id, + name=prior.name, + capability_id=prior.capability_id, + steps=steps, + status=RecipeStatus.VERSIONED, + version=version_tag, + replaced_by_recipe_id=prior.replaced_by_recipe_id, + ) + case RecipeDeprecated(replaced_by_recipe_id=replaced_by_recipe_id): + prior = require_state(state, "RecipeDeprecated") + return Recipe( + id=prior.id, + name=prior.name, + capability_id=prior.capability_id, + steps=prior.steps, + status=RecipeStatus.DEPRECATED, + version=prior.version, + replaced_by_recipe_id=replaced_by_recipe_id, + ) + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def fold(events: Sequence[RecipeEvent]) -> Recipe | None: + """Replay a stream of events from the empty initial state.""" + state: Recipe | None = None + for event in events: + state = evolve(state, event) + return state diff --git a/apps/api/src/cora/recipe/aggregates/recipe/read.py b/apps/api/src/cora/recipe/aggregates/recipe/read.py new file mode 100644 index 000000000..c0d78e8c4 --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/read.py @@ -0,0 +1,134 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +"""Read repositories for the Recipe aggregate. + +`load_recipe(event_store, recipe_id) -> Recipe | None` mirrors +`load_capability` / `load_method` / `load_plan`. + +`load_recipe_timestamps(pool, recipe_id) -> RecipeLifecycleTimestamps | None` +reads the projection-row metadata that mirrors the FSM transitions +(Path C). State stays minimal per decider purity; lifecycle +timestamps live on the projection per the May-2026 +template-aggregate-timestamps sweep precedent. Mirrors +`load_capability_timestamps` / `load_method_timestamps` / +`load_plan_timestamps` / `load_practice_timestamps` / +`load_family_timestamps`. + +Note: `Recipe.replaced_by_recipe_id` STATE field is unaffected; it's +an intrinsic deprecation pointer the decider may read on future +commands, distinct from the lifecycle-when timestamp surfaced here. +""" + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +import asyncpg + +from cora.infrastructure.ports import EventStore +from cora.recipe.aggregates.recipe.events import ( + RecipeVersioned, + from_stored, +) +from cora.recipe.aggregates.recipe.evolver import fold +from cora.recipe.aggregates.recipe.state import ( + Recipe, + RecipeVersionNotFoundError, +) + +_STREAM_TYPE = "Recipe" + +_SELECT_TIMESTAMPS_SQL = """ +SELECT created_at, versioned_at, deprecated_at +FROM proj_recipe_recipe_summary +WHERE recipe_id = $1 +""" + + +@dataclass(frozen=True) +class RecipeLifecycleTimestamps: + """Observed wall-clock timestamps of FSM transitions. + + Sourced from the Recipe summary projection, not from aggregate + state. `created_at` is set once on `RecipeDefined`; `versioned_at` + is overwritten on each `RecipeVersioned` (state-always-holds-latest + convention mirrored in the projection); `deprecated_at` is set + once on `RecipeDeprecated` and is terminal. + """ + + created_at: datetime + versioned_at: datetime | None + deprecated_at: datetime | None + + +async def load_recipe(event_store: EventStore, recipe_id: UUID) -> Recipe | None: + """Load and fold a Recipe's event stream into current state.""" + stored, _version = await event_store.load(_STREAM_TYPE, recipe_id) + events = [from_stored(s) for s in stored] + return fold(events) + + +async def load_recipe_at_version( + event_store: EventStore, + recipe_id: UUID, + version_tag: str | None, +) -> Recipe | None: + """Load Recipe state at the pinned `version_tag` (first-match-from-head). + + Walks the Recipe event stream from genesis, folding events into + `Recipe` state incrementally. Stops AT the first `RecipeVersioned` + event whose `version_tag` matches the pinned tag and returns the + post-fold state. Used by `conduct_procedure` replay (per + [[project-run-procedure-replay-design]]) to resolve a Recipe to + the exact snapshot pinned in `RecipeExpansionRecorded.recipe_version`. + + Semantics: + - Returns `None` when the Recipe stream is empty (no genesis event); + the caller decides whether to raise. + - When `version_tag is None`, returns the post-genesis state + (post-`RecipeDefined`, no `version_recipe` calls yet). This + mirrors `Recipe.version is None` and covers Procedures registered + from a Recipe that was never versioned. + - When `version_tag` is set and the stream has events but no + `RecipeVersioned` matches, raises `RecipeVersionNotFoundError`. + - First-match-from-head when multiple `RecipeVersioned` events + share a tag (re-tagging is allowed per ): the first + match wins because the later re-tagging cannot retroactively + change which version was pinned by an earlier + `RecipeExpansionRecorded`. + - The fold runs over all preceding events; `RecipeDeprecated` + events the FSM forbids ahead of a matching `RecipeVersioned` + are still folded defensively (the helper does not assume FSM + cleanliness, only that it can find the target event). + """ + stored, _version = await event_store.load(_STREAM_TYPE, recipe_id) + if not stored: + return None + events = [from_stored(s) for s in stored] + if version_tag is None: + return fold(events[:1]) + for index, event in enumerate(events): + if isinstance(event, RecipeVersioned) and event.version_tag == version_tag: + return fold(events[: index + 1]) + raise RecipeVersionNotFoundError(recipe_id, version_tag) + + +async def load_recipe_timestamps( + pool: asyncpg.Pool, + recipe_id: UUID, +) -> RecipeLifecycleTimestamps | None: + """Read the lifecycle-timestamp triple from the projection. + + Contract: `pool` MUST be a live asyncpg pool; None-check belongs + to the caller, not this function (mirrors `load_capability_timestamps` + and peers). + """ + async with pool.acquire() as conn: + row = await conn.fetchrow(_SELECT_TIMESTAMPS_SQL, recipe_id) + if row is None: + return None + return RecipeLifecycleTimestamps( + created_at=row["created_at"], + versioned_at=row["versioned_at"], + deprecated_at=row["deprecated_at"], + ) diff --git a/apps/api/src/cora/recipe/aggregates/recipe/state.py b/apps/api/src/cora/recipe/aggregates/recipe/state.py new file mode 100644 index 000000000..5730050ce --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/state.py @@ -0,0 +1,255 @@ +"""Recipe aggregate state, status enum, errors, and value objects. + +`Recipe` is the EXECUTABLE PARAMETERIZED STEP SEQUENCE at the +operations layer: a deployment-bound, ordered tuple of templated +steps that, once an operator supplies parameter bindings, expands +into a flat list of `Step`s the Operation BC Conductor walks. Each +Recipe references one `Capability` via `capability_id`; that +Capability declares the contract (parameters_schema, required +affordances, executor shapes) the Recipe realizes. + +Per [[project-recipe-aggregate-design]] (the 5th aggregate added to +Recipe BC after [[capability-naming-split-lock]]), Recipe sits BESIDE +Capability rather than absorbing Method/Plan: Method (technique-class +contract) and Plan (Asset-bound binding) keep their roles; Recipe +carries the body that previously squatted on `Capability.template_body`. + +Distinct from siblings: +- `Capability` declares the operations-layer contract: code, name, + required affordances, parameters_schema, executor shapes. Slow-changing + namespaced declaration. +- `Recipe` (this aggregate) is the deployment-specific executable body: + capability_id + ordered tuple of typed step VOs with embedded + BindingRef sentinels. Iterates faster than Capability. +- `Method` is the technique-class contract (the science-side declaration). +- `Plan` is the Asset-bound binding (Plan.wires + Plan.default_parameters). + +Genesis + FSM `Defined -> Versioned -> Deprecated`, matching +Capability/Method/Plan/Practice/Family precedent. Slice verbs: +`define_recipe`, `version_recipe`, `deprecate_recipe`, `get_recipe`. + +## Status as enum-in-state, derived-from-event-type-in-evolver + +`RecipeStatus` is a `StrEnum` so the values would serialize naturally +as JSON-friendly strings IF carried in event payloads. Today they +aren't: state holds the enum (typed) and the evolver derives status +from the event TYPE, same precedent as `CapabilityStatus` / +`FamilyStatus` / `MethodStatus`. + +## Non-emptiness invariant on `steps` + +A Recipe without steps has no operational meaning (expansion would +produce zero work). `Recipe.__post_init__` raises `EmptyRecipeStepsError` +when `steps` is empty. The invariant is carried by Recipe construction +and re-runs every time the evolver folds a `RecipeDefined` or +`RecipeVersioned` event into a `Recipe(...)` call. The retired +worktree `TemplateBody` wrapper VO that previously enforced this is +not reintroduced; Recipe owns the invariant directly. + +## Immutable capability_id + +`Recipe.capability_id` is REQUIRED at define_recipe time and IMMUTABLE +across `version_recipe`: re-binding a Recipe to a different Capability +is forbidden. Operators wanting a different binding author a new +Recipe. Mirrors `Method.capability_id` immutability. + +## No `description` field + +Intentional divergence from the Capability mirror. Per anti-hook 17 in +[[project-recipe-aggregate-design]]: human annotation belongs on +Capability (the contract), not on Recipe (the executable derivative). +A Recipe is identified by `capability_id + name`. +""" + +from dataclasses import dataclass +from enum import StrEnum +from uuid import UUID + +from cora.infrastructure.bounded_text import validate_bounded_text +from cora.recipe.aggregates.recipe.body import RecipeStep + +RECIPE_NAME_MAX_LENGTH = 200 +RECIPE_VERSION_TAG_MAX_LENGTH = 50 + + +class RecipeStatus(StrEnum): + """The Recipe's lifecycle state. + + Transitions: + - Defined -> Versioned (version_recipe) + - (Defined | Versioned) -> Deprecated (deprecate_recipe) + + `Defined` is the genesis state set by `define_recipe`. PascalCase + string values match the BC-map status vocabulary so log lines and + DTOs read naturally without additional mapping. + """ + + DEFINED = "Defined" + VERSIONED = "Versioned" + DEPRECATED = "Deprecated" + + +class InvalidRecipeNameError(ValueError): + """The supplied name is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Recipe name must be 1-{RECIPE_NAME_MAX_LENGTH} chars after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidRecipeVersionTagError(ValueError): + """The supplied version tag is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Recipe version tag must be 1-{RECIPE_VERSION_TAG_MAX_LENGTH} chars after " + f"trimming (got: {value!r})" + ) + self.value = value + + +class EmptyRecipeStepsError(Exception): + """`Recipe.steps` is empty. + + Enforced inside `Recipe.__post_init__` so the gate fires both at + write time (deciders and handlers) and at fold time (evolver + construction on replay). A Recipe with zero steps has no + operational meaning; expansion would produce no work. + + Family: `Invalid`. HTTP 400 (domain-invariant error from + `__post_init__`, not a Pydantic-boundary parse failure). The 422 + reservation is for boundary parse errors only. + """ + + def __init__(self) -> None: + super().__init__("Recipe.steps must be non-empty") + + +class RecipeAlreadyExistsError(Exception): + """Attempted to define a Recipe whose stream already has events.""" + + def __init__(self, recipe_id: UUID) -> None: + super().__init__(f"Recipe {recipe_id} already exists") + self.recipe_id = recipe_id + + +class RecipeNotFoundError(Exception): + """Attempted an operation on a Recipe whose stream has no events.""" + + def __init__(self, recipe_id: UUID) -> None: + super().__init__(f"Recipe {recipe_id} not found") + self.recipe_id = recipe_id + + +class RecipeVersionNotFoundError(Exception): + """A `load_recipe_at_version` lookup found the Recipe stream but no + `RecipeVersioned` event whose `version_tag` matches the pinned tag. + + Distinct from `RecipeNotFoundError` (stream wholly absent). Raised + by `load_recipe_at_version` when the caller pins a tag that is + absent from the Recipe history; surfaced as HTTP 404 since the + requested resource (the version) does not exist. + """ + + def __init__(self, recipe_id: UUID, version_tag: str) -> None: + super().__init__( + f"Recipe {recipe_id} has no RecipeVersioned event with version_tag {version_tag!r}" + ) + self.recipe_id = recipe_id + self.version_tag = version_tag + + +class RecipeCannotVersionError(Exception): + """Attempted to version a Recipe whose status is `Deprecated`. + + Multi-source guard: `version_recipe` accepts `Defined | Versioned`. + Re-versioning with the same tag SUCCEEDS and emits the event + (re-attestation is a legitimate audit moment, matching + `version_capability` / `version_method` precedent). + """ + + def __init__(self, recipe_id: UUID, current_status: "RecipeStatus") -> None: + super().__init__( + f"Recipe {recipe_id} cannot be versioned: currently in status " + f"{current_status.value}, version requires " + f"{RecipeStatus.DEFINED.value} or {RecipeStatus.VERSIONED.value}" + ) + self.recipe_id = recipe_id + self.current_status = current_status + + +class RecipeCannotDeprecateError(Exception): + """Attempted to deprecate a Recipe whose status is `Deprecated`. + + Strict-not-idempotent: re-deprecating a Deprecated Recipe raises. + """ + + def __init__(self, recipe_id: UUID, current_status: "RecipeStatus") -> None: + super().__init__( + f"Recipe {recipe_id} cannot be deprecated: currently in status " + f"{current_status.value}, deprecate requires " + f"{RecipeStatus.DEFINED.value} or {RecipeStatus.VERSIONED.value}" + ) + self.recipe_id = recipe_id + self.current_status = current_status + + +@dataclass(frozen=True) +class RecipeName: + """Display name for a Recipe. Trimmed; 1-200 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=RECIPE_NAME_MAX_LENGTH, + error_class=InvalidRecipeNameError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class Recipe: + """Aggregate root: a deployment-bound executable step sequence. + + `capability_id` is REQUIRED at define time and IMMUTABLE across + versions. Re-binding a Recipe to a different Capability is + forbidden; operators create a new Recipe instead. + + `steps` is the ordered tuple of templated `RecipeStep` instances, + each potentially carrying `BindingRef` sentinels resolved against + operator-supplied bindings at expansion time. Replaced wholesale + by `version_recipe`; non-empty enforced in `__post_init__`. + + `version` is the operator-supplied label of the most recent + `version_recipe` call (None until first version). State holds the + latest tag; past tags live in the event stream as `RecipeVersioned` + events. `version_tag` carries no UNIQUE constraint; re-tagging is + allowed (re-attestation is a legitimate audit moment per + `version_capability`/`version_method` precedent). Replay determinism + comes from first-match-from-head tag-string lookup via + `load_recipe_at_version`; the earlier `RecipeVersioned` binds the + earlier `RecipeExpansionRecorded` by construction (the later + re-tagging cannot retroactively change which version was pinned). + See [[project-run-procedure-replay-design]] Locks. + + `replaced_by_recipe_id`: pointer to a successor Recipe when this + one is deprecated with replacement. None on + Deprecated-without-replacement and on Defined/Versioned. LOINC + `MAP_TO` precedent matching `Capability.replaced_by_capability_id`. + """ + + id: UUID + name: RecipeName + capability_id: UUID + steps: tuple[RecipeStep, ...] + status: RecipeStatus = RecipeStatus.DEFINED + version: str | None = None + replaced_by_recipe_id: UUID | None = None + + def __post_init__(self) -> None: + if not self.steps: + raise EmptyRecipeStepsError diff --git a/apps/api/src/cora/recipe/aggregates/recipe/steps_validation.py b/apps/api/src/cora/recipe/aggregates/recipe/steps_validation.py new file mode 100644 index 000000000..5d2db50ec --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/steps_validation.py @@ -0,0 +1,140 @@ +"""Cross-aggregate validation: Recipe step BindingRefs vs Capability.parameters_schema. + +A Recipe's `steps` may carry `BindingRef(name=...)` sentinels that +resolve against operator-supplied bindings at expansion time. At +define-recipe-time AND version-recipe-time AND +register-procedure-from-recipe-time, the slice handler verifies each +`BindingRef.name` reachable inside `steps` REFERS TO a parameter +declared in the referenced `Capability.parameters_schema.properties`. +Catching unknown binding names at every Recipe lifecycle write AND at +expansion gives deployment authors a fast, exhaustive failure mode +for typos, stale renames, and the Capability-re-version race. + +Eager re-validation at expansion time closes the race where Capability +was versioned independently after the Recipe's last write; the slice +loads the CURRENT Capability state and runs this validator. When the +schema has drifted such that the Recipe's BindingRefs no longer +resolve, the slice raises a dedicated stale-Capability error class +(defined in the slice module, not here). + +Validation rules: + - Every `BindingRef.name` reachable in the steps must appear in + `parameters_schema["properties"]` (when `parameters_schema` is + non-None). + - If `parameters_schema` is None, the steps MUST contain zero + `BindingRef` instances (a Recipe with bindings against no schema + is malformed). + +Recursion: walks all `BindingRef`-eligible positions +(`RecipeSetpointStep.value`, `RecipeActionStep.params` per-key, +`RecipeCheckStep.criterion` thresholds at v1 do NOT carry BindingRef). +""" + +from collections.abc import Mapping +from typing import Any, cast + +from cora.recipe.aggregates.recipe.body import ( + BindingRef, + RecipeActionStep, + RecipeCheckStep, + RecipeSetpointStep, + RecipeStep, +) + + +class RecipeBindingReferencesUnknownParameterError(Exception): + """A `BindingRef.name` in the Recipe's steps does not appear in `parameters_schema`. + + Carries the offending name + the set of declared parameter names + so operators can spot a typo (or stale rename) immediately. Family: + `Invalid`. HTTP 422. + """ + + def __init__(self, name: str, schema_properties: frozenset[str]) -> None: + declared = sorted(schema_properties) + super().__init__( + f"Recipe steps reference unknown parameter {name!r}; " + f"Capability.parameters_schema declares {declared!r}" + ) + self.name = name + self.schema_properties = schema_properties + + +class RecipeRequiresCapabilityParametersSchemaError(Exception): + """Recipe steps contain `BindingRef`s but the referenced Capability has no schema. + + A Recipe with bindings cannot validate against a None + `parameters_schema`; the operator needs to either drop the + bindings or first set a parameters_schema on the referenced + Capability. Family: `Invalid`. HTTP 422. + """ + + def __init__(self, binding_names: frozenset[str]) -> None: + names = sorted(binding_names) + super().__init__( + f"Recipe has {len(names)} binding reference(s) {names!r} " + f"but the referenced Capability.parameters_schema is None" + ) + self.binding_names = binding_names + + +def _binding_names_in_value(value: Any) -> frozenset[str]: + if isinstance(value, BindingRef): + return frozenset({value.name}) + return frozenset() + + +def _binding_names_in_step(step: object) -> frozenset[str]: + if isinstance(step, RecipeSetpointStep): + return _binding_names_in_value(step.value) + if isinstance(step, RecipeActionStep): + names: set[str] = set() + for val in step.params.values(): + names |= _binding_names_in_value(val) + return frozenset(names) + if isinstance(step, RecipeCheckStep): + return frozenset() + return frozenset() + + +def collect_binding_names(steps: tuple[RecipeStep, ...]) -> frozenset[str]: + """Return the set of `BindingRef.name` values reachable inside the step sequence.""" + names: set[str] = set() + for step in steps: + names |= _binding_names_in_step(step) + return frozenset(names) + + +def validate_recipe_steps_against_capability_schema( + steps: tuple[RecipeStep, ...], + parameters_schema: Mapping[str, Any] | None, +) -> None: + """Verify every `BindingRef` in `steps` resolves to a declared parameter. + + Raises `RecipeRequiresCapabilityParametersSchemaError` if the steps + have any `BindingRef` but `parameters_schema` is None. Raises + `RecipeBindingReferencesUnknownParameterError` for the first + BindingRef whose name is not in `parameters_schema["properties"]`. + """ + binding_names = collect_binding_names(steps) + if not binding_names: + return + if parameters_schema is None: + raise RecipeRequiresCapabilityParametersSchemaError(binding_names) + raw_properties = parameters_schema.get("properties", {}) + if isinstance(raw_properties, dict): + typed_properties = cast("dict[str, Any]", raw_properties) + declared: frozenset[str] = frozenset(typed_properties.keys()) + else: + declared = frozenset() + for name in sorted(binding_names): + if name not in declared: + raise RecipeBindingReferencesUnknownParameterError(name, declared) + + +__all__ = [ + "RecipeBindingReferencesUnknownParameterError", + "RecipeRequiresCapabilityParametersSchemaError", + "collect_binding_names", + "validate_recipe_steps_against_capability_schema", +] diff --git a/apps/api/src/cora/recipe/features/define_recipe/__init__.py b/apps/api/src/cora/recipe/features/define_recipe/__init__.py new file mode 100644 index 000000000..30af68182 --- /dev/null +++ b/apps/api/src/cora/recipe/features/define_recipe/__init__.py @@ -0,0 +1,23 @@ +"""Slice: define a new Recipe against an existing Capability. + +Vertical slice. Mirrors `define_capability` and `define_method` in +shape and discipline; adds a cross-aggregate fan-out at handler time +to load the referenced Capability and validate BindingRef integrity +against its `parameters_schema`. +""" + +from cora.recipe.features.define_recipe import tool +from cora.recipe.features.define_recipe.command import DefineRecipe +from cora.recipe.features.define_recipe.decider import decide +from cora.recipe.features.define_recipe.handler import Handler, IdempotentHandler, bind +from cora.recipe.features.define_recipe.route import router + +__all__ = [ + "DefineRecipe", + "Handler", + "IdempotentHandler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/recipe/features/define_recipe/command.py b/apps/api/src/cora/recipe/features/define_recipe/command.py new file mode 100644 index 000000000..27b9bbeae --- /dev/null +++ b/apps/api/src/cora/recipe/features/define_recipe/command.py @@ -0,0 +1,34 @@ +"""The `DefineRecipe` command, intent dataclass for this slice. + +Carries the FULL declarative contract the caller controls: +operator-supplied name, capability_id (REQUIRED + immutable across +versions), and the templated `steps` tuple with embedded BindingRef +sentinels. Server-side concerns (new id, wall-clock timestamp, +correlation id, per-event ids) are injected by the handler from +infrastructure ports. + +`capability_id` resolves cross-aggregate at handler time before the +decider runs; a missing Capability raises `CapabilityNotFoundError` +(re-used per anti-hook 18 of [[project-recipe-aggregate-design]]). +Every reachable `BindingRef.name` is validated against the loaded +Capability's `parameters_schema.properties` per the eager cross-BC +validation lock. + +`steps` is REQUIRED non-empty; the Recipe aggregate's `__post_init__` +gate raises `EmptyRecipeStepsError` if the resulting evolver fold +would produce an empty step sequence. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.recipe.aggregates.recipe import RecipeStep + + +@dataclass(frozen=True) +class DefineRecipe: + """Define a new Recipe against an existing Capability.""" + + name: str + capability_id: UUID + steps: tuple[RecipeStep, ...] diff --git a/apps/api/src/cora/recipe/features/define_recipe/decider.py b/apps/api/src/cora/recipe/features/define_recipe/decider.py new file mode 100644 index 000000000..0c50d7fa5 --- /dev/null +++ b/apps/api/src/cora/recipe/features/define_recipe/decider.py @@ -0,0 +1,62 @@ +"""Pure decider for the `DefineRecipe` command. + +Pure function: given the current Recipe state (None for a fresh +stream) and a `DefineRecipe` command, returns the events to append. +No I/O, no awaits, no side effects. The handler performs the +cross-aggregate Capability load + BindingRef integrity check BEFORE +invoking this decider; the decider receives only the validated +result. + +`now` and `new_id` are injected by the application handler from the +Clock and IdGenerator ports. + +Invariants: + - State must be None (recipe stream must be fresh) + -> RecipeAlreadyExistsError + - command.name must be 1-200 chars after trimming + -> InvalidRecipeNameError (Recipe.__post_init__-adjacent + boundary; raised by RecipeName VO construction) + - command.steps must be non-empty + -> EmptyRecipeStepsError (Recipe.__post_init__ gate) +""" + +from datetime import datetime +from uuid import UUID + +from cora.recipe.aggregates.recipe import ( + Recipe, + RecipeAlreadyExistsError, + RecipeDefined, + RecipeName, +) +from cora.recipe.features.define_recipe.command import DefineRecipe + + +def decide( + state: Recipe | None, + command: DefineRecipe, + *, + now: datetime, + new_id: UUID, +) -> list[RecipeDefined]: + """Decide the events produced by defining a new Recipe.""" + if state is not None: + raise RecipeAlreadyExistsError(state.id) + name = RecipeName(command.name) # validates 1-200 chars + # Re-construct Recipe through the aggregate to fire the + # EmptyRecipeStepsError invariant before any event is emitted. + Recipe( + id=new_id, + name=name, + capability_id=command.capability_id, + steps=command.steps, + ) + return [ + RecipeDefined( + recipe_id=new_id, + name=name.value, + capability_id=command.capability_id, + steps=command.steps, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/recipe/features/define_recipe/handler.py b/apps/api/src/cora/recipe/features/define_recipe/handler.py new file mode 100644 index 000000000..d8d5fb820 --- /dev/null +++ b/apps/api/src/cora/recipe/features/define_recipe/handler.py @@ -0,0 +1,155 @@ +"""Application handler for the `define_recipe` slice. + +Create-style handler with a cross-aggregate fan-out preceding the +decider call: loads the referenced Capability via +`load_capability(deps.event_store, ...)`, then validates the +supplied steps' BindingRef integrity against +`Capability.parameters_schema.properties`. The handler raises the +existing `CapabilityNotFoundError` cross-aggregate when the +Capability stream is empty (anti-hook 18 of +[[project-recipe-aggregate-design]]: do NOT invent a new +error class for missing-Capability). +""" + +from typing import Protocol +from uuid import UUID + +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.recipe.aggregates.capability import ( + CapabilityNotFoundError, + load_capability, +) +from cora.recipe.aggregates.recipe import ( + event_type_name, + to_payload, + validate_recipe_steps_against_capability_schema, +) +from cora.recipe.errors import UnauthorizedError +from cora.recipe.features.define_recipe.command import DefineRecipe +from cora.recipe.features.define_recipe.decider import decide + +_STREAM_TYPE = "Recipe" +_COMMAND_NAME = "DefineRecipe" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare define_recipe handler, the shape `bind()` returns.""" + + async def __call__( + self, + command: DefineRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +class IdempotentHandler(Protocol): + """define_recipe handler with Idempotency-Key support.""" + + async def __call__( + self, + command: DefineRecipe, + *, + 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 define_recipe handler closed over the shared deps.""" + + async def handler( + command: DefineRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "define_recipe.start", + command_name=_COMMAND_NAME, + capability_id=str(command.capability_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( + "define_recipe.denied", + command_name=_COMMAND_NAME, + capability_id=str(command.capability_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) + + capability = await load_capability(deps.event_store, command.capability_id) + if capability is None: + raise CapabilityNotFoundError(command.capability_id) + validate_recipe_steps_against_capability_schema(command.steps, capability.parameters_schema) + + new_id = deps.id_generator.new_id() + now = deps.clock.now() + + domain_events = decide( + state=None, + command=command, + now=now, + new_id=new_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( + "define_recipe.success", + command_name=_COMMAND_NAME, + recipe_id=str(new_id), + capability_id=str(command.capability_id), + 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/recipe/features/define_recipe/route.py b/apps/api/src/cora/recipe/features/define_recipe/route.py new file mode 100644 index 000000000..730a8b407 --- /dev/null +++ b/apps/api/src/cora/recipe/features/define_recipe/route.py @@ -0,0 +1,120 @@ +"""HTTP route for the `define_recipe` slice.""" + +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request, status +from pydantic import BaseModel, Field + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.recipe.aggregates.recipe import ( + RECIPE_NAME_MAX_LENGTH, + steps_from_dict, +) +from cora.recipe.features.define_recipe.command import DefineRecipe +from cora.recipe.features.define_recipe.handler import IdempotentHandler + + +class DefineRecipeRequest(BaseModel): + """Body for `POST /recipes`.""" + + name: str = Field( + ..., + min_length=1, + max_length=RECIPE_NAME_MAX_LENGTH, + description="Display name for the new Recipe.", + ) + capability_id: UUID = Field( + ..., + description=( + "Capability this Recipe realizes. REQUIRED and IMMUTABLE " + "across versions; re-binding requires authoring a new Recipe." + ), + ) + steps: dict[str, Any] = Field( + ..., + description=( + "Wire-format step sequence: `{steps: [{kind: setpoint|action|" + "check, ...}]}`. Each `value` or `params[k]` position may carry " + "`{__binding__: name}` to reference a Capability parameter." + ), + ) + + +class DefineRecipeResponse(BaseModel): + """Response body for `POST /recipes`.""" + + recipe_id: UUID + + +def _get_handler(request: Request) -> IdempotentHandler: + handler: IdempotentHandler = request.app.state.recipe.define_recipe + return handler + + +router = APIRouter(tags=["recipe"]) + + +@router.post( + "/recipes", + status_code=status.HTTP_201_CREATED, + response_model=DefineRecipeResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ("Domain invariant violated (whitespace-only name, empty steps)."), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "Referenced Capability does not exist.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation OR BindingRef in steps " + "references a parameter not declared in the Capability's " + "parameters_schema OR steps contain BindingRefs but the " + "Capability has no parameters_schema." + ), + }, + }, + summary="Define a new Recipe against an existing Capability", +) +async def post_recipes( + body: DefineRecipeRequest, + 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", + description=( + "Optional client-supplied unique key per logical request. " + "Retries with the same key + same body return the cached " + "response instead of re-creating the Recipe." + ), + ), + ] = None, +) -> DefineRecipeResponse: + recipe_id = await handler( + DefineRecipe( + name=body.name, + capability_id=body.capability_id, + steps=steps_from_dict(body.steps), + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + idempotency_key=idempotency_key, + ) + return DefineRecipeResponse(recipe_id=recipe_id) diff --git a/apps/api/src/cora/recipe/features/define_recipe/tool.py b/apps/api/src/cora/recipe/features/define_recipe/tool.py new file mode 100644 index 000000000..e4e17138f --- /dev/null +++ b/apps/api/src/cora/recipe/features/define_recipe/tool.py @@ -0,0 +1,79 @@ +"""MCP tool for the `define_recipe` 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.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.recipe.aggregates.recipe import RECIPE_NAME_MAX_LENGTH, steps_from_dict +from cora.recipe.features.define_recipe.command import DefineRecipe +from cora.recipe.features.define_recipe.handler import IdempotentHandler + + +class DefineRecipeOutput(BaseModel): + """Structured output of the `define_recipe` MCP tool.""" + + recipe_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> None: + """Register the `define_recipe` tool on the given MCP server.""" + + @mcp.tool( + name="define_recipe", + description=( + "Define a new Recipe against an existing Capability. Recipe " + "carries the templated step sequence the Operation BC Conductor " + "walks after operator-supplied parameter bindings are resolved " + "at register_procedure_from_recipe time. capability_id is " + "REQUIRED and IMMUTABLE across versions; every BindingRef.name " + "in steps must resolve in the referenced Capability's " + "parameters_schema." + ), + ) + async def define_recipe_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + name: Annotated[ + str, + Field( + min_length=1, + max_length=RECIPE_NAME_MAX_LENGTH, + description="Display name for the new Recipe.", + ), + ], + capability_id: Annotated[ + UUID, + Field( + description=( + "Capability this Recipe realizes. REQUIRED + IMMUTABLE across versions." + ), + ), + ], + steps: Annotated[ + dict[str, Any], + Field( + description=( + "Wire-format step sequence: `{steps: [{kind: setpoint|" + "action|check, ...}]}`. BindingRef sentinels carry " + "`{__binding__: name}` at parameterized positions." + ), + ), + ], + ) -> DefineRecipeOutput: + handler = get_handler() + recipe_id = await handler( + DefineRecipe( + name=name, + capability_id=capability_id, + steps=steps_from_dict(steps), + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return DefineRecipeOutput(recipe_id=recipe_id) diff --git a/apps/api/src/cora/recipe/features/deprecate_recipe/__init__.py b/apps/api/src/cora/recipe/features/deprecate_recipe/__init__.py new file mode 100644 index 000000000..8c3486890 --- /dev/null +++ b/apps/api/src/cora/recipe/features/deprecate_recipe/__init__.py @@ -0,0 +1,21 @@ +"""Slice: deprecate an existing Recipe. + +Vertical slice. Mirrors `deprecate_capability` shape; no +cross-aggregate fan-out (Recipe deprecation does not load the +referenced Capability). +""" + +from cora.recipe.features.deprecate_recipe import tool +from cora.recipe.features.deprecate_recipe.command import DeprecateRecipe +from cora.recipe.features.deprecate_recipe.decider import decide +from cora.recipe.features.deprecate_recipe.handler import Handler, bind +from cora.recipe.features.deprecate_recipe.route import router + +__all__ = [ + "DeprecateRecipe", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/recipe/features/deprecate_recipe/command.py b/apps/api/src/cora/recipe/features/deprecate_recipe/command.py new file mode 100644 index 000000000..155e94cae --- /dev/null +++ b/apps/api/src/cora/recipe/features/deprecate_recipe/command.py @@ -0,0 +1,23 @@ +"""The `DeprecateRecipe` command, intent dataclass for this slice. + +Multi-source transition: `Defined | Versioned -> Deprecated`. Carries +the target Recipe id + an optional `replaced_by_recipe_id` pointer +for the successor (LOINC `MAP_TO` precedent matching +`Capability.replaced_by_capability_id`). When None, this is +deprecated-without-replacement. + +Existing Procedures already expanded from the deprecated Recipe are +NOT automatically invalidated (advisory at BC layer per anti-hook 6 +of [[project-recipe-aggregate-design]]). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class DeprecateRecipe: + """Mark an existing Recipe as Deprecated, optionally pointing at a successor.""" + + recipe_id: UUID + replaced_by_recipe_id: UUID | None = None diff --git a/apps/api/src/cora/recipe/features/deprecate_recipe/decider.py b/apps/api/src/cora/recipe/features/deprecate_recipe/decider.py new file mode 100644 index 000000000..c14f28eb8 --- /dev/null +++ b/apps/api/src/cora/recipe/features/deprecate_recipe/decider.py @@ -0,0 +1,50 @@ +"""Pure decider for the `DeprecateRecipe` command. + +Multi-source-state transition: `Defined | Versioned -> Deprecated`. +Re-deprecating a Deprecated Recipe raises (strict-not-idempotent). + +`replaced_by_recipe_id` (when supplied) points at a successor Recipe. +Eventual-consistency: the target id is NOT verified cross-stream at +decider time (same precedent as `Capability.replaced_by_capability_id`). + +Invariants: + - State must not be None -> RecipeNotFoundError + - State.status must be in {Defined, Versioned} + -> RecipeCannotDeprecateError(current_status=...) +""" + +from datetime import datetime + +from cora.recipe.aggregates.recipe import ( + Recipe, + RecipeCannotDeprecateError, + RecipeDeprecated, + RecipeNotFoundError, + RecipeStatus, +) +from cora.recipe.features.deprecate_recipe.command import DeprecateRecipe + +_DEPRECATABLE_STATUSES: tuple[RecipeStatus, ...] = ( + RecipeStatus.DEFINED, + RecipeStatus.VERSIONED, +) + + +def decide( + state: Recipe | None, + command: DeprecateRecipe, + *, + now: datetime, +) -> list[RecipeDeprecated]: + """Decide the events produced by deprecating an existing Recipe.""" + if state is None: + raise RecipeNotFoundError(command.recipe_id) + if state.status not in _DEPRECATABLE_STATUSES: + raise RecipeCannotDeprecateError(state.id, current_status=state.status) + return [ + RecipeDeprecated( + recipe_id=state.id, + replaced_by_recipe_id=command.replaced_by_recipe_id, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/recipe/features/deprecate_recipe/handler.py b/apps/api/src/cora/recipe/features/deprecate_recipe/handler.py new file mode 100644 index 000000000..d151673de --- /dev/null +++ b/apps/api/src/cora/recipe/features/deprecate_recipe/handler.py @@ -0,0 +1,132 @@ +"""Application handler for the `deprecate_recipe` slice. + +Update-style handler shape: load Recipe stream + fold + decide + +append. No cross-aggregate fan-out (Recipe deprecation does not +inspect the referenced Capability). +""" + +from typing import Protocol +from uuid import UUID + +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.recipe.aggregates.recipe import ( + RecipeEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.recipe.errors import UnauthorizedError +from cora.recipe.features.deprecate_recipe.command import DeprecateRecipe +from cora.recipe.features.deprecate_recipe.decider import decide + +_STREAM_TYPE = "Recipe" +_COMMAND_NAME = "DeprecateRecipe" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every deprecate_recipe handler implements.""" + + async def __call__( + self, + command: DeprecateRecipe, + *, + 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 deprecate_recipe handler closed over the shared deps.""" + + async def handler( + command: DeprecateRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "deprecate_recipe.start", + command_name=_COMMAND_NAME, + recipe_id=str(command.recipe_id), + replaced_by_recipe_id=( + str(command.replaced_by_recipe_id) + if command.replaced_by_recipe_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( + "deprecate_recipe.denied", + command_name=_COMMAND_NAME, + recipe_id=str(command.recipe_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.recipe_id, + ) + history: list[RecipeEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + 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.recipe_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "deprecate_recipe.success", + command_name=_COMMAND_NAME, + recipe_id=str(command.recipe_id), + 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/recipe/features/deprecate_recipe/route.py b/apps/api/src/cora/recipe/features/deprecate_recipe/route.py new file mode 100644 index 000000000..c067a9e4c --- /dev/null +++ b/apps/api/src/cora/recipe/features/deprecate_recipe/route.py @@ -0,0 +1,81 @@ +"""HTTP route for the `deprecate_recipe` slice.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.recipe.features.deprecate_recipe.command import DeprecateRecipe +from cora.recipe.features.deprecate_recipe.handler import Handler + + +class DeprecateRecipeRequest(BaseModel): + """Body for `POST /recipes/{recipe_id}/deprecate`. + + Optional `replaced_by_recipe_id` pointer for the successor + Recipe. Omit entirely for deprecated-without-replacement. + """ + + replaced_by_recipe_id: UUID | None = Field( + default=None, + description=( + "Optional pointer to a successor Recipe (LOINC `MAP_TO` " + "precedent). None means deprecated-without-replacement." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.recipe.deprecate_recipe + return handler + + +router = APIRouter(tags=["recipe"]) + + +@router.post( + "/recipes/{recipe_id}/deprecate", + 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 Recipe exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "Recipe is already Deprecated.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter or request body failed schema validation.", + }, + }, + summary="Deprecate an existing Recipe", +) +async def post_recipes_deprecate( + recipe_id: Annotated[UUID, Path(description="Target Recipe's id.")], + body: DeprecateRecipeRequest, + 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( + DeprecateRecipe( + recipe_id=recipe_id, + replaced_by_recipe_id=body.replaced_by_recipe_id, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/recipe/features/deprecate_recipe/tool.py b/apps/api/src/cora/recipe/features/deprecate_recipe/tool.py new file mode 100644 index 000000000..75dd0089d --- /dev/null +++ b/apps/api/src/cora/recipe/features/deprecate_recipe/tool.py @@ -0,0 +1,56 @@ +"""MCP tool for the `deprecate_recipe` 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 Field + +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.recipe.features.deprecate_recipe.command import DeprecateRecipe +from cora.recipe.features.deprecate_recipe.handler import Handler + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `deprecate_recipe` tool on the given MCP server.""" + + @mcp.tool( + name="deprecate_recipe", + description=( + "Mark an existing Recipe as Deprecated. Multi-source: Defined " + "or Versioned to Deprecated. Existing Procedures already " + "expanded from the deprecated Recipe are NOT auto-invalidated " + "(advisory at BC layer). Optional replaced_by_recipe_id points " + "at a successor (LOINC MAP_TO precedent)." + ), + ) + async def deprecate_recipe_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + recipe_id: Annotated[ + UUID, + Field(description="Target Recipe's id."), + ], + replaced_by_recipe_id: Annotated[ + UUID | None, + Field( + default=None, + description=( + "Optional pointer to a successor Recipe. None means " + "deprecated-without-replacement." + ), + ), + ] = None, + ) -> None: + handler = get_handler() + await handler( + DeprecateRecipe( + recipe_id=recipe_id, + replaced_by_recipe_id=replaced_by_recipe_id, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) diff --git a/apps/api/src/cora/recipe/features/get_recipe/__init__.py b/apps/api/src/cora/recipe/features/get_recipe/__init__.py new file mode 100644 index 000000000..5fc3ad40b --- /dev/null +++ b/apps/api/src/cora/recipe/features/get_recipe/__init__.py @@ -0,0 +1,12 @@ +"""Slice: read the current state of a Recipe by id. + +Vertical slice. Mirrors `get_capability` shape (Path C: +projection-sourced lifecycle timestamps merged with aggregate state). +""" + +from cora.recipe.features.get_recipe import tool +from cora.recipe.features.get_recipe.handler import Handler, RecipeView, bind +from cora.recipe.features.get_recipe.query import GetRecipe +from cora.recipe.features.get_recipe.route import router + +__all__ = ["GetRecipe", "Handler", "RecipeView", "bind", "router", "tool"] diff --git a/apps/api/src/cora/recipe/features/get_recipe/handler.py b/apps/api/src/cora/recipe/features/get_recipe/handler.py new file mode 100644 index 000000000..48fb0ee7a --- /dev/null +++ b/apps/api/src/cora/recipe/features/get_recipe/handler.py @@ -0,0 +1,118 @@ +"""Application handler for the `get_recipe` query slice. + +Path C: handler returns RecipeView bundling aggregate state + +projection-sourced lifecycle timestamps. State stays minimal per +decider purity; timestamps live on the projection per the May-2026 +template-aggregate-timestamps sweep. Mirrors the pattern from +Capability / Method / Plan / Practice / Family. +""" + +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +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.recipe.aggregates.recipe import ( + Recipe, + RecipeLifecycleTimestamps, + load_recipe, + load_recipe_timestamps, +) +from cora.recipe.errors import UnauthorizedError +from cora.recipe.features.get_recipe.query import GetRecipe + +_QUERY_NAME = "GetRecipe" + +_log = get_logger(__name__) + + +@dataclass(frozen=True) +class RecipeView: + """Read-side bundle: aggregate state + projection-sourced lifecycle + timestamps. `timestamps` is None when the projection has not caught + up yet OR when the deps lack a configured pool (in-memory test + mode).""" + + recipe: Recipe + timestamps: RecipeLifecycleTimestamps | None + + +class Handler(Protocol): + """Callable interface every get_recipe handler implements.""" + + async def __call__( + self, + query: GetRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> RecipeView | None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a get_recipe handler closed over the shared deps.""" + + async def handler( + query: GetRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> RecipeView | None: + _log.info( + "get_recipe.start", + query_name=_QUERY_NAME, + recipe_id=str(query.recipe_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_QUERY_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "get_recipe.denied", + query_name=_QUERY_NAME, + recipe_id=str(query.recipe_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + recipe = await load_recipe(deps.event_store, query.recipe_id) + if recipe is None: + _log.info( + "get_recipe.success", + query_name=_QUERY_NAME, + recipe_id=str(query.recipe_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + found=False, + ) + return None + + timestamps: RecipeLifecycleTimestamps | None = None + if deps.pool is not None: + timestamps = await load_recipe_timestamps(deps.pool, query.recipe_id) + + _log.info( + "get_recipe.success", + query_name=_QUERY_NAME, + recipe_id=str(query.recipe_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + found=True, + timestamps_present=timestamps is not None, + ) + return RecipeView(recipe=recipe, timestamps=timestamps) + + return handler diff --git a/apps/api/src/cora/recipe/features/get_recipe/query.py b/apps/api/src/cora/recipe/features/get_recipe/query.py new file mode 100644 index 000000000..f6495a204 --- /dev/null +++ b/apps/api/src/cora/recipe/features/get_recipe/query.py @@ -0,0 +1,14 @@ +"""The `GetRecipe` query, intent dataclass for this read slice. + +Mirrors `GetCapability` / `GetMethod` / `GetPlan`. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class GetRecipe: + """Read the current state of an existing Recipe by id.""" + + recipe_id: UUID diff --git a/apps/api/src/cora/recipe/features/get_recipe/route.py b/apps/api/src/cora/recipe/features/get_recipe/route.py new file mode 100644 index 000000000..0b7dbb9f6 --- /dev/null +++ b/apps/api/src/cora/recipe/features/get_recipe/route.py @@ -0,0 +1,118 @@ +"""HTTP route for the `get_recipe` slice. + +`GET /recipes/{recipe_id}` returns 200 + RecipeResponse on hit, 404 +on miss. + +`created_at` / `versioned_at` / `deprecated_at` are sourced from +the `proj_recipe_recipe_summary` projection (Path C). Null semantics +under eventual consistency: read together with `status`. A 200 with +a populated `status` but null timestamp means projection lag, never +a missing transition. A 404 means the Recipe aggregate itself does +not exist. + +`steps` is exposed in wire format (the same `{steps: [{kind: ...}]}` +shape `define_recipe` / `version_recipe` accept on input), so +operators can inspect the templated body. `BindingRef` sentinels +serialize as `{__binding__: name}` per the standard wire format. +""" + +from datetime import datetime +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status +from pydantic import BaseModel, Field + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.recipe.aggregates.recipe import RECIPE_NAME_MAX_LENGTH, steps_to_dict +from cora.recipe.features.get_recipe.handler import Handler +from cora.recipe.features.get_recipe.query import GetRecipe + + +class RecipeResponse(BaseModel): + """Read-side DTO at the API boundary. + + Carries primitives, not domain VOs. `status` is the StrEnum's + string value (Defined / Versioned / Deprecated). `version` is + the operator-supplied label of the most recent `version_recipe` + call (null until first version). `steps` is the wire-format + dict (BindingRef sentinels serialize as `{__binding__: name}`). + `replaced_by_recipe_id` is null on Defined / Versioned / + Deprecated-without-replacement; populated when a deprecation + supplied a successor pointer. `created_at` / `versioned_at` / + `deprecated_at` are projection-sourced lifecycle timestamps + (Path C); see module docstring for null semantics. + """ + + id: UUID + name: str = Field(..., max_length=RECIPE_NAME_MAX_LENGTH) + capability_id: UUID + status: str + version: str | None + steps: dict[str, Any] + replaced_by_recipe_id: UUID | None + created_at: datetime | None = None + versioned_at: datetime | None = None + deprecated_at: datetime | None = None + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.recipe.get_recipe + return handler + + +router = APIRouter(tags=["recipe"]) + + +@router.get( + "/recipes/{recipe_id}", + status_code=status.HTTP_200_OK, + response_model=RecipeResponse, + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No Recipe exists with the given id.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter failed schema validation.", + }, + }, + summary="Get a Recipe by id", +) +async def get_recipes( + recipe_id: Annotated[UUID, Path(description="Target Recipe'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)], +) -> RecipeResponse: + view = await handler( + GetRecipe(recipe_id=recipe_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) + if view is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Recipe {recipe_id} not found", + ) + recipe = view.recipe + timestamps = view.timestamps + return RecipeResponse( + id=recipe.id, + name=recipe.name.value, + capability_id=recipe.capability_id, + status=recipe.status.value, + version=recipe.version, + steps=steps_to_dict(recipe.steps), + replaced_by_recipe_id=recipe.replaced_by_recipe_id, + created_at=timestamps.created_at if timestamps is not None else None, + versioned_at=timestamps.versioned_at if timestamps is not None else None, + deprecated_at=timestamps.deprecated_at if timestamps is not None else None, + ) diff --git a/apps/api/src/cora/recipe/features/get_recipe/tool.py b/apps/api/src/cora/recipe/features/get_recipe/tool.py new file mode 100644 index 000000000..e3a795ad3 --- /dev/null +++ b/apps/api/src/cora/recipe/features/get_recipe/tool.py @@ -0,0 +1,80 @@ +"""MCP tool for the `get_recipe` query slice.""" + +from collections.abc import Callable +from datetime import datetime +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +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.recipe.aggregates.recipe import RECIPE_NAME_MAX_LENGTH, steps_to_dict +from cora.recipe.features.get_recipe.handler import Handler +from cora.recipe.features.get_recipe.query import GetRecipe + + +class RecipeOutput(BaseModel): + """Structured output of the `get_recipe` MCP tool. + + `created_at` / `versioned_at` / `deprecated_at` mirror the REST + `RecipeResponse` (Path C): sourced from the + `proj_recipe_recipe_summary` projection. Null semantics: read + together with `status`. A populated `status` with a null timestamp + means the projection has not yet folded that lifecycle event, + never a missing transition. A not-found Recipe raises (MCP + `isError: true`) rather than returning null timestamps. + """ + + id: UUID + name: str = Field(..., max_length=RECIPE_NAME_MAX_LENGTH) + capability_id: UUID + status: str + version: str | None + steps: dict[str, Any] + replaced_by_recipe_id: UUID | None + created_at: datetime | None = None + versioned_at: datetime | None = None + deprecated_at: datetime | None = None + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `get_recipe` tool on the given MCP server.""" + + @mcp.tool( + name="get_recipe", + description="Read the current state of an existing Recipe by id.", + ) + async def get_recipe_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + recipe_id: Annotated[ + UUID, + Field(description="Target Recipe's id."), + ], + ) -> RecipeOutput: + handler = get_handler() + view = await handler( + GetRecipe(recipe_id=recipe_id), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + if view is None: + msg = f"Recipe {recipe_id} not found" + raise ValueError(msg) + recipe = view.recipe + timestamps = view.timestamps + return RecipeOutput( + id=recipe.id, + name=recipe.name.value, + capability_id=recipe.capability_id, + status=recipe.status.value, + version=recipe.version, + steps=steps_to_dict(recipe.steps), + replaced_by_recipe_id=recipe.replaced_by_recipe_id, + created_at=timestamps.created_at if timestamps is not None else None, + versioned_at=timestamps.versioned_at if timestamps is not None else None, + deprecated_at=timestamps.deprecated_at if timestamps is not None else None, + ) diff --git a/apps/api/src/cora/recipe/features/version_recipe/__init__.py b/apps/api/src/cora/recipe/features/version_recipe/__init__.py new file mode 100644 index 000000000..19f38cda8 --- /dev/null +++ b/apps/api/src/cora/recipe/features/version_recipe/__init__.py @@ -0,0 +1,24 @@ +"""Slice: issue a new version label + replacement steps for an existing Recipe. + +Vertical slice. Mirrors `version_capability` plus a cross-aggregate +BindingRef re-validation against the CURRENT Capability state at +write time (per anti-hook 5 of +[[project-recipe-aggregate-design]]: the same validator that fires +at define_recipe time fires again here, closing the operator-side +half of the Capability-re-version race). +""" + +from cora.recipe.features.version_recipe import tool +from cora.recipe.features.version_recipe.command import VersionRecipe +from cora.recipe.features.version_recipe.decider import decide +from cora.recipe.features.version_recipe.handler import Handler, bind +from cora.recipe.features.version_recipe.route import router + +__all__ = [ + "Handler", + "VersionRecipe", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/recipe/features/version_recipe/command.py b/apps/api/src/cora/recipe/features/version_recipe/command.py new file mode 100644 index 000000000..2390b3464 --- /dev/null +++ b/apps/api/src/cora/recipe/features/version_recipe/command.py @@ -0,0 +1,26 @@ +"""The `VersionRecipe` command, intent dataclass for this slice. + +Multi-source transition: `Defined | Versioned -> Versioned`. The +supplied `steps` REPLACE the prior wholesale (a new version IS a new +declaration; Pattern P; matches Method/Plan/Practice/Family / +Capability replace-on-version precedent). + +`capability_id` is NOT part of this command; it's PRESERVED from the +prior Recipe state (immutable across versions per anti-hook 3 of +[[project-recipe-aggregate-design]]). Re-binding to a different +Capability requires authoring a new Recipe. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.recipe.aggregates.recipe import RecipeStep + + +@dataclass(frozen=True) +class VersionRecipe: + """Issue a new version label + replacement steps for an existing Recipe.""" + + recipe_id: UUID + version_tag: str + steps: tuple[RecipeStep, ...] diff --git a/apps/api/src/cora/recipe/features/version_recipe/decider.py b/apps/api/src/cora/recipe/features/version_recipe/decider.py new file mode 100644 index 000000000..0d7eeffb8 --- /dev/null +++ b/apps/api/src/cora/recipe/features/version_recipe/decider.py @@ -0,0 +1,72 @@ +"""Pure decider for the `VersionRecipe` command. + +Multi-source-state transition: `Defined | Versioned -> Versioned`. +Both Defined (first revision) and Versioned (subsequent revisions) +are valid sources; only Deprecated is rejected. + +Re-attestation: calling version_recipe with the same version_tag + +same steps both succeed and emit a `RecipeVersioned` event each +time. Re-attestation is a legitimate audit moment ("the operator +re-confirmed v2 on date X"); the multi-source Versioned -> Versioned +transition permits the operation structurally. Same precedent as +`version_capability` / `version_method`. + +Invariants: + - State must not be None -> RecipeNotFoundError + - command.version_tag must be 1-50 chars after trimming + -> InvalidRecipeVersionTagError + - command.steps must be non-empty (re-asserted via Recipe construction) + -> EmptyRecipeStepsError + - State.status must be in {Defined, Versioned} + -> RecipeCannotVersionError(current_status=...) +""" + +from datetime import datetime + +from cora.recipe.aggregates.recipe import ( + RECIPE_VERSION_TAG_MAX_LENGTH, + InvalidRecipeVersionTagError, + Recipe, + RecipeCannotVersionError, + RecipeNotFoundError, + RecipeStatus, + RecipeVersioned, +) +from cora.recipe.features.version_recipe.command import VersionRecipe + +_VERSIONABLE_STATUSES: tuple[RecipeStatus, ...] = ( + RecipeStatus.DEFINED, + RecipeStatus.VERSIONED, +) + + +def decide( + state: Recipe | None, + command: VersionRecipe, + *, + now: datetime, +) -> list[RecipeVersioned]: + """Decide the events produced by versioning an existing Recipe.""" + if state is None: + raise RecipeNotFoundError(command.recipe_id) + trimmed = command.version_tag.strip() + if not trimmed or len(trimmed) > RECIPE_VERSION_TAG_MAX_LENGTH: + raise InvalidRecipeVersionTagError(command.version_tag) + if state.status not in _VERSIONABLE_STATUSES: + raise RecipeCannotVersionError(state.id, current_status=state.status) + # Re-assert the non-empty-steps invariant via Recipe construction + # before any event is emitted. + Recipe( + id=state.id, + name=state.name, + capability_id=state.capability_id, + steps=command.steps, + ) + return [ + RecipeVersioned( + recipe_id=state.id, + version_tag=trimmed, + steps=command.steps, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/recipe/features/version_recipe/handler.py b/apps/api/src/cora/recipe/features/version_recipe/handler.py new file mode 100644 index 000000000..4ddcb3d46 --- /dev/null +++ b/apps/api/src/cora/recipe/features/version_recipe/handler.py @@ -0,0 +1,153 @@ +"""Application handler for the `version_recipe` slice. + +Update-style handler shape: load Recipe stream + fold, load the +referenced Capability cross-aggregate, re-validate BindingRef +integrity against the CURRENT Capability.parameters_schema, then +decide + append. NOT idempotency-wrapped: re-versioning emits a +duplicate event per `version_capability` / `version_method` +precedent (re-attestation is the audit signal). + +The cross-aggregate re-validation closes the operator-side half of +the Capability-re-version race per anti-hook 5 of +[[project-recipe-aggregate-design]]: if the Capability has been +versioned after the Recipe's last write and a binding name dropped, +this slice rejects with `RecipeBindingReferencesUnknownParameterError` +or `RecipeRequiresCapabilityParametersSchemaError`. +""" + +from typing import Protocol +from uuid import UUID + +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.recipe.aggregates.capability import ( + CapabilityNotFoundError, + load_capability, +) +from cora.recipe.aggregates.recipe import ( + RecipeEvent, + RecipeNotFoundError, + event_type_name, + fold, + from_stored, + to_payload, + validate_recipe_steps_against_capability_schema, +) +from cora.recipe.errors import UnauthorizedError +from cora.recipe.features.version_recipe.command import VersionRecipe +from cora.recipe.features.version_recipe.decider import decide + +_STREAM_TYPE = "Recipe" +_COMMAND_NAME = "VersionRecipe" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every version_recipe handler implements.""" + + async def __call__( + self, + command: VersionRecipe, + *, + 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 version_recipe handler closed over the shared deps.""" + + async def handler( + command: VersionRecipe, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "version_recipe.start", + command_name=_COMMAND_NAME, + recipe_id=str(command.recipe_id), + version_tag=command.version_tag, + 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( + "version_recipe.denied", + command_name=_COMMAND_NAME, + recipe_id=str(command.recipe_id), + version_tag=command.version_tag, + 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.recipe_id, + ) + history: list[RecipeEvent] = [from_stored(s) for s in stored] + state = fold(history) + if state is None: + raise RecipeNotFoundError(command.recipe_id) + + capability = await load_capability(deps.event_store, state.capability_id) + if capability is None: + raise CapabilityNotFoundError(state.capability_id) + validate_recipe_steps_against_capability_schema(command.steps, capability.parameters_schema) + + domain_events = decide(state=state, command=command, now=now) + + 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.recipe_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "version_recipe.success", + command_name=_COMMAND_NAME, + recipe_id=str(command.recipe_id), + version_tag=command.version_tag, + 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/recipe/features/version_recipe/route.py b/apps/api/src/cora/recipe/features/version_recipe/route.py new file mode 100644 index 000000000..7d39b596d --- /dev/null +++ b/apps/api/src/cora/recipe/features/version_recipe/route.py @@ -0,0 +1,106 @@ +"""HTTP route for the `version_recipe` slice.""" + +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.recipe.aggregates.recipe import ( + RECIPE_VERSION_TAG_MAX_LENGTH, + steps_from_dict, +) +from cora.recipe.features.version_recipe.command import VersionRecipe +from cora.recipe.features.version_recipe.handler import Handler + + +class VersionRecipeRequest(BaseModel): + """Body for `POST /recipes/{recipe_id}/version`.""" + + version_tag: str = Field( + ..., + min_length=1, + max_length=RECIPE_VERSION_TAG_MAX_LENGTH, + description=( + "Operator-supplied label for this revision (for example " + "'v2', '2026-Q3'). Free text; institution-specific. NOT " + "constrained UNIQUE across versions; same tag + same steps " + "re-emits the event as a re-attestation audit signal." + ), + ) + steps: dict[str, Any] = Field( + ..., + description=( + "Replacement step sequence for the new version (wholesale " + "replace; the prior steps are dropped). BindingRef sentinels " + "are re-validated against the CURRENT Capability.parameters_schema." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.recipe.version_recipe + return handler + + +router = APIRouter(tags=["recipe"]) + + +@router.post( + "/recipes/{recipe_id}/version", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated (whitespace-only version_tag, empty steps)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": ( + "No Recipe exists with the given id OR referenced Capability does not exist." + ), + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "Recipe is currently Deprecated.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Path parameter or request body failed schema validation OR " + "BindingRef in steps references a parameter not declared " + "in the current Capability.parameters_schema." + ), + }, + }, + summary="Issue a new version label + replacement steps for a Recipe", +) +async def post_recipes_version( + recipe_id: Annotated[UUID, Path(description="Target Recipe's id.")], + body: VersionRecipeRequest, + 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( + VersionRecipe( + recipe_id=recipe_id, + version_tag=body.version_tag, + steps=steps_from_dict(body.steps), + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/recipe/features/version_recipe/tool.py b/apps/api/src/cora/recipe/features/version_recipe/tool.py new file mode 100644 index 000000000..8dc2c6da6 --- /dev/null +++ b/apps/api/src/cora/recipe/features/version_recipe/tool.py @@ -0,0 +1,78 @@ +"""MCP tool for the `version_recipe` 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.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.recipe.aggregates.recipe import ( + RECIPE_VERSION_TAG_MAX_LENGTH, + steps_from_dict, +) +from cora.recipe.features.version_recipe.command import VersionRecipe +from cora.recipe.features.version_recipe.handler import Handler + + +class VersionRecipeOutput(BaseModel): + """Structured output of the `version_recipe` MCP tool.""" + + recipe_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `version_recipe` tool on the given MCP server.""" + + @mcp.tool( + name="version_recipe", + description=( + "Issue a new version label + replacement steps for an existing " + "Recipe. capability_id is PRESERVED from the prior Recipe state " + "(immutable across versions); steps replace wholesale. BindingRef " + "integrity is re-validated against the CURRENT Capability schema " + "to catch any Capability-re-version drift since the Recipe's " + "last write." + ), + ) + async def version_recipe_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + recipe_id: Annotated[UUID, Field(description="Target Recipe's id.")], + version_tag: Annotated[ + str, + Field( + min_length=1, + max_length=RECIPE_VERSION_TAG_MAX_LENGTH, + description=( + "Operator-supplied label (for example 'v2', '2026-Q3'). " + "NOT UNIQUE across versions; same tag + same steps " + "re-emits as a re-attestation audit signal." + ), + ), + ], + steps: Annotated[ + dict[str, Any], + Field( + description=( + "Wire-format replacement step sequence: `{steps: [{kind: " + "setpoint|action|check, ...}]}`. Wholesale replace; prior " + "steps are dropped." + ), + ), + ], + ) -> VersionRecipeOutput: + handler = get_handler() + await handler( + VersionRecipe( + recipe_id=recipe_id, + version_tag=version_tag, + steps=steps_from_dict(steps), + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return VersionRecipeOutput(recipe_id=recipe_id) diff --git a/apps/api/src/cora/recipe/projections/__init__.py b/apps/api/src/cora/recipe/projections/__init__.py index 935b23dd8..952d58370 100644 --- a/apps/api/src/cora/recipe/projections/__init__.py +++ b/apps/api/src/cora/recipe/projections/__init__.py @@ -11,10 +11,12 @@ from cora.recipe.projections.method import MethodSummaryProjection from cora.recipe.projections.plan import PlanSummaryProjection from cora.recipe.projections.practice import PracticeSummaryProjection +from cora.recipe.projections.recipe import RecipeSummaryProjection __all__ = [ "CapabilitySummaryProjection", "MethodSummaryProjection", "PlanSummaryProjection", "PracticeSummaryProjection", + "RecipeSummaryProjection", ] diff --git a/apps/api/src/cora/recipe/projections/recipe.py b/apps/api/src/cora/recipe/projections/recipe.py new file mode 100644 index 000000000..4f5d3152b --- /dev/null +++ b/apps/api/src/cora/recipe/projections/recipe.py @@ -0,0 +1,125 @@ +"""RecipeSummaryProjection: folds the Recipe aggregate's lifecycle +events into `proj_recipe_recipe_summary`. + +Subscribed events: + - RecipeDefined -> INSERT (status=Defined, version=NULL, + replaced_by_recipe_id=NULL, + steps_count from payload) + - RecipeVersioned -> UPDATE status=Versioned + version_tag + + REFRESH steps_count + (a new version IS a new declaration; + steps replace wholesale) + - RecipeDeprecated -> UPDATE status=Deprecated + + replaced_by_recipe_id + (steps + capability_id PRESERVED for audit) + +All branches idempotent. `version_tag` lands ONLY on Versioned +(Defined INSERT leaves it NULL and Deprecated UPDATE doesn't touch +it). `steps_count` is the denormalized number of `RecipeStep`s in +the latest event's payload; the steps themselves live in the event +stream per [[project-pg-smart-logic-observation]] to keep the summary +table small. +""" + +# 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_RECIPE_SQL = """ +INSERT INTO proj_recipe_recipe_summary + (recipe_id, name, capability_id, status, version_tag, + steps_count, replaced_by_recipe_id, created_at) +VALUES ($1, $2, $3, 'Defined', NULL, $4, NULL, $5) +ON CONFLICT (recipe_id) DO NOTHING +""" + +_UPDATE_VERSIONED_SQL = """ +UPDATE proj_recipe_recipe_summary +SET status = 'Versioned', + version_tag = $2, + steps_count = $3, + versioned_at = $4, + updated_at = now() +WHERE recipe_id = $1 +""" + +_UPDATE_DEPRECATED_SQL = """ +UPDATE proj_recipe_recipe_summary +SET status = 'Deprecated', + replaced_by_recipe_id = $2, + deprecated_at = $3, + updated_at = now() +WHERE recipe_id = $1 +""" + + +def _steps_count(payload: dict[str, object]) -> int: + """Count the entries in the payload's wire-format `{steps: {steps: [...]}}`. + + The `body.to_dict` wrapper nests the step list one level deep so the + JSON shape stays explicit. Defensive: returns 0 if the shape is + malformed (projection never raises on a single bad event). + """ + outer = payload.get("steps") + if not isinstance(outer, dict): + return 0 + inner = outer.get("steps") + if not isinstance(inner, list): + return 0 + return len(inner) + + +class RecipeSummaryProjection: + """Maintains the `proj_recipe_recipe_summary` read model.""" + + name = "proj_recipe_recipe_summary" + subscribed_event_types = frozenset( + { + "RecipeDefined", + "RecipeVersioned", + "RecipeDeprecated", + } + ) + + async def apply( + self, + event: StoredEvent, + conn: ConnectionLike, + ) -> None: + match event.event_type: + case "RecipeDefined": + await conn.execute( + _INSERT_RECIPE_SQL, + UUID(event.payload["recipe_id"]), + event.payload["name"], + UUID(event.payload["capability_id"]), + _steps_count(event.payload), + datetime.fromisoformat(event.payload["occurred_at"]), + ) + case "RecipeVersioned": + await conn.execute( + _UPDATE_VERSIONED_SQL, + UUID(event.payload["recipe_id"]), + event.payload["version_tag"], + _steps_count(event.payload), + datetime.fromisoformat(event.payload["occurred_at"]), + ) + case "RecipeDeprecated": + raw_replaced = event.payload.get("replaced_by_recipe_id") + replaced = UUID(raw_replaced) if raw_replaced is not None else None + await conn.execute( + _UPDATE_DEPRECATED_SQL, + UUID(event.payload["recipe_id"]), + replaced, + datetime.fromisoformat(event.payload["occurred_at"]), + ) + case _: + # Not in our subscription set; defensive no-op. + return + + +__all__ = ["RecipeSummaryProjection"] diff --git a/apps/api/src/cora/recipe/routes.py b/apps/api/src/cora/recipe/routes.py index ec4113215..98698bd0f 100644 --- a/apps/api/src/cora/recipe/routes.py +++ b/apps/api/src/cora/recipe/routes.py @@ -86,6 +86,20 @@ PracticeCannotVersionError, PracticeNotFoundError, ) +from cora.recipe.aggregates.recipe import ( + EmptyRecipeStepsError, + InvalidRecipeNameError, + InvalidRecipeStepShapeError, + InvalidRecipeVersionTagError, + RecipeAlreadyExistsError, + RecipeBindingReferencesUnknownParameterError, + RecipeCannotDeprecateError, + RecipeCannotVersionError, + RecipeNotFoundError, + RecipeRequiresCapabilityParametersSchemaError, + RecipeVersionNotFoundError, + UnboundRecipeBindingError, +) from cora.recipe.errors import UnauthorizedError from cora.recipe.features import ( add_plan_wire, @@ -93,14 +107,17 @@ define_method, define_plan, define_practice, + define_recipe, deprecate_capability, deprecate_method, deprecate_plan, deprecate_practice, + deprecate_recipe, get_capability, get_method, get_plan, get_practice, + get_recipe, inspect_plan_binding, list_methods, list_plans, @@ -112,6 +129,7 @@ version_method, version_plan, version_practice, + version_recipe, ) @@ -171,6 +189,23 @@ async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONRes ) +async def _handle_unprocessable(request: Request, exc: Exception) -> JSONResponse: + """Shared 422 handler for parse-shape failures past the Pydantic boundary. + + Covers Recipe BindingRef-against-schema mismatches, malformed step + shapes, and unbound BindingRefs at expansion time. The 400 + Invalid family is reserved for VO constructor failures + (name / version_tag); 422 is reserved for downstream parse-shape + or schema-cross-check failures that pass Pydantic but fail at the + cross-aggregate boundary. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content={"detail": str(exc)}, + ) + + def register_recipe_routes(app: FastAPI) -> None: """Attach Recipe slice routers and exception handlers to the FastAPI app.""" app.include_router(define_method.router) @@ -196,6 +231,10 @@ def register_recipe_routes(app: FastAPI) -> None: app.include_router(version_capability.router) app.include_router(deprecate_capability.router) app.include_router(get_capability.router) + app.include_router(define_recipe.router) + app.include_router(version_recipe.router) + app.include_router(deprecate_recipe.router) + app.include_router(get_recipe.router) app.include_router(inspect_plan_binding.router) for validation_cls in ( InvalidCapabilityCodeError, @@ -215,6 +254,12 @@ def register_recipe_routes(app: FastAPI) -> None: InvalidPlanDefaultParametersError, InvalidPlanVersionTagError, InvalidWireError, + # Recipe Invalid name + version-tag VO constructor failures. + InvalidRecipeNameError, + InvalidRecipeVersionTagError, + # Recipe __post_init__ invariant; domain error, not a Pydantic + # boundary parse failure. + EmptyRecipeStepsError, ): app.add_exception_handler(validation_cls, _handle_validation_error) for not_found_cls in ( @@ -225,6 +270,8 @@ def register_recipe_routes(app: FastAPI) -> None: # 6h: removing a Wire that's not currently in the Plan's wire # set (strict-not-idempotent symmetry with PlanWireAlreadyExistsError). PlanWireNotFoundError, + RecipeNotFoundError, + RecipeVersionNotFoundError, ): app.add_exception_handler(not_found_cls, _handle_not_found) for already_exists_cls in ( @@ -235,6 +282,7 @@ def register_recipe_routes(app: FastAPI) -> None: # 6h: re-adding an already-present Wire (strict-not-idempotent; # mirrors 5h add_asset_port). PlanWireAlreadyExistsError, + RecipeAlreadyExistsError, ): app.add_exception_handler(already_exists_cls, _handle_already_exists) for cannot_transition_cls in ( @@ -271,6 +319,22 @@ def register_recipe_routes(app: FastAPI) -> None: PlanWireDirectionMismatchError, PlanWireSignalTypeMismatchError, PlanWireSelfLoopError, + # Recipe transition guards. + RecipeCannotVersionError, + RecipeCannotDeprecateError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) + for unprocessable_cls in ( + # Recipe parse-shape / schema-cross-check failures past the + # Pydantic boundary. Distinct from the Invalid 400 family + # because these fire AFTER the request body validates: the + # wire-format step shape, the BindingRef-vs-Capability-schema + # cross-aggregate check, the missing-schema-with-bindings case, + # and the unbound-binding-at-expansion case. + InvalidRecipeStepShapeError, + RecipeBindingReferencesUnknownParameterError, + RecipeRequiresCapabilityParametersSchemaError, + UnboundRecipeBindingError, + ): + app.add_exception_handler(unprocessable_cls, _handle_unprocessable) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/recipe/tools.py b/apps/api/src/cora/recipe/tools.py index 39db01b2d..95d154e28 100644 --- a/apps/api/src/cora/recipe/tools.py +++ b/apps/api/src/cora/recipe/tools.py @@ -16,14 +16,17 @@ from cora.recipe.features.define_method import tool as define_method_tool from cora.recipe.features.define_plan import tool as define_plan_tool from cora.recipe.features.define_practice import tool as define_practice_tool +from cora.recipe.features.define_recipe import tool as define_recipe_tool from cora.recipe.features.deprecate_capability import tool as deprecate_capability_tool from cora.recipe.features.deprecate_method import tool as deprecate_method_tool from cora.recipe.features.deprecate_plan import tool as deprecate_plan_tool from cora.recipe.features.deprecate_practice import tool as deprecate_practice_tool +from cora.recipe.features.deprecate_recipe import tool as deprecate_recipe_tool from cora.recipe.features.get_capability import tool as get_capability_tool from cora.recipe.features.get_method import tool as get_method_tool from cora.recipe.features.get_plan import tool as get_plan_tool from cora.recipe.features.get_practice import tool as get_practice_tool +from cora.recipe.features.get_recipe import tool as get_recipe_tool from cora.recipe.features.inspect_plan_binding import tool as inspect_plan_binding_tool from cora.recipe.features.list_methods import tool as list_methods_tool from cora.recipe.features.list_plans import tool as list_plans_tool @@ -39,6 +42,7 @@ from cora.recipe.features.version_method import tool as version_method_tool from cora.recipe.features.version_plan import tool as version_plan_tool from cora.recipe.features.version_practice import tool as version_practice_tool +from cora.recipe.features.version_recipe import tool as version_recipe_tool from cora.recipe.wire import RecipeHandlers @@ -140,6 +144,22 @@ def register_recipe_tools( mcp, get_handler=lambda: get_handlers().get_capability, ) + define_recipe_tool.register( + mcp, + get_handler=lambda: get_handlers().define_recipe, + ) + version_recipe_tool.register( + mcp, + get_handler=lambda: get_handlers().version_recipe, + ) + deprecate_recipe_tool.register( + mcp, + get_handler=lambda: get_handlers().deprecate_recipe, + ) + get_recipe_tool.register( + mcp, + get_handler=lambda: get_handlers().get_recipe, + ) inspect_plan_binding_tool.register( mcp, get_handler=lambda: get_handlers().inspect_plan_binding, diff --git a/apps/api/src/cora/recipe/wire.py b/apps/api/src/cora/recipe/wire.py index df10e883a..3792550c4 100644 --- a/apps/api/src/cora/recipe/wire.py +++ b/apps/api/src/cora/recipe/wire.py @@ -16,10 +16,12 @@ 3. `with_tracing` — OTel span around every handler call. Records `cora.bc`, `cora.command` / `cora.query` attributes. -The BC currently owns four aggregates: `Method` (the technique -contract), `Practice` (a Method adaptation), `Plan` (an executable -Recipe binding Practices to Assets), and `Capability` (the universal -template that Methods and Procedures realize as executors). +The BC owns five aggregates: `Method` (the technique contract), +`Practice` (a Method adaptation), `Plan` (an executable binding of +Practices to Assets), `Capability` (the universal declarative +template Methods and Procedures realize as executors), and `Recipe` +(the deployment-bound templated step sequence that expands to a +flat Step list at register_procedure_from_recipe time). """ from dataclasses import dataclass @@ -34,14 +36,17 @@ define_method, define_plan, define_practice, + define_recipe, deprecate_capability, deprecate_method, deprecate_plan, deprecate_practice, + deprecate_recipe, get_capability, get_method, get_plan, get_practice, + get_recipe, inspect_plan_binding, list_methods, list_plans, @@ -53,6 +58,7 @@ version_method, version_plan, version_practice, + version_recipe, ) _BC = "recipe" @@ -85,6 +91,10 @@ class RecipeHandlers: version_capability: version_capability.Handler deprecate_capability: deprecate_capability.Handler get_capability: get_capability.Handler + define_recipe: define_recipe.IdempotentHandler + version_recipe: version_recipe.Handler + deprecate_recipe: deprecate_recipe.Handler + get_recipe: get_recipe.Handler inspect_plan_binding: inspect_plan_binding.Handler @@ -243,6 +253,34 @@ def wire_recipe(deps: Kernel) -> RecipeHandlers: bc=_BC, kind="query", ), + define_recipe=with_tracing( + with_idempotency( + define_recipe.bind(deps), + deps.idempotency_store, + command_name="DefineRecipe", + serialize_result=str, + deserialize_result=UUID, + lock_stale_seconds=deps.settings.idempotency_lock_stale_seconds, + ), + command_name="DefineRecipe", + bc=_BC, + ), + version_recipe=with_tracing( + version_recipe.bind(deps), + command_name="VersionRecipe", + bc=_BC, + ), + deprecate_recipe=with_tracing( + deprecate_recipe.bind(deps), + command_name="DeprecateRecipe", + bc=_BC, + ), + get_recipe=with_tracing( + get_recipe.bind(deps), + command_name="GetRecipe", + bc=_BC, + kind="query", + ), inspect_plan_binding=with_tracing( inspect_plan_binding.bind(deps), command_name="InspectPlanBinding", diff --git a/apps/api/tests/architecture/test_canonical_json_bytes_single_source.py b/apps/api/tests/architecture/test_canonical_json_bytes_single_source.py new file mode 100644 index 000000000..9dd861e7c --- /dev/null +++ b/apps/api/tests/architecture/test_canonical_json_bytes_single_source.py @@ -0,0 +1,117 @@ +"""Single-source canonicalizer fitness. + +Per [[project-run-procedure-replay-design]] §Canonical-JSON +consolidation + §Locks. had three copies of the inline +`json.dumps(..., sort_keys=True, separators=(",", ":"))` string +(decider's bindings/steps hashing, events.py to_payload arm, and the +contract test). consolidated to one source: +`cora.infrastructure.canonical_json.canonical_json_bytes`. + +This fitness AST-walks `tracked_python_files()` (per +[[feedback-architecture-test-git-aware]]) under the operation and +recipe BC source trees only, and asserts every `json.dumps` Call +node carrying `sort_keys=True` lives in the single allowlisted file +(`canonical_json.py` itself). Pre-existing co-occurrences in +`infrastructure/content_hash.py`, `infrastructure/idempotency.py`, +and the integration test stay out of scope because they +canonicalize for orthogonal purposes (content-addressed identity + +idempotency keys); promoting them to canonical_json_bytes is a +future rule-of-three hoist, not a lock. + +A future refactor that inlines `json.dumps(sort_keys=True)` anywhere +under cora/operation or cora/recipe fails this test and is steered +back to `canonical_json_bytes`. +""" + +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING + +import pytest + +from tests.architecture.conftest import CORA_ROOT, tracked_python_files + +if TYPE_CHECKING: + from pathlib import Path + +# Source trees this fitness governs. Other BC trees + infrastructure + +# tests carry their own canonicalizers (deliberately untouched in +# ; rule-of-three deferred). Restrict the AST walk so the +# scope of the lock is unambiguous. +_SCOPED_TREES = ( + CORA_ROOT / "operation", + CORA_ROOT / "recipe", +) + +# The single file allowed to construct `json.dumps(..., sort_keys=True)` +# in scope. `canonical_json_bytes` is hoisted to infrastructure (NOT in +# scope here); both `cora/operation/` and `cora/recipe/` import it from +# there. If a future module legitimately needs to extend the +# canonicalizer (e.g., add a `decimal=str` mode), add it here AND in +# the comment block at the top of canonical_json.py. +# No source file under cora/operation or cora/recipe is allowed to call +# json.dumps with sort_keys=True directly. The allowlist is intentionally +# empty: the helper lives at cora.infrastructure.canonical_json (out of +# scope of this fitness's tree filter). +_ALLOWLIST_RELATIVE_PATHS: frozenset[Path] = frozenset() + + +def _scoped_files() -> list[Path]: + files: list[Path] = [] + for path in tracked_python_files(): + if any(path.is_relative_to(tree) for tree in _SCOPED_TREES): + files.append(path) + return sorted(files) + + +def _json_dumps_sort_keys_lines(path: Path) -> list[int]: + tree = ast.parse(path.read_text()) + hits: list[int] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + # Match both `json.dumps(...)` and bare `dumps(...)` (if someone + # ever `from json import dumps`d into the scoped trees). + is_json_dumps = ( + isinstance(func, ast.Attribute) + and func.attr == "dumps" + and isinstance(func.value, ast.Name) + and func.value.id == "json" + ) or (isinstance(func, ast.Name) and func.id == "dumps") + if not is_json_dumps: + continue + for kw in node.keywords: + if ( + kw.arg == "sort_keys" + and isinstance(kw.value, ast.Constant) + and kw.value.value is True + ): + hits.append(node.lineno) + break + return hits + + +@pytest.mark.architecture +def test_canonical_json_bytes_is_the_single_source_in_operation_and_recipe_trees() -> None: + """No source file under `cora/operation/` or `cora/recipe/` may + invoke `json.dumps(..., sort_keys=True, ...)` directly: route all + canonical-JSON byte production through + `cora.infrastructure.canonical_json.canonical_json_bytes`.""" + violations: list[str] = [] + for path in _scoped_files(): + relative = path.relative_to(CORA_ROOT) + if relative in _ALLOWLIST_RELATIVE_PATHS: + continue + lines = _json_dumps_sort_keys_lines(path) + for line in lines: + violations.append(f" {relative}:{line}") + assert not violations, ( + "Inline `json.dumps(..., sort_keys=True)` co-occurrence found in " + "the operation/recipe BC source trees; route the call through " + "`cora.infrastructure.canonical_json.canonical_json_bytes` so " + "hash bytes stay byte-equal across write-time and replay-time. " + "See [[project-run-procedure-replay-design]] §Canonical-JSON " + "consolidation. Offenders:\n" + "\n".join(violations) + ) diff --git a/apps/api/tests/architecture/test_decider_changes_require_paired_pbt.py b/apps/api/tests/architecture/test_decider_changes_require_paired_pbt.py index ec54f95a7..b394826ff 100644 --- a/apps/api/tests/architecture/test_decider_changes_require_paired_pbt.py +++ b/apps/api/tests/architecture/test_decider_changes_require_paired_pbt.py @@ -126,6 +126,7 @@ "cora.federation.features.suspend_permit.decider", "cora.operation.features.abort_procedure.decider", "cora.operation.features.complete_procedure.decider", + "cora.operation.features.register_procedure_from_recipe.decider", "cora.operation.features.start_procedure.decider", "cora.operation.features.truncate_procedure.decider", "cora.recipe.features.add_plan_wire.decider", @@ -136,6 +137,7 @@ "cora.recipe.features.deprecate_method.decider", "cora.recipe.features.deprecate_plan.decider", "cora.recipe.features.deprecate_practice.decider", + "cora.recipe.features.deprecate_recipe.decider", "cora.recipe.features.remove_plan_wire.decider", "cora.recipe.features.update_method_parameters_schema.decider", "cora.recipe.features.update_plan_default_parameters.decider", @@ -143,6 +145,7 @@ "cora.recipe.features.version_method.decider", "cora.recipe.features.version_plan.decider", "cora.recipe.features.version_practice.decider", + "cora.recipe.features.version_recipe.decider", "cora.run.features.abort_run.decider", "cora.run.features.adjust_run.decider", "cora.run.features.complete_run.decider", diff --git a/apps/api/tests/architecture/test_http_422_handler_registered.py b/apps/api/tests/architecture/test_http_422_handler_registered.py new file mode 100644 index 000000000..702da3ba6 --- /dev/null +++ b/apps/api/tests/architecture/test_http_422_handler_registered.py @@ -0,0 +1,96 @@ +"""Both Recipe BC and Operation BC routes files must register a 422 exception handler. + +Six Recipe rejection classes (per [[project-recipe-aggregate-design]] Rejections) +map to HTTP 422 (parse-shape / schema-cross-check failures past the Pydantic +boundary). Without an explicit `_handle_unprocessable` handler the unmapped +raise silently falls through to the default 500. This fitness pins the +handler registration so a future routes-file refactor cannot accidentally +drop it. + +Operation BC currently has no 422-mapped Recipe errors at this commit +boundary; the assertion against `cora/operation/routes.py` is gated to +SKIP until that BC gains its own 422-family errors in a downstream commit. +The Recipe BC assertion is unconditional. +""" + +import ast +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_RECIPE_ROUTES = _REPO_ROOT / "src" / "cora" / "recipe" / "routes.py" +_OPERATION_ROUTES = _REPO_ROOT / "src" / "cora" / "operation" / "routes.py" + + +def _has_422_handler(path: Path) -> bool: + """Return True if the module text references the FastAPI 422 status constant. + + Looks for either the modern `HTTP_422_UNPROCESSABLE_CONTENT` constant + or the deprecated `HTTP_422_UNPROCESSABLE_ENTITY` alias in the file + content; this is the load-bearing surface (the `_handle_unprocessable` + function body uses it). Either the helper function or an inline + reference in a routes-level handler satisfies the gate. + """ + if not path.is_file(): + return False + text = path.read_text() + return "HTTP_422_UNPROCESSABLE_CONTENT" in text or "HTTP_422_UNPROCESSABLE_ENTITY" in text + + +def _module_imports_unprocessable_helper(path: Path) -> bool: + """Return True if the file defines a `_handle_unprocessable` function.""" + if not path.is_file(): + return False + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.AsyncFunctionDef) and node.name == "_handle_unprocessable": + return True + if isinstance(node, ast.FunctionDef) and node.name == "_handle_unprocessable": + return True + return False + + +@pytest.mark.architecture +def test_recipe_routes_registers_422_handler() -> None: + """Recipe routes.py exposes a 422 handler used by 4 Recipe error classes. + + Without this handler, InvalidRecipeStepShapeError / + RecipeBindingReferencesUnknownParameterError / + RecipeRequiresCapabilityParametersSchemaError / UnboundRecipeBindingError + fall through to 500 instead of the documented 422. + """ + assert _RECIPE_ROUTES.is_file(), f"missing routes file: {_RECIPE_ROUTES}" + assert _has_422_handler(_RECIPE_ROUTES), ( + f"{_RECIPE_ROUTES} must reference HTTP_422_UNPROCESSABLE_ENTITY (Recipe BC " + "has 4 errors mapped to 422 per the Recipe aggregate design memo)." + ) + assert _module_imports_unprocessable_helper(_RECIPE_ROUTES), ( + f"{_RECIPE_ROUTES} must define a `_handle_unprocessable` function so " + "FastAPI add_exception_handler calls register the 422 mapping." + ) + + +@pytest.mark.architecture +def test_operation_routes_registers_422_handler_when_needed() -> None: + """Operation routes.py 422-handler gate; skipped until the BC needs one. + + Recipe expansion at register_procedure_from_recipe time can raise + RecipeBindingsStaleAgainstCurrentCapabilityError (per memo Rejections) + which must map to 422 from Operation BC routes. Until that handler + lands the check is skipped; do not delete this test, gate it. + """ + if not _OPERATION_ROUTES.is_file(): + pytest.skip(f"missing routes file: {_OPERATION_ROUTES}") + text = _OPERATION_ROUTES.read_text() + if "RecipeBindings" not in text and "RecipeExpansion" not in text: + pytest.skip( + "Operation BC has no Recipe-tier 422 errors registered yet; " + "the handler lands when the Operation BC slice rewrite imports " + "the Recipe-tier error classes." + ) + assert _has_422_handler(_OPERATION_ROUTES), ( + f"{_OPERATION_ROUTES} imports Recipe-tier error classes but does not " + "reference HTTP_422_UNPROCESSABLE_ENTITY; add a `_handle_unprocessable` " + "helper and register the Recipe-tier 422 errors." + ) diff --git a/apps/api/tests/architecture/test_recipe_expansion_recorded_denorm_matches_capability_state.py b/apps/api/tests/architecture/test_recipe_expansion_recorded_denorm_matches_capability_state.py new file mode 100644 index 000000000..78f07132e --- /dev/null +++ b/apps/api/tests/architecture/test_recipe_expansion_recorded_denorm_matches_capability_state.py @@ -0,0 +1,109 @@ +"""State-vs-event consistency fitness for `RecipeExpansionRecorded`. + +Anti-hook 15 of [[project-recipe-aggregate-design]] pins +`capability_id + capability_version` on the `RecipeExpansionRecorded` +payload as a denormalized snapshot of the Capability state at +expansion time. A future refactor that silently drops the denorm +would break audit-by-Capability read paths; this fitness keeps the +contract honest by asserting the event class declares the denorm +fields AND the to_payload arm carries them. + +Runs at the unit tier against an in-memory construction of the event ++ Capability fixture; an integration-tier variant (deferred) can +exercise the same invariant against PostgresEventStore for the +capability_version pin. +""" + +from dataclasses import fields +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.operation.aggregates.procedure import ( + RecipeExpansionRecorded, + to_payload, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_REQUIRED_DENORM_FIELDS = frozenset( + { + "recipe_id", + "recipe_version", + "capability_id", + "capability_version", + } +) + + +@pytest.mark.architecture +def test_recipe_expansion_recorded_dataclass_declares_denorm_fields() -> None: + """The event must keep `recipe_id` + `recipe_version` (replay snapshot pin) + AND `capability_id` + `capability_version` (audit-by-Capability denorm).""" + declared = {field.name for field in fields(RecipeExpansionRecorded)} + missing = _REQUIRED_DENORM_FIELDS - declared + assert not missing, ( + f"RecipeExpansionRecorded missing denorm fields: {sorted(missing)}. " + f"Anti-hook 15 of project-recipe-aggregate-design pins these as " + f"the load-bearing escape hatch for audit-by-Capability read paths." + ) + + +@pytest.mark.architecture +def test_recipe_expansion_recorded_to_payload_carries_denorm_keys() -> None: + """to_payload must serialize every denorm field; a refactor that + silently drops one would break audit-by-Capability filters.""" + recipe_id = uuid4() + capability_id = uuid4() + event = RecipeExpansionRecorded( + procedure_id=uuid4(), + recipe_id=recipe_id, + recipe_version="v1", + capability_id=capability_id, + capability_version="cap-v3", + bindings={"angle": 30.0}, + expansion_port_version="v1", + steps_hash="abc", + bindings_hash="def", + step_count=1, + occurred_at=_NOW, + ) + payload = to_payload(event) + for key in _REQUIRED_DENORM_FIELDS: + assert key in payload, ( + f"to_payload({type(event).__name__}) omits denorm key {key!r}; " + f"audit-by-Capability read paths would lose the {key} pin." + ) + assert payload["recipe_id"] == str(recipe_id) + assert payload["capability_id"] == str(capability_id) + assert payload["recipe_version"] == "v1" + assert payload["capability_version"] == "cap-v3" + + +@pytest.mark.architecture +def test_recipe_expansion_recorded_bindings_serialize_canonically() -> None: + """Bindings serialize via canonical-JSON sort_keys so the persisted + payload reproduces `bindings_hash`. Distinct-order dicts must serialize + identically.""" + proc_id = uuid4() + rec_id = uuid4() + cap_id = uuid4() + + def _make(bindings: dict[str, object]) -> RecipeExpansionRecorded: + return RecipeExpansionRecorded( + procedure_id=proc_id, + recipe_id=rec_id, + recipe_version=None, + capability_id=cap_id, + capability_version=None, + bindings=bindings, + expansion_port_version="v1", + steps_hash="h", + bindings_hash="b", + step_count=0, + occurred_at=_NOW, + ) + + event_a = _make({"angle": 30.0, "energy": 10.0}) + event_b = _make({"energy": 10.0, "angle": 30.0}) + assert to_payload(event_a)["bindings"] == to_payload(event_b)["bindings"] diff --git a/apps/api/tests/architecture/test_recipe_step_variants_match_step_union.py b/apps/api/tests/architecture/test_recipe_step_variants_match_step_union.py new file mode 100644 index 000000000..a093705bd --- /dev/null +++ b/apps/api/tests/architecture/test_recipe_step_variants_match_step_union.py @@ -0,0 +1,97 @@ +"""The `RecipeStep` union must stay in sync with the Conductor's `Step` union. + +Pins three parallel declarations against drift: + + - `cora.recipe.aggregates.recipe.body.RecipeStep` arms (the templated + step VOs operators author inside a Recipe) + - `cora.operation.conductor.Step` arms (the bound step VOs the + Conductor walks) + - `cora.operation._recipe_expansion._expand_step` dispatch arms (the + function that translates each `RecipeStep` to its bound `Step` at + register_procedure_from_recipe time) + +A new RecipeStep variant (say, `RecipeWaitStep`) added without a +matching `Step` arm OR without a matching `_expand_step` dispatch +arm would silently miss the kind at expansion time, with no CI +signal. This test catches the divergence at fitness time. + +The third assertion is conditional: the `_recipe_expansion` module +lands in a downstream commit (see project_recipe_aggregate_design). +Until then the dispatch-coverage check is skipped via +`pytest.importorskip`; the arity + name-parity gates provide +sufficient structural coverage in the meantime. +""" + +import inspect +from typing import get_args + +import pytest + +from cora.operation import conductor as _conductor_module +from cora.recipe.aggregates.recipe import body as _recipe_body + + +@pytest.mark.architecture +def test_recipe_step_union_arity_matches_conductor_step_union() -> None: + """Both unions have the same number of arms. + + A new RecipeStep variant or Conductor Step variant added without + a matching arm on the other side lands here. Currently both unions + carry 3 arms (Setpoint / Action / Check). + """ + recipe_arms = get_args(_recipe_body.RecipeStep) + conductor_arms = get_args(_conductor_module.Step) + assert len(recipe_arms) == len(conductor_arms), ( + f"RecipeStep union has {len(recipe_arms)} arms but Conductor.Step has " + f"{len(conductor_arms)} arms. The two declarations must stay one-to-one " + f"or expansion will silently skip the new kind." + ) + + +@pytest.mark.architecture +def test_recipe_prefix_strip_matches_step_arm_names() -> None: + """Every `RecipeStep` arm has a matching `Step` arm in the Conductor union. + + Strips the `Recipe` prefix from each arm class name and asserts + the unprefixed name appears in `Conductor.Step`. A new + `RecipeFooBarStep` added without a matching Conductor `FooBarStep` + lands here. + """ + recipe_names = {arm.__name__ for arm in get_args(_recipe_body.RecipeStep)} + conductor_names = {arm.__name__ for arm in get_args(_conductor_module.Step)} + stripped = {name.removeprefix("Recipe") for name in recipe_names} + missing = stripped - conductor_names + assert not missing, ( + f"RecipeStep arms {sorted(recipe_names)} strip to {sorted(stripped)}; " + f"Conductor.Step declares {sorted(conductor_names)}. Missing arms in " + f"Conductor.Step: {sorted(missing)}." + ) + + +@pytest.mark.architecture +def test_recipe_expansion_dispatches_every_recipe_step_arm() -> None: + """Every `RecipeStep` arm has a matching dispatch path in `_recipe_expansion`. + + The expansion module translates each `RecipeStep` arm into its + bound `Step` form. A new RecipeStep variant added without a + dispatch arm in `_expand_step` would silently skip the kind at + expansion. Until the expansion module is authored the check is + SKIPPED (the union arity + name parity gates above cover the + structural shape). + """ + expansion_module = pytest.importorskip( + "cora.operation._recipe_expansion", + reason="_recipe_expansion module not present yet; dispatch-coverage check pending", + ) + recipe_arms = get_args(_recipe_body.RecipeStep) + expander = getattr(expansion_module, "_expand_step", None) + assert expander is not None, ( + "cora.operation._recipe_expansion is missing the _expand_step dispatch helper; " + "the expansion module must export it so this fitness can verify dispatch coverage." + ) + source = inspect.getsource(expander) + missing = [arm.__name__ for arm in recipe_arms if arm.__name__ not in source] + assert not missing, ( + f"_recipe_expansion._expand_step source omits dispatch for RecipeStep arms: " + f"{sorted(missing)}. Add an isinstance arm per missing variant." + ) 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 94f2ce029..77491502c 100644 --- a/apps/api/tests/architecture/test_slice_verb_names_subject.py +++ b/apps/api/tests/architecture/test_slice_verb_names_subject.py @@ -61,6 +61,7 @@ "policy", "practice", "procedure", + "recipe", "run", "seal", "subject", diff --git a/apps/api/tests/contract/test_conduct_procedure_endpoint.py b/apps/api/tests/contract/test_conduct_procedure_endpoint.py index d93ea7930..7ab979705 100644 --- a/apps/api/tests/contract/test_conduct_procedure_endpoint.py +++ b/apps/api/tests/contract/test_conduct_procedure_endpoint.py @@ -107,9 +107,12 @@ def test_post_conduct_with_setpoint_to_unconnected_address_returns_not_connected @pytest.mark.contract def test_post_conduct_against_unregistered_procedure_returns_404() -> None: - """conduct() re-raises start_procedure's ProcedureNotFoundError so the - BC's central exception handler maps it to 404. Earlier shape (200 with - lifecycle failure on the body) was rejected by routes.py wiring: see + """The conduct_procedure handler loads the Procedure stream up front + ([[project-run-procedure-replay-design]] added + `load_procedure_with_events` at handler entry) and raises + `ProcedureNotFoundError`, which the BC's central exception handler + maps to 404. Earlier shape (200 with lifecycle failure on the body) + was rejected by routes.py wiring: see [[project_conduct_procedure_test_contract_drift]] memory.""" with TestClient(create_app()) as client: unknown_pid = uuid4() diff --git a/apps/api/tests/contract/test_conduct_procedure_mcp_tool.py b/apps/api/tests/contract/test_conduct_procedure_mcp_tool.py index adaea51e9..39d7e95c6 100644 --- a/apps/api/tests/contract/test_conduct_procedure_mcp_tool.py +++ b/apps/api/tests/contract/test_conduct_procedure_mcp_tool.py @@ -107,10 +107,13 @@ def test_mcp_conduct_procedure_with_unknown_action_returns_failure_in_structured @pytest.mark.contract def test_mcp_conduct_procedure_against_unregistered_procedure_returns_iserror() -> None: - """conduct() re-raises ProcedureNotFoundError; FastMCP surfaces as isError. - Earlier shape (200-with-lifecycle-failure structured content) was rejected - by routes.py wiring: see [[project_conduct_procedure_test_contract_drift]] - memory.""" + """The conduct_procedure handler loads the Procedure stream up front + ([[project-run-procedure-replay-design]] added + `load_procedure_with_events`) and raises `ProcedureNotFoundError`; + FastMCP wraps it generically (no allowlist) so the tools/call response + surfaces as `isError=true`. Earlier shape (200-with-lifecycle-failure + structured content) was rejected by routes.py wiring: see + [[project_conduct_procedure_test_contract_drift]] memory.""" with TestClient(create_app()) as client: headers = open_session(client) unknown_pid = uuid4() diff --git a/apps/api/tests/contract/test_define_recipe_endpoint.py b/apps/api/tests/contract/test_define_recipe_endpoint.py new file mode 100644 index 000000000..84c9a3234 --- /dev/null +++ b/apps/api/tests/contract/test_define_recipe_endpoint.py @@ -0,0 +1,136 @@ +"""Contract tests for `POST /recipes`. + +Recipe is a deployment-bound executable step sequence anchored on a +Capability. The endpoint loads the referenced Capability + validates +BindingRef integrity against its parameters_schema before persisting. +""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema" +_DEFAULT_STEPS: list[dict[str, object]] = [ + {"kind": "setpoint", "address": "dev:rot:val", "value": 1.0, "verify": False}, +] + + +def _capability_body( + code: str = "cora.capability.tomo", + name: str = "Tomo", + parameters_schema: dict[str, object] | None = None, +) -> dict[str, object]: + body: dict[str, object] = { + "code": code, + "name": name, + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + if parameters_schema is not None: + body["parameters_schema"] = parameters_schema + return body + + +def _schema_with_angle() -> dict[str, object]: + return { + "$schema": _DRAFT_2020_12, + "type": "object", + "properties": {"angle": {"type": "number"}}, + "required": ["angle"], + } + + +def _recipe_body( + *, + capability_id: str, + name: str = "TomoRecipe", + steps: list[dict[str, object]] | None = None, +) -> dict[str, object]: + return { + "name": name, + "capability_id": capability_id, + "steps": {"steps": steps if steps is not None else _DEFAULT_STEPS}, + } + + +@pytest.mark.contract +def test_post_recipes_201_creates_recipe_against_existing_capability() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability_body()).json() + response = client.post("/recipes", json=_recipe_body(capability_id=cap["capability_id"])) + assert response.status_code == 201 + assert "recipe_id" in response.json() + + +@pytest.mark.contract +def test_post_recipes_404_when_capability_missing() -> None: + with TestClient(create_app()) as client: + bogus = "01900000-0000-7000-8000-deadbeefcafe" + response = client.post("/recipes", json=_recipe_body(capability_id=bogus)) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_recipes_422_when_binding_ref_unknown() -> None: + with TestClient(create_app()) as client: + cap = client.post( + "/capabilities", json=_capability_body(parameters_schema=_schema_with_angle()) + ).json() + response = client.post( + "/recipes", + json=_recipe_body( + capability_id=cap["capability_id"], + steps=[ + { + "kind": "setpoint", + "address": "dev:rot:val", + "value": {"__binding__": "enrgy"}, + "verify": False, + } + ], + ), + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_recipes_422_when_request_body_missing_steps() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability_body()).json() + response = client.post( + "/recipes", + json={"name": "X", "capability_id": cap["capability_id"]}, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_recipes_400_when_steps_empty() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability_body()).json() + response = client.post( + "/recipes", + json=_recipe_body(capability_id=cap["capability_id"], steps=[]), + ) + assert response.status_code == 400 + + +@pytest.mark.contract +def test_post_recipes_same_idempotency_key_returns_same_recipe_id() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability_body()).json() + headers = {"Idempotency-Key": "rk-1"} + r1 = client.post( + "/recipes", + json=_recipe_body(capability_id=cap["capability_id"]), + headers=headers, + ) + r2 = client.post( + "/recipes", + json=_recipe_body(capability_id=cap["capability_id"]), + headers=headers, + ) + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["recipe_id"] == r2.json()["recipe_id"] diff --git a/apps/api/tests/contract/test_define_recipe_mcp_tool.py b/apps/api/tests/contract/test_define_recipe_mcp_tool.py new file mode 100644 index 000000000..66a07ebc9 --- /dev/null +++ b/apps/api/tests/contract/test_define_recipe_mcp_tool.py @@ -0,0 +1,92 @@ +"""Contract tests for the `define_recipe` MCP tool.""" + +from typing import Any +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _capability_args() -> dict[str, Any]: + return { + "code": "cora.capability.mcp_recipe", + "name": "MCPRecipe", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_args(capability_id: str) -> dict[str, Any]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [{"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}], + }, + } + + +def _call_tool( + client: TestClient, + session_headers: dict[str, str], + tool: str, + arguments: dict[str, Any], + request_id: int, +) -> dict[str, Any]: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + headers=session_headers, + ) + return parse_sse_data(response.text)["result"] + + +@pytest.mark.contract +def test_mcp_lists_define_recipe_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "define_recipe" in tool_names + + +@pytest.mark.contract +def test_mcp_define_recipe_tool_returns_structured_recipe_id() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + cap_result = _call_tool(client, session_headers, "define_capability", _capability_args(), 2) + capability_id = cap_result["structuredContent"]["capability_id"] + result = _call_tool( + client, session_headers, "define_recipe", _recipe_args(capability_id), 3 + ) + assert result["isError"] is False + assert "recipe_id" in result["structuredContent"] + UUID(result["structuredContent"]["recipe_id"]) + + +@pytest.mark.contract +def test_mcp_define_recipe_rejects_when_capability_missing() -> None: + """MCP returns isError when the referenced Capability stream is empty.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + result = _call_tool( + client, + session_headers, + "define_recipe", + _recipe_args("01900000-0000-7000-8000-deadbeefcafe"), + 4, + ) + assert result["isError"] is True diff --git a/apps/api/tests/contract/test_deprecate_recipe_endpoint.py b/apps/api/tests/contract/test_deprecate_recipe_endpoint.py new file mode 100644 index 000000000..f63521639 --- /dev/null +++ b/apps/api/tests/contract/test_deprecate_recipe_endpoint.py @@ -0,0 +1,68 @@ +"""Contract tests for `POST /recipes/{recipe_id}/deprecate`.""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + + +def _capability() -> dict[str, object]: + return { + "code": "cora.capability.dtomo", + "name": "DTomo", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_for(capability_id: str) -> dict[str, object]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [ + {"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}, + ], + }, + } + + +@pytest.mark.contract +def test_post_deprecate_recipe_204_emits_deprecated_event() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + response = client.post(f"/recipes/{recipe['recipe_id']}/deprecate", json={}) + assert response.status_code == 204 + + +@pytest.mark.contract +def test_post_deprecate_recipe_accepts_replaced_by_recipe_id() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + successor = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + response = client.post( + f"/recipes/{recipe['recipe_id']}/deprecate", + json={"replaced_by_recipe_id": successor["recipe_id"]}, + ) + assert response.status_code == 204 + + +@pytest.mark.contract +def test_post_deprecate_recipe_404_when_recipe_missing() -> None: + with TestClient(create_app()) as client: + bogus = "01900000-0000-7000-8000-deadbeefcafe" + response = client.post(f"/recipes/{bogus}/deprecate", json={}) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_deprecate_recipe_409_on_re_deprecate() -> None: + """Strict-not-idempotent: re-deprecating raises 409.""" + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + client.post(f"/recipes/{recipe['recipe_id']}/deprecate", json={}) + response = client.post(f"/recipes/{recipe['recipe_id']}/deprecate", json={}) + assert response.status_code == 409 diff --git a/apps/api/tests/contract/test_deprecate_recipe_mcp_tool.py b/apps/api/tests/contract/test_deprecate_recipe_mcp_tool.py new file mode 100644 index 000000000..03b697680 --- /dev/null +++ b/apps/api/tests/contract/test_deprecate_recipe_mcp_tool.py @@ -0,0 +1,82 @@ +"""Contract tests for the `deprecate_recipe` MCP tool.""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _capability_args() -> dict[str, Any]: + return { + "code": "cora.capability.mcp_drecipe", + "name": "MCPDRecipe", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_args(capability_id: str) -> dict[str, Any]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [{"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}], + }, + } + + +def _call_tool( + client: TestClient, + session_headers: dict[str, str], + tool: str, + arguments: dict[str, Any], + request_id: int, +) -> dict[str, Any]: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + headers=session_headers, + ) + return parse_sse_data(response.text)["result"] + + +@pytest.mark.contract +def test_mcp_lists_deprecate_recipe_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "deprecate_recipe" in tool_names + + +@pytest.mark.contract +def test_mcp_deprecate_recipe_tool_succeeds_after_define() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + cap_result = _call_tool(client, session_headers, "define_capability", _capability_args(), 2) + capability_id = cap_result["structuredContent"]["capability_id"] + recipe_result = _call_tool( + client, session_headers, "define_recipe", _recipe_args(capability_id), 3 + ) + recipe_id = recipe_result["structuredContent"]["recipe_id"] + result = _call_tool( + client, + session_headers, + "deprecate_recipe", + {"recipe_id": recipe_id}, + 4, + ) + assert result["isError"] is False diff --git a/apps/api/tests/contract/test_get_recipe_endpoint.py b/apps/api/tests/contract/test_get_recipe_endpoint.py new file mode 100644 index 000000000..d1d27cc14 --- /dev/null +++ b/apps/api/tests/contract/test_get_recipe_endpoint.py @@ -0,0 +1,79 @@ +"""Contract tests for `GET /recipes/{recipe_id}`.""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + + +def _capability() -> dict[str, object]: + return { + "code": "cora.capability.gtomo", + "name": "GTomo", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_for(capability_id: str) -> dict[str, object]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [ + {"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}, + ], + }, + } + + +@pytest.mark.contract +def test_get_recipe_200_returns_full_recipe_response() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + response = client.get(f"/recipes/{recipe['recipe_id']}") + assert response.status_code == 200 + body = response.json() + assert body["id"] == recipe["recipe_id"] + assert body["capability_id"] == cap["capability_id"] + assert body["status"] == "Defined" + assert body["version"] is None + assert body["replaced_by_recipe_id"] is None + assert "steps" in body + assert body["steps"]["steps"][0]["kind"] == "setpoint" + + +@pytest.mark.contract +def test_get_recipe_404_when_recipe_missing() -> None: + with TestClient(create_app()) as client: + bogus = "01900000-0000-7000-8000-deadbeefcafe" + response = client.get(f"/recipes/{bogus}") + assert response.status_code == 404 + + +@pytest.mark.contract +def test_get_recipe_reflects_versioned_state_after_version_recipe_call() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + client.post( + f"/recipes/{recipe['recipe_id']}/version", + json={ + "version_tag": "v2", + "steps": { + "steps": [ + { + "kind": "setpoint", + "address": "dev:x", + "value": 9.0, + "verify": False, + } + ] + }, + }, + ) + response = client.get(f"/recipes/{recipe['recipe_id']}") + body = response.json() + assert body["status"] == "Versioned" + assert body["version"] == "v2" diff --git a/apps/api/tests/contract/test_get_recipe_mcp_tool.py b/apps/api/tests/contract/test_get_recipe_mcp_tool.py new file mode 100644 index 000000000..9d5697b49 --- /dev/null +++ b/apps/api/tests/contract/test_get_recipe_mcp_tool.py @@ -0,0 +1,80 @@ +"""Contract tests for the `get_recipe` MCP tool.""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _capability_args() -> dict[str, Any]: + return { + "code": "cora.capability.mcp_grecipe", + "name": "MCPGRecipe", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_args(capability_id: str) -> dict[str, Any]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [{"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}], + }, + } + + +def _call_tool( + client: TestClient, + session_headers: dict[str, str], + tool: str, + arguments: dict[str, Any], + request_id: int, +) -> dict[str, Any]: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + headers=session_headers, + ) + return parse_sse_data(response.text)["result"] + + +@pytest.mark.contract +def test_mcp_lists_get_recipe_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "get_recipe" in tool_names + + +@pytest.mark.contract +def test_mcp_get_recipe_tool_returns_structured_recipe_state() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + cap_result = _call_tool(client, session_headers, "define_capability", _capability_args(), 2) + capability_id = cap_result["structuredContent"]["capability_id"] + recipe_result = _call_tool( + client, session_headers, "define_recipe", _recipe_args(capability_id), 3 + ) + recipe_id = recipe_result["structuredContent"]["recipe_id"] + result = _call_tool(client, session_headers, "get_recipe", {"recipe_id": recipe_id}, 4) + assert result["isError"] is False + body = result["structuredContent"] + assert body["id"] == recipe_id + assert body["status"] == "Defined" + assert body["capability_id"] == capability_id diff --git a/apps/api/tests/contract/test_register_procedure_from_recipe_endpoint.py b/apps/api/tests/contract/test_register_procedure_from_recipe_endpoint.py new file mode 100644 index 000000000..cbf86845f --- /dev/null +++ b/apps/api/tests/contract/test_register_procedure_from_recipe_endpoint.py @@ -0,0 +1,132 @@ +"""Contract tests for `POST /procedures/from-recipe`. + +End-to-end through TestClient: register a Capability, register a +Recipe against that Capability, then exercise the Operation BC +register_procedure_from_recipe path covering 201 happy / 404 missing +Recipe / 409 executor-mismatch / 422 stale Capability schema. +""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_DRAFT = "https://json-schema.org/draft/2020-12/schema" + + +def _capability_body( + code: str = "cora.capability.rec_proc", + parameters_schema: dict[str, Any] | None = None, + executor_shapes: list[str] | None = None, +) -> dict[str, Any]: + body: dict[str, Any] = { + "code": code, + "name": "TestCap", + "required_affordances": [], + "executor_shapes": executor_shapes or ["Method", "Procedure"], + } + if parameters_schema is not None: + body["parameters_schema"] = parameters_schema + return body + + +def _recipe_body(capability_id: str, with_binding: bool = False) -> dict[str, Any]: + value: Any = {"__binding__": "angle"} if with_binding else 1.0 + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [{"kind": "setpoint", "address": "dev:x", "value": value, "verify": False}], + }, + } + + +def _register_body(recipe_id: str, bindings: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "name": "P", + "kind": "bakeout", + "target_asset_ids": [], + "parent_run_id": None, + "recipe_id": recipe_id, + "bindings": bindings or {}, + } + + +@pytest.mark.contract +def test_post_procedures_from_recipe_201_creates_procedure() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability_body()).json() + recipe = client.post("/recipes", json=_recipe_body(cap["capability_id"])).json() + response = client.post("/procedures/from-recipe", json=_register_body(recipe["recipe_id"])) + assert response.status_code == 201 + assert "procedure_id" in response.json() + + +@pytest.mark.contract +def test_post_procedures_from_recipe_404_when_recipe_missing() -> None: + with TestClient(create_app()) as client: + response = client.post( + "/procedures/from-recipe", + json=_register_body("01900000-0000-7000-8000-deadbeefcafe"), + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_procedures_from_recipe_409_when_capability_excludes_procedure() -> None: + with TestClient(create_app()) as client: + cap = client.post( + "/capabilities", + json=_capability_body(code="cora.capability.method_only", executor_shapes=["Method"]), + ).json() + recipe = client.post("/recipes", json=_recipe_body(cap["capability_id"])).json() + response = client.post("/procedures/from-recipe", json=_register_body(recipe["recipe_id"])) + assert response.status_code == 409 + + +@pytest.mark.contract +def test_post_procedures_from_recipe_422_when_capability_schema_drifted() -> None: + """Anti-hook 5 expansion-time half via REST: 422 + the stale-Capability error. + + The handler raises RecipeBindingsStaleAgainstCurrentCapabilityError + when the Capability has been re-versioned since the Recipe was + written and a binding name dropped from parameters_schema. + """ + with TestClient(create_app()) as client: + # Capability with `angle` schema; Recipe binds it. + cap_v1 = client.post( + "/capabilities", + json=_capability_body( + parameters_schema={ + "$schema": _DRAFT, + "type": "object", + "properties": {"angle": {"type": "number"}}, + }, + ), + ).json() + recipe = client.post( + "/recipes", + json=_recipe_body(cap_v1["capability_id"], with_binding=True), + ).json() + # Version the Capability to DROP `angle` (now only `energy`). + client.post( + f"/capabilities/{cap_v1['capability_id']}/version", + json={ + "version_tag": "v2", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + "parameters_schema": { + "$schema": _DRAFT, + "type": "object", + "properties": {"energy": {"type": "number"}}, + }, + }, + ) + response = client.post( + "/procedures/from-recipe", + json=_register_body(recipe["recipe_id"], bindings={"angle": 30.0}), + ) + assert response.status_code == 422 + assert "stale" in response.json()["detail"].lower() diff --git a/apps/api/tests/contract/test_register_procedure_from_recipe_mcp_tool.py b/apps/api/tests/contract/test_register_procedure_from_recipe_mcp_tool.py new file mode 100644 index 000000000..34d252251 --- /dev/null +++ b/apps/api/tests/contract/test_register_procedure_from_recipe_mcp_tool.py @@ -0,0 +1,85 @@ +"""Contract tests for the `register_procedure_from_recipe` MCP tool.""" + +from typing import Any +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _call_tool( + client: TestClient, + session_headers: dict[str, str], + tool: str, + arguments: dict[str, Any], + request_id: int, +) -> dict[str, Any]: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + headers=session_headers, + ) + return parse_sse_data(response.text)["result"] + + +def _capability_args() -> dict[str, Any]: + return { + "code": "cora.capability.mcp_rfr", + "name": "MCPRFR", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_args(capability_id: str) -> dict[str, Any]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [{"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}] + }, + } + + +@pytest.mark.contract +def test_mcp_lists_register_procedure_from_recipe_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "register_procedure_from_recipe" in tool_names + + +@pytest.mark.contract +def test_mcp_register_procedure_from_recipe_returns_structured_procedure_id() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + cap = _call_tool(client, session_headers, "define_capability", _capability_args(), 2) + capability_id = cap["structuredContent"]["capability_id"] + recipe = _call_tool( + client, session_headers, "define_recipe", _recipe_args(capability_id), 3 + ) + recipe_id = recipe["structuredContent"]["recipe_id"] + result = _call_tool( + client, + session_headers, + "register_procedure_from_recipe", + {"name": "P", "kind": "bakeout", "recipe_id": recipe_id}, + 4, + ) + assert result["isError"] is False + assert "procedure_id" in result["structuredContent"] + UUID(result["structuredContent"]["procedure_id"]) diff --git a/apps/api/tests/contract/test_version_recipe_endpoint.py b/apps/api/tests/contract/test_version_recipe_endpoint.py new file mode 100644 index 000000000..9a95d3a6f --- /dev/null +++ b/apps/api/tests/contract/test_version_recipe_endpoint.py @@ -0,0 +1,83 @@ +"""Contract tests for `POST /recipes/{recipe_id}/version`.""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + + +def _capability() -> dict[str, object]: + return { + "code": "cora.capability.vtomo", + "name": "VTomo", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_for(capability_id: str) -> dict[str, object]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [ + {"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}, + ], + }, + } + + +def _version_body(version_tag: str = "v1") -> dict[str, object]: + return { + "version_tag": version_tag, + "steps": { + "steps": [ + {"kind": "setpoint", "address": "dev:x", "value": 2.0, "verify": False}, + ], + }, + } + + +@pytest.mark.contract +def test_post_version_recipe_204_emits_versioned_event() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + response = client.post( + f"/recipes/{recipe['recipe_id']}/version", + json=_version_body(), + ) + assert response.status_code == 204 + + +@pytest.mark.contract +def test_post_version_recipe_404_when_recipe_missing() -> None: + with TestClient(create_app()) as client: + bogus = "01900000-0000-7000-8000-deadbeefcafe" + response = client.post(f"/recipes/{bogus}/version", json=_version_body()) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_version_recipe_409_when_recipe_already_deprecated() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + client.post(f"/recipes/{recipe['recipe_id']}/deprecate", json={}) + response = client.post(f"/recipes/{recipe['recipe_id']}/version", json=_version_body()) + assert response.status_code == 409 + + +@pytest.mark.contract +def test_post_version_recipe_400_when_version_tag_whitespace() -> None: + with TestClient(create_app()) as client: + cap = client.post("/capabilities", json=_capability()).json() + recipe = client.post("/recipes", json=_recipe_for(cap["capability_id"])).json() + # Pydantic min_length=1 catches empty before the decider; use a + # whitespace-only tag that passes min_length=1 but fails the trim + # check in the decider via InvalidRecipeVersionTagError -> 400. + response = client.post( + f"/recipes/{recipe['recipe_id']}/version", + json=_version_body(version_tag=" "), + ) + assert response.status_code == 400 diff --git a/apps/api/tests/contract/test_version_recipe_mcp_tool.py b/apps/api/tests/contract/test_version_recipe_mcp_tool.py new file mode 100644 index 000000000..40207af75 --- /dev/null +++ b/apps/api/tests/contract/test_version_recipe_mcp_tool.py @@ -0,0 +1,95 @@ +"""Contract tests for the `version_recipe` MCP tool.""" + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _capability_args() -> dict[str, Any]: + return { + "code": "cora.capability.mcp_vrecipe", + "name": "MCPVRecipe", + "required_affordances": [], + "executor_shapes": ["Method", "Procedure"], + } + + +def _recipe_args(capability_id: str) -> dict[str, Any]: + return { + "name": "R", + "capability_id": capability_id, + "steps": { + "steps": [{"kind": "setpoint", "address": "dev:x", "value": 1.0, "verify": False}], + }, + } + + +def _call_tool( + client: TestClient, + session_headers: dict[str, str], + tool: str, + arguments: dict[str, Any], + request_id: int, +) -> dict[str, Any]: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + headers=session_headers, + ) + return parse_sse_data(response.text)["result"] + + +@pytest.mark.contract +def test_mcp_lists_version_recipe_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "version_recipe" in tool_names + + +@pytest.mark.contract +def test_mcp_version_recipe_tool_succeeds_after_define() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + cap_result = _call_tool(client, session_headers, "define_capability", _capability_args(), 2) + capability_id = cap_result["structuredContent"]["capability_id"] + recipe_result = _call_tool( + client, session_headers, "define_recipe", _recipe_args(capability_id), 3 + ) + recipe_id = recipe_result["structuredContent"]["recipe_id"] + result = _call_tool( + client, + session_headers, + "version_recipe", + { + "recipe_id": recipe_id, + "version_tag": "v1", + "steps": { + "steps": [ + { + "kind": "setpoint", + "address": "dev:x", + "value": 9.0, + "verify": False, + } + ] + }, + }, + 4, + ) + assert result["isError"] is False diff --git a/apps/api/tests/integration/test_define_recipe_handler_postgres.py b/apps/api/tests/integration/test_define_recipe_handler_postgres.py new file mode 100644 index 000000000..bceba4a40 --- /dev/null +++ b/apps/api/tests/integration/test_define_recipe_handler_postgres.py @@ -0,0 +1,68 @@ +"""End-to-end integration test: define_recipe handler against real Postgres. + +Pinned: Recipe step sequence round-trips through jsonb via the wire +format (`{steps: [{kind: setpoint|action|check, ...}]}`). The Recipe +stream is keyed by `recipe_id`; the referenced Capability stream +must exist (seeded via `seed_capability_postgres`) for the handler's +cross-aggregate fan-out to resolve. BindingRef-sentinel wire round-trip +(`{__binding__: name}`) is exercised at the unit tier in +`test_recipe_body.py` and `test_recipe_body_roundtrip_properties.py`; +this integration test stays on literal values so the seeded Capability +need not declare a parameters_schema. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.recipe.aggregates.recipe import RecipeSetpointStep +from cora.recipe.features import define_recipe +from cora.recipe.features.define_recipe import DefineRecipe +from tests.integration._helpers import build_postgres_deps, seed_capability_postgres + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +@pytest.mark.integration +async def test_define_recipe_persists_recipe_defined_event(db_pool: asyncpg.Pool) -> None: + recipe_id = UUID("01900000-0000-7000-8000-00000056fa01") + event_id = UUID("01900000-0000-7000-8000-00000056fa0e") + capability_id = UUID("01900000-0000-7000-8000-00000056fa0c") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[recipe_id, event_id]) + await seed_capability_postgres(deps.event_store, capability_id) + + returned_id = await define_recipe.bind(deps)( + DefineRecipe( + name="TomoRecipe", + capability_id=capability_id, + steps=( + RecipeSetpointStep(address="dev:rot:val", value=1.0), + RecipeSetpointStep(address="dev:z", value=2.5, verify=True), + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == recipe_id + + events, version = await deps.event_store.load("Recipe", recipe_id) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "RecipeDefined" + assert stored.payload["recipe_id"] == str(recipe_id) + assert stored.payload["capability_id"] == str(capability_id) + assert stored.payload["name"] == "TomoRecipe" + # Wire-format step sequence survives jsonb round-trip. + assert stored.payload["steps"]["steps"][0]["address"] == "dev:rot:val" + assert stored.payload["steps"]["steps"][1]["value"] == 2.5 + assert stored.payload["steps"]["steps"][1]["verify"] is True + assert stored.correlation_id == _CORRELATION_ID + assert stored.event_id == event_id + assert stored.metadata == {"command": "DefineRecipe"} + assert stored.occurred_at == _NOW diff --git a/apps/api/tests/integration/test_register_procedure_from_recipe_handler_postgres.py b/apps/api/tests/integration/test_register_procedure_from_recipe_handler_postgres.py new file mode 100644 index 000000000..2f0bc2e10 --- /dev/null +++ b/apps/api/tests/integration/test_register_procedure_from_recipe_handler_postgres.py @@ -0,0 +1,140 @@ +"""End-to-end integration test: register_procedure_from_recipe against real Postgres. + +Pinned: the slice's 2-event genesis block +(`ProcedureRegistered` + `RecipeExpansionRecorded`) lands in the +Procedure stream as a single atomic append; `RecipeExpansionRecorded`'s +canonical-JSON `bindings` payload reproduces `bindings_hash` via +`sha256(payload['bindings'])` even when the operator-supplied dict's +key order differs from sorted order; the `recipe_id` denorm round-trips +through jsonb on `ProcedureRegistered`; and the +`proj_operation_procedure_summary` projection populates the +`recipe_id` column so the partial index added by migration +`20260602124600_procedure_summary_add_recipe_id` can serve +audit-by-Recipe queries. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +import hashlib +import json +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from cora.operation._projections import register_operation_projections +from cora.operation.adapters.in_memory_recipe_expansion_port import ( + InMemoryRecipeExpansionPort, +) +from cora.operation.features import register_procedure_from_recipe +from cora.operation.features.register_procedure_from_recipe import ( + RegisterProcedureFromRecipe, +) +from cora.recipe.aggregates.recipe import ( + RecipeDefined, + RecipeSetpointStep, + event_type_name, + to_payload, +) +from tests.integration._helpers import build_postgres_deps, seed_capability_postgres + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +@pytest.mark.integration +async def test_register_procedure_from_recipe_persists_two_event_genesis_block( + db_pool: asyncpg.Pool, +) -> None: + procedure_id = UUID("01900000-0000-7000-8000-000005700001") + event_a = UUID("01900000-0000-7000-8000-000005700002") + event_b = UUID("01900000-0000-7000-8000-000005700003") + recipe_id = UUID("01900000-0000-7000-8000-000005700004") + capability_id = UUID("01900000-0000-7000-8000-000005700005") + seed_event_id = UUID("01900000-0000-7000-8000-000005700006") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[procedure_id, event_a, event_b]) + await seed_capability_postgres(deps.event_store, capability_id) + + # Seed the Recipe. + recipe_event = RecipeDefined( + recipe_id=recipe_id, + name="R", + capability_id=capability_id, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + occurred_at=_NOW, + ) + await deps.event_store.append( + stream_type="Recipe", + stream_id=recipe_id, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(recipe_event), + payload=to_payload(recipe_event), + occurred_at=_NOW, + event_id=seed_event_id, + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + # Multi-key bindings in deliberately non-sorted order so sort_keys + # canonicalization is structurally exercised (a single-key dict + # would pass either way). + bindings = {"beta": 2.0, "alpha": 1.0} + returned_id = await register_procedure_from_recipe.bind( + deps, expansion_port=InMemoryRecipeExpansionPort() + )( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=recipe_id, + bindings=bindings, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == procedure_id + + events, version = await deps.event_store.load("Procedure", procedure_id) + assert version == 2 + assert len(events) == 2 + + registered, recorded = events + assert registered.event_type == "ProcedureRegistered" + assert registered.payload["procedure_id"] == str(procedure_id) + assert registered.payload["recipe_id"] == str(recipe_id) + assert registered.payload["capability_id"] == str(capability_id) + + assert recorded.event_type == "RecipeExpansionRecorded" + assert recorded.payload["procedure_id"] == str(procedure_id) + assert recorded.payload["recipe_id"] == str(recipe_id) + assert recorded.payload["capability_id"] == str(capability_id) + assert recorded.payload["bindings"] == bindings + assert recorded.payload["step_count"] == 1 + # Canonical-JSON sort_keys + same hash function the decider uses. + expected_hash = hashlib.sha256( + json.dumps(bindings, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + assert recorded.payload["bindings_hash"] == expected_hash + + registry = ProjectionRegistry() + register_operation_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT recipe_id FROM proj_operation_procedure_summary WHERE procedure_id = $1", + procedure_id, + ) + assert row is not None + assert row["recipe_id"] == recipe_id diff --git a/apps/api/tests/integration/test_register_procedure_handler_postgres.py b/apps/api/tests/integration/test_register_procedure_handler_postgres.py index d2b3f46e8..aa1673e47 100644 --- a/apps/api/tests/integration/test_register_procedure_handler_postgres.py +++ b/apps/api/tests/integration/test_register_procedure_handler_postgres.py @@ -74,6 +74,7 @@ async def test_register_procedure_persists_event_to_postgres_with_target_assets( "target_asset_ids": sorted([str(asset1), str(asset2)]), "parent_run_id": None, "capability_id": None, + "recipe_id": None, "occurred_at": _NOW.isoformat(), } assert stored.correlation_id == _CORRELATION_ID diff --git a/apps/api/tests/integration/test_register_then_conduct_procedure_postgres.py b/apps/api/tests/integration/test_register_then_conduct_procedure_postgres.py new file mode 100644 index 000000000..e1afcd178 --- /dev/null +++ b/apps/api/tests/integration/test_register_then_conduct_procedure_postgres.py @@ -0,0 +1,213 @@ +"""End-to-end integration: register_procedure_from_recipe -> conduct_procedure. + +Per [[project-run-procedure-replay-design]] §Test plan. Exercises the +cross-BC fetch path (load_recipe_at_version + load_procedure_with_events) +against real Postgres + the canonical-JSON byte-equality between the +at-write decider and the replay-time handler. Asserts the conduct_procedure +handler does not raise + the Procedure event stream carries the expected +genesis + start + complete events. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.infrastructure.event_envelope import to_new_event +from cora.operation import wire_operation +from cora.operation.features.conduct_procedure import ConductProcedure +from cora.operation.features.register_procedure_from_recipe import ( + RegisterProcedureFromRecipe, +) +from cora.recipe.aggregates.recipe import ( + RecipeDefined, + RecipeSetpointStep, + RecipeVersioned, + event_type_name, + to_payload, +) +from tests.integration._helpers import build_postgres_deps, seed_capability_postgres + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _seed_recipe_event( + event_store: object, + recipe_id: UUID, + expected_version: int, + event: object, +) -> None: + await event_store.append( # type: ignore[attr-defined] + stream_type="Recipe", + stream_id=recipe_id, + expected_version=expected_version, + events=[ + to_new_event( + event_type=event_type_name(event), # type: ignore[arg-type] + payload=to_payload(event), # type: ignore[arg-type] + occurred_at=_NOW, + event_id=UUID(int=expected_version + 0x70000010), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ), + ], + ) + + +@pytest.mark.integration +async def test_register_procedure_from_recipe_then_conduct_procedure_succeeds_postgres( + db_pool: asyncpg.Pool, +) -> None: + """The handler chain registers a recipe-driven Procedure then runs + it via conduct_procedure; the re-expansion + hash verification round-trip + against real asyncpg jsonb storage and the Procedure transitions + through Defined -> Running -> Completed.""" + procedure_id = UUID("01900000-0000-7000-8000-000005810001") + recipe_id = UUID("01900000-0000-7000-8000-000005810004") + capability_id = UUID("01900000-0000-7000-8000-000005810005") + # Generous pool: register emits 2 events; run emits start + step + # appends + complete; the helper consumes IDs for every new_id call + # the IdGenerator backs. + ids = [procedure_id] + [UUID(int=0x01900000_0000_7000_8000_000005810100 + i) for i in range(20)] + deps = build_postgres_deps(db_pool, now=_NOW, ids=ids) + await seed_capability_postgres(deps.event_store, capability_id) + await _seed_recipe_event( + deps.event_store, + recipe_id, + 0, + RecipeDefined( + recipe_id=recipe_id, + name="R", + capability_id=capability_id, + steps=(RecipeSetpointStep(address="dev:noop", value=1.0),), + occurred_at=_NOW, + ), + ) + + handlers = wire_operation(deps) + + returned_id = await handlers.register_procedure_from_recipe( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=recipe_id, + bindings={}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == procedure_id + + result = await handlers.conduct_procedure( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + # The replay gate succeeded: we got a RunProcedureResult, not a + # raised RecipeExpansion*Error. Conductor downstream may or may + # not succeed depending on the in-memory ControlPort's handling + # of `dev:noop` (out of scope for this test); what matters is the + # replay handler reached the Conductor with the re-expanded steps. + assert result.procedure_id == procedure_id + if result.failure is not None: + # Downstream Conductor failure (control / action / check), not + # a replay-side rejection. + assert result.failure.source_kind != "lifecycle" + + events, _version = await deps.event_store.load("Procedure", procedure_id) + event_types = [e.event_type for e in events] + assert event_types[:2] == ["ProcedureRegistered", "RecipeExpansionRecorded"] + # ProcedureStarted is the proof the replay gate passed and handed + # the re-expanded steps to the Conductor. + assert "ProcedureStarted" in event_types + + +@pytest.mark.integration +async def test_register_then_version_recipe_then_conduct_procedure_replays_pinned_steps_postgres( + db_pool: asyncpg.Pool, +) -> None: + """A Recipe is registered + a Procedure expands against it (v1 implicit); + the Recipe is later re-versioned with mutated steps; conduct_procedure + re-expands at the PINNED pre-version snapshot (None), proving + load_recipe_at_version correctly walks the event tail to the + snapshot at expansion time.""" + procedure_id = UUID("01900000-0000-7000-8000-000005820001") + recipe_id = UUID("01900000-0000-7000-8000-000005820004") + capability_id = UUID("01900000-0000-7000-8000-000005820005") + ids = [procedure_id] + [UUID(int=0x01900000_0000_7000_8000_000005820100 + i) for i in range(20)] + deps = build_postgres_deps(db_pool, now=_NOW, ids=ids) + await seed_capability_postgres(deps.event_store, capability_id) + # Recipe v1-implicit (RecipeDefined only). + await _seed_recipe_event( + deps.event_store, + recipe_id, + 0, + RecipeDefined( + recipe_id=recipe_id, + name="R", + capability_id=capability_id, + steps=(RecipeSetpointStep(address="dev:noop", value=1.0),), + occurred_at=_NOW, + ), + ) + + handlers = wire_operation(deps) + await handlers.register_procedure_from_recipe( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=recipe_id, + bindings={}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # After registration: bump the Recipe with a different step body. + # The pinned recipe_version on RecipeExpansionRecorded is None + # (Recipe was in Defined state at expansion time), so replay must + # resolve to the post-genesis snapshot, NOT the current state. + await _seed_recipe_event( + deps.event_store, + recipe_id, + 1, + RecipeVersioned( + recipe_id=recipe_id, + version_tag="v2", + steps=(RecipeSetpointStep(address="dev:OTHER", value=999.0),), + occurred_at=_NOW, + ), + ) + + result = await handlers.conduct_procedure( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + # Replay resolved against the PINNED snapshot (recipe_version=None + # = post-genesis state), so the Conductor walked the v1 step + # (`dev:noop`), NOT the v2 step (`dev:OTHER` value 999.0). The + # in-memory ControlPort fails on the address but the source_kind + # confirms we reached the Conductor with the v1 step. + assert result.procedure_id == procedure_id + events, _version = await deps.event_store.load("Procedure", procedure_id) + event_types = [e.event_type for e in events] + assert event_types[:2] == ["ProcedureRegistered", "RecipeExpansionRecorded"] + assert "ProcedureStarted" in event_types + if result.failure is not None: + # Failure target confirms which step the Conductor walked: the + # pinned v1 step, NOT the post-version_recipe v2 step. A + # RecipeExpansionReplayMismatchError would have raised earlier, + # never reaching Conductor. + assert "OTHER" not in (result.failure.target or "") diff --git a/apps/api/tests/unit/operation/test_conduct_procedure_handler.py b/apps/api/tests/unit/operation/test_conduct_procedure_handler.py index 65d5449e0..6970b5efb 100644 --- a/apps/api/tests/unit/operation/test_conduct_procedure_handler.py +++ b/apps/api/tests/unit/operation/test_conduct_procedure_handler.py @@ -16,14 +16,25 @@ from collections.abc import Sequence from dataclasses import dataclass, field +from datetime import UTC, datetime from typing import Any from uuid import UUID, uuid4 import pytest +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.infrastructure.ports import Allow, Deny from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.operation.adapters.in_memory_recipe_expansion_port import ( + InMemoryRecipeExpansionPort, +) +from cora.operation.aggregates.procedure import ( + ProcedureRegistered, + event_type_name, + to_payload, +) from cora.operation.conductor import ( ActionStep, CheckStep, @@ -47,6 +58,41 @@ step_from_wire, ) +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +async def _seed_procedure(store: InMemoryEventStore, procedure_id: UUID) -> None: + """Seed a legacy (no-recipe) Procedure so load_procedure_with_events + returns non-None and the conduct_procedure handler takes the legacy + (caller-supplied steps) branch per [[project-run-procedure-replay-design]].""" + event = ProcedureRegistered( + procedure_id=procedure_id, + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + capability_id=None, + recipe_id=None, + occurred_at=_NOW, + ) + await store.append( + stream_type="Procedure", + stream_id=procedure_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="seed", + correlation_id=uuid4(), + causation_id=None, + principal_id=uuid4(), + ), + ], + ) + @dataclass class _FakeAuthz: @@ -108,14 +154,19 @@ async def conduct( return self.result -def _deps(authz: _FakeAuthz) -> Kernel: - """Minimal Kernel-shaped stub; only `.authz` is exercised.""" +def _deps(authz: _FakeAuthz, event_store: InMemoryEventStore | None = None) -> Kernel: + """Minimal Kernel-shaped stub: authz + event_store (the conduct_procedure + handler reads `deps.event_store` for `load_procedure_with_events` + per [[project-run-procedure-replay-design]] Step 8).""" @dataclass class _MinimalKernel: authz: _FakeAuthz + event_store: InMemoryEventStore - return _MinimalKernel(authz=authz) # type: ignore[return-value] + return _MinimalKernel( # type: ignore[return-value] + authz=authz, event_store=event_store or InMemoryEventStore() + ) # --- handler dispatch --------------------------------------------------- @@ -128,8 +179,14 @@ async def test_conduct_procedure_handler_dispatches_to_conductor_with_envelope() correlation_id = uuid4() causation_id = uuid4() surface_id = uuid4() + store = InMemoryEventStore() + await _seed_procedure(store, procedure_id) conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=2)) - handler = bind(_deps(_FakeAuthz()), conductor=conductor) # type: ignore[arg-type] + handler = bind( + _deps(_FakeAuthz(), store), # type: ignore[arg-type] + conductor=conductor, # type: ignore[arg-type] + expansion_port=InMemoryRecipeExpansionPort(), + ) steps: tuple[Step, ...] = ( SetpointStep(address="2bma:rot:val", value=45.0), SetpointStep(address="2bma:cam:exposure", value=0.025), @@ -166,10 +223,16 @@ async def test_conduct_procedure_handler_propagates_failure_from_conductor() -> error_class="ControlNotConnectedError", message="Control address '2bma:rot:val' not connected", ) + store = InMemoryEventStore() + await _seed_procedure(store, procedure_id) conductor = _FakeConductor( result=ConductorResult(procedure_id=procedure_id, completed_count=0, failure=failure) ) - handler = bind(_deps(_FakeAuthz()), conductor=conductor) # type: ignore[arg-type] + handler = bind( + _deps(_FakeAuthz(), store), # type: ignore[arg-type] + conductor=conductor, # type: ignore[arg-type] + expansion_port=InMemoryRecipeExpansionPort(), + ) result = await handler( ConductProcedure(procedure_id=procedure_id, steps=()), principal_id=uuid4(), @@ -182,7 +245,11 @@ async def test_conduct_procedure_handler_propagates_failure_from_conductor() -> @pytest.mark.unit async def test_conduct_procedure_handler_raises_unauthorized_when_authz_denies() -> None: conductor = _FakeConductor(result=ConductorResult(procedure_id=uuid4(), completed_count=0)) - handler = bind(_deps(_FakeAuthz(deny_reason="no permission")), conductor=conductor) # type: ignore[arg-type] + handler = bind( + _deps(_FakeAuthz(deny_reason="no permission")), # type: ignore[arg-type] + conductor=conductor, # type: ignore[arg-type] + expansion_port=InMemoryRecipeExpansionPort(), + ) with pytest.raises(UnauthorizedError, match="no permission"): await handler( ConductProcedure(procedure_id=uuid4(), steps=()), @@ -380,3 +447,304 @@ def test_conduct_procedure_request_with_empty_step_list_is_valid() -> None: def test_conduct_procedure_request_default_is_empty_step_list() -> None: body = ConductProcedureRequest.model_validate({}) assert body.steps == [] + + +# --- recipe-replay branch -------------------------------------- +# +# These tests pin the recipe-driven branch of `conduct_procedure` per +# [[project-run-procedure-replay-design]]. Each test seeds a Procedure +# stream carrying both `ProcedureRegistered(recipe_id=...)` and +# `RecipeExpansionRecorded(...)`; the test-only knob is whatever payload +# field needs to drift to trigger the rejection. + +import hashlib # noqa: E402 + +from cora.infrastructure.canonical_json import canonical_json_bytes # noqa: E402 +from cora.operation._recipe_expansion import steps_to_wire # noqa: E402 +from cora.operation.aggregates.procedure import ( # noqa: E402 + ProcedureNotFoundError, + ProcedureStepsForbiddenForRecipeDrivenError, + RecipeExpansionPortVersionMismatchError, + RecipeExpansionRecorded, + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, +) +from cora.recipe.aggregates.recipe import ( # noqa: E402 + RecipeDefined, + RecipeSetpointStep, +) +from cora.recipe.aggregates.recipe import event_type_name as recipe_event_type_name # noqa: E402 +from cora.recipe.aggregates.recipe import to_payload as recipe_to_payload # noqa: E402 + + +async def _seed_recipe_driven_procedure( + store: InMemoryEventStore, + procedure_id: UUID, + recipe_id: UUID, + *, + bindings: dict[str, object] | None = None, + recipe_steps: tuple[RecipeSetpointStep, ...] | None = None, + expansion_port_version: str = "v1", + bindings_hash_override: str | None = None, + steps_hash_override: str | None = None, + omit_recipe_expansion_recorded: bool = False, +) -> None: + """Seed both events of the 2-event genesis block emitted by + register_procedure_from_recipe, optionally drifting one of the pins.""" + capability_id = uuid4() + binds = bindings if bindings is not None else {"angle": 30.0} + rsteps = ( + recipe_steps + if recipe_steps is not None + else (RecipeSetpointStep(address="dev:x", value=1.0),) + ) + # Also seed the Recipe stream so load_recipe_at_version succeeds. + recipe_event = RecipeDefined( + recipe_id=recipe_id, + name="R", + capability_id=capability_id, + steps=rsteps, + occurred_at=_NOW, + ) + await store.append( + stream_type="Recipe", + stream_id=recipe_id, + expected_version=0, + events=[ + to_new_event( + event_type=recipe_event_type_name(recipe_event), + payload=recipe_to_payload(recipe_event), + occurred_at=_NOW, + event_id=uuid4(), + command_name="seed", + correlation_id=uuid4(), + causation_id=None, + principal_id=uuid4(), + ), + ], + ) + # Compute the expected hashes via the SAME canonicalizer the at-write + # decider uses; tests can override either to trigger drift assertions. + expected_bindings_hash = hashlib.sha256(canonical_json_bytes(dict(binds))).hexdigest() + # Tests in this file use only literal values (no BindingRef), so the + # cast to Conductor's narrower Step.value union is safe; the recipe- + # expansion bridge does the same translation at run time. + expanded_for_hash: tuple[Step, ...] = tuple( + SetpointStep(address=s.address, value=s.value) # type: ignore[arg-type] + for s in rsteps + ) + expected_steps_hash = hashlib.sha256( + canonical_json_bytes(steps_to_wire(expanded_for_hash)) + ).hexdigest() + registered = ProcedureRegistered( + procedure_id=procedure_id, + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + capability_id=capability_id, + recipe_id=recipe_id, + occurred_at=_NOW, + ) + procedure_events = [registered] + if not omit_recipe_expansion_recorded: + recorded = RecipeExpansionRecorded( + procedure_id=procedure_id, + recipe_id=recipe_id, + recipe_version=None, + capability_id=capability_id, + capability_version=None, + bindings=binds, + expansion_port_version=expansion_port_version, + steps_hash=steps_hash_override or expected_steps_hash, + bindings_hash=bindings_hash_override or expected_bindings_hash, + step_count=len(rsteps), + occurred_at=_NOW, + ) + procedure_events.append(recorded) # type: ignore[arg-type] + new_events = [ + to_new_event( + event_type=event_type_name(event), # type: ignore[arg-type] + payload=to_payload(event), # type: ignore[arg-type] + occurred_at=_NOW, + event_id=uuid4(), + command_name="seed", + correlation_id=uuid4(), + causation_id=None, + principal_id=uuid4(), + ) + for event in procedure_events + ] + await store.append( + stream_type="Procedure", + stream_id=procedure_id, + expected_version=0, + events=new_events, + ) + + +def _bind_handler( + store: InMemoryEventStore, + conductor: "_FakeConductor", + *, + expansion_port: InMemoryRecipeExpansionPort | None = None, +) -> Any: + return bind( + _deps(_FakeAuthz(), store), # type: ignore[arg-type] + conductor=conductor, # type: ignore[arg-type] + expansion_port=expansion_port or InMemoryRecipeExpansionPort(), + ) + + +@pytest.mark.unit +async def test_conduct_procedure_legacy_procedure_uses_caller_supplied_steps_unchanged() -> None: + procedure_id = uuid4() + store = InMemoryEventStore() + await _seed_procedure(store, procedure_id) # recipe_id is None + conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=1)) + handler = _bind_handler(store, conductor) + steps = (SetpointStep(address="dev:caller", value=99.0),) + await handler( + ConductProcedure(procedure_id=procedure_id, steps=steps), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert conductor.calls[0].steps == steps + + +@pytest.mark.unit +async def test_conduct_procedure_recipe_driven_procedure_uses_re_expanded_steps() -> None: + procedure_id = uuid4() + recipe_id = uuid4() + store = InMemoryEventStore() + await _seed_recipe_driven_procedure(store, procedure_id, recipe_id) + conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=1)) + handler = _bind_handler(store, conductor) + await handler( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert conductor.calls[0].steps == (SetpointStep(address="dev:x", value=1.0),) + + +@pytest.mark.unit +async def test_recipe_driven_handler_with_non_empty_caller_steps_raises_forbidden() -> None: + procedure_id = uuid4() + recipe_id = uuid4() + store = InMemoryEventStore() + await _seed_recipe_driven_procedure(store, procedure_id, recipe_id) + conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=0)) + handler = _bind_handler(store, conductor) + with pytest.raises(ProcedureStepsForbiddenForRecipeDrivenError) as exc: + await handler( + ConductProcedure( + procedure_id=procedure_id, + steps=(SetpointStep(address="dev:caller", value=99.0),), + ), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert exc.value.procedure_id == procedure_id + assert conductor.calls == [] + + +@pytest.mark.unit +async def test_recipe_driven_handler_with_missing_expansion_record_raises_not_found() -> None: + procedure_id = uuid4() + recipe_id = uuid4() + store = InMemoryEventStore() + await _seed_recipe_driven_procedure( + store, procedure_id, recipe_id, omit_recipe_expansion_recorded=True + ) + conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=0)) + handler = _bind_handler(store, conductor) + with pytest.raises(RecipeExpansionRecordNotFoundError) as exc: + await handler( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert exc.value.procedure_id == procedure_id + + +@pytest.mark.unit +async def test_recipe_driven_handler_with_port_version_mismatch_raises_port_error() -> None: + """Dataclass `version` field supports `InMemoryRecipeExpansionPort(version='v2')` + so we can stage a v2 port against a v1-pinned event without inventing a second adapter.""" + procedure_id = uuid4() + recipe_id = uuid4() + store = InMemoryEventStore() + await _seed_recipe_driven_procedure(store, procedure_id, recipe_id) # pinned at v1 + conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=0)) + handler = _bind_handler( + store, conductor, expansion_port=InMemoryRecipeExpansionPort(version="v2") + ) + with pytest.raises(RecipeExpansionPortVersionMismatchError) as exc: + await handler( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert exc.value.procedure_id == procedure_id + assert exc.value.recorded_version == "v1" + assert exc.value.current_version == "v2" + + +@pytest.mark.unit +async def test_recipe_driven_handler_with_bindings_drift_raises_bindings_mismatch() -> None: + procedure_id = uuid4() + recipe_id = uuid4() + store = InMemoryEventStore() + await _seed_recipe_driven_procedure( + store, procedure_id, recipe_id, bindings_hash_override="0" * 64 + ) + conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=0)) + handler = _bind_handler(store, conductor) + with pytest.raises(RecipeExpansionReplayMismatchError) as exc: + await handler( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert exc.value.procedure_id == procedure_id + assert exc.value.mismatch_field == "bindings" + + +@pytest.mark.unit +async def test_recipe_driven_handler_with_steps_drift_raises_steps_mismatch() -> None: + procedure_id = uuid4() + recipe_id = uuid4() + store = InMemoryEventStore() + await _seed_recipe_driven_procedure( + store, procedure_id, recipe_id, steps_hash_override="0" * 64 + ) + conductor = _FakeConductor(result=ConductorResult(procedure_id=procedure_id, completed_count=0)) + handler = _bind_handler(store, conductor) + with pytest.raises(RecipeExpansionReplayMismatchError) as exc: + await handler( + ConductProcedure(procedure_id=procedure_id, steps=()), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert exc.value.procedure_id == procedure_id + assert exc.value.mismatch_field == "steps" + + +@pytest.mark.unit +async def test_conduct_procedure_with_unregistered_procedure_raises_procedure_not_found_error() -> ( + None +): + """Added `load_procedure_with_events` at handler entry; the + handler raises ProcedureNotFoundError before hitting the Conductor. + Aligns with the route-tier 404 mapping (was: 200 + lifecycle-failure).""" + store = InMemoryEventStore() + conductor = _FakeConductor(result=ConductorResult(procedure_id=uuid4(), completed_count=0)) + handler = _bind_handler(store, conductor) + with pytest.raises(ProcedureNotFoundError): + await handler( + ConductProcedure(procedure_id=uuid4(), steps=()), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert conductor.calls == [] diff --git a/apps/api/tests/unit/operation/test_load_procedure_with_events.py b/apps/api/tests/unit/operation/test_load_procedure_with_events.py new file mode 100644 index 000000000..07c2c5868 --- /dev/null +++ b/apps/api/tests/unit/operation/test_load_procedure_with_events.py @@ -0,0 +1,129 @@ +"""Unit tests for `load_procedure_with_events`. + +Per [[project-run-procedure-replay-design]] §Operation BC seam +additions. The helper returns both the folded Procedure state AND +the raw StoredEvent list from a single `event_store.load` call; +`load_procedure` becomes a thin wrapper that discards the events. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.ports.event_store import StoredEvent +from cora.operation.aggregates.procedure import ( + ProcedureRegistered, + event_type_name, + load_procedure, + load_procedure_with_events, + to_payload, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _seed_registered( + store: InMemoryEventStore, + procedure_id: UUID, +) -> None: + event = ProcedureRegistered( + procedure_id=procedure_id, + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + capability_id=None, + recipe_id=None, + occurred_at=_NOW, + ) + await store.append( + stream_type="Procedure", + stream_id=procedure_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="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ), + ], + ) + + +@pytest.mark.unit +async def test_load_procedure_with_events_returns_folded_state_and_raw_event_list() -> None: + store = InMemoryEventStore() + procedure_id = uuid4() + await _seed_registered(store, procedure_id) + + state, events = await load_procedure_with_events(store, procedure_id) + + assert state is not None + assert state.id == procedure_id + assert isinstance(events, list) + assert len(events) == 1 + assert isinstance(events[0], StoredEvent) + assert events[0].event_type == "ProcedureRegistered" + + +@pytest.mark.unit +async def test_load_procedure_with_events_with_empty_stream_returns_none_and_empty_list() -> None: + store = InMemoryEventStore() + + state, events = await load_procedure_with_events(store, uuid4()) + + assert state is None + assert events == [] + + +@pytest.mark.unit +async def test_load_procedure_returns_same_state_as_load_procedure_with_events_tuple_first() -> ( + None +): + """Wrapper-parity: legacy `load_procedure` returns the same state + as the first element of `load_procedure_with_events`'s tuple.""" + store = InMemoryEventStore() + procedure_id = uuid4() + await _seed_registered(store, procedure_id) + + state_a = await load_procedure(store, procedure_id) + state_b, _events = await load_procedure_with_events(store, procedure_id) + + assert state_a == state_b + + +@pytest.mark.unit +async def test_load_procedure_with_events_uses_single_event_store_load_call() -> None: + """Single underlying `event_store.load` call: a counter-spy + asserts the helper does not double-IO.""" + + class _CountingStore: + def __init__(self, inner: InMemoryEventStore) -> None: + self._inner = inner + self.load_calls = 0 + + async def load(self, stream_type: str, stream_id: UUID) -> tuple[list[StoredEvent], int]: + self.load_calls += 1 + return await self._inner.load(stream_type, stream_id) + + async def append(self, **kwargs: object) -> None: # type: ignore[override] + await self._inner.append(**kwargs) # type: ignore[arg-type] + + inner = InMemoryEventStore() + procedure_id = uuid4() + await _seed_registered(inner, procedure_id) + counting = _CountingStore(inner) + + await load_procedure_with_events(counting, procedure_id) # type: ignore[arg-type] + + assert counting.load_calls == 1 diff --git a/apps/api/tests/unit/operation/test_procedure_events.py b/apps/api/tests/unit/operation/test_procedure_events.py index c8e63d7dd..22b565617 100644 --- a/apps/api/tests/unit/operation/test_procedure_events.py +++ b/apps/api/tests/unit/operation/test_procedure_events.py @@ -15,6 +15,7 @@ ProcedureStarted, ProcedureStepsLogbookOpened, ProcedureTruncated, + RecipeExpansionRecorded, event_type_name, from_stored, to_payload, @@ -74,9 +75,15 @@ def test_to_payload_serializes_procedure_registered_to_primitives() -> None: # Sorted by string form for deterministic payload bytes. "target_asset_ids": sorted([str(asset1), str(asset2)]), "parent_run_id": str(parent_run), - # capability_id (default). Pre-10d streams omit the key and - # fold via `.get("capability_id")` in from_stored. + # capability_id (default). Pre-binding streams omit the key + # and fold via `.get("capability_id")` in from_stored. "capability_id": None, + # recipe_id (default). Pre-Recipe-rewrite streams omit the + # key and fold via `.get("recipe_id")` in from_stored. + # `register_procedure_from_recipe` sets both `recipe_id` and + # the denorm `capability_id`; the legacy `register_procedure` + # slice leaves both None. + "recipe_id": None, "occurred_at": _NOW.isoformat(), } @@ -509,6 +516,36 @@ def test_procedure_truncated_round_trips() -> None: assert rebuilt == original +@pytest.mark.unit +def test_to_payload_bindings_field_is_dict_after_canonical_json_hoist() -> None: + """The events.py `to_payload(RecipeExpansionRecorded)` arm uses + `json.loads(canonical_json_bytes(dict(bindings)))` so the persisted + payload `bindings` field stays a dict (the shape `from_stored`'s + `dict(payload['bindings'])` consumer expects). A future refactor + that drops the `json.loads(...)` wrapper (e.g., `.decode('utf-8')`) + would persist a JSON string and break round-trip; this test pins + the dict shape. + """ + event = RecipeExpansionRecorded( + procedure_id=uuid4(), + recipe_id=uuid4(), + recipe_version="v1", + capability_id=uuid4(), + capability_version=None, + bindings={"beta": 2.0, "alpha": 1.0}, + expansion_port_version="v1", + steps_hash="aaaa", + bindings_hash="bbbb", + step_count=1, + occurred_at=_NOW, + ) + payload = to_payload(event) + assert isinstance(payload["bindings"], dict) + assert payload["bindings"] == {"alpha": 1.0, "beta": 2.0} + rebuilt = from_stored(_stored("RecipeExpansionRecorded", payload)) + assert rebuilt.bindings == event.bindings # type: ignore[union-attr] + + @pytest.mark.unit @pytest.mark.parametrize( "event_type", diff --git a/apps/api/tests/unit/operation/test_recipe_replay.py b/apps/api/tests/unit/operation/test_recipe_replay.py new file mode 100644 index 000000000..18bca1875 --- /dev/null +++ b/apps/api/tests/unit/operation/test_recipe_replay.py @@ -0,0 +1,188 @@ +"""Unit tests for the `_recipe_replay` helpers. + +Per [[project-run-procedure-replay-design]] §Operation BC seam +additions. The helpers locate the genesis `RecipeExpansionRecorded` +event in a Procedure stream, extract the pinned hash+bindings tuple, +and verify a freshly re-expanded `tuple[Step, ...]` matches the pins. +""" + +import hashlib +import json +from collections.abc import Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.infrastructure.canonical_json import canonical_json_bytes +from cora.infrastructure.ports.event_store import StoredEvent +from cora.operation._recipe_expansion import steps_to_wire +from cora.operation._recipe_replay import ( + RecipeExpansionPins, + find_recipe_expansion_record, + pins_from_payload, + verify_bindings_hash, + verify_steps_hash, +) +from cora.operation.aggregates.procedure import ( + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, +) +from cora.operation.conductor import SetpointStep + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PROCEDURE_ID = UUID("01900000-0000-7000-8000-000000000099") + + +def _stored(event_type: str, payload: dict[str, object]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Procedure", + stream_id=_PROCEDURE_ID, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +def _pins(bindings: dict[str, object] | None = None) -> RecipeExpansionPins: + binds: dict[str, object] = bindings if bindings is not None else {"a": 1.0} + bindings_hash = hashlib.sha256(canonical_json_bytes(dict(binds))).hexdigest() + steps_hash = hashlib.sha256( + canonical_json_bytes(steps_to_wire((SetpointStep(address="dev:x", value=1.0),))) + ).hexdigest() + return RecipeExpansionPins( + recipe_version="v1", + bindings=binds, + bindings_hash=bindings_hash, + steps_hash=steps_hash, + expansion_port_version="v1", + ) + + +@pytest.mark.unit +def test_find_recipe_expansion_record_in_well_formed_stream_lands_at_index_one() -> None: + """In well-formed Recipe-driven streams emitted by + register_procedure_from_recipe, the match is the SECOND event + (index 1) of the 2-event genesis block.""" + stream = [ + _stored("ProcedureRegistered", {}), + _stored("RecipeExpansionRecorded", {"hint": "match"}), + _stored("ProcedureStarted", {}), + ] + match = find_recipe_expansion_record(stream) + assert match is not None + assert match is stream[1] + + +@pytest.mark.unit +def test_find_recipe_expansion_record_with_two_matches_returns_first_match() -> None: + stream = [ + _stored("ProcedureRegistered", {}), + _stored("RecipeExpansionRecorded", {"hint": "first"}), + _stored("RecipeExpansionRecorded", {"hint": "second"}), + ] + match = find_recipe_expansion_record(stream) + assert match is not None + assert match.payload == {"hint": "first"} + + +@pytest.mark.unit +def test_find_recipe_expansion_record_with_first_match_does_not_scan_subsequent_events() -> None: + """Early-exit: a generator that raises if consumed past the first + match confirms the helper stops scanning.""" + + def _generator() -> Iterator[StoredEvent]: + yield _stored("ProcedureRegistered", {}) + yield _stored("RecipeExpansionRecorded", {"hint": "match"}) + raise AssertionError("scanner consumed past first match") + + match = find_recipe_expansion_record(_generator()) + assert match is not None + assert match.event_type == "RecipeExpansionRecorded" + + +@pytest.mark.unit +def test_find_recipe_expansion_record_with_no_match_returns_none() -> None: + stream = [_stored("ProcedureRegistered", {}), _stored("ProcedureStarted", {})] + assert find_recipe_expansion_record(stream) is None + + +@pytest.mark.unit +@pytest.mark.parametrize( + "missing_key", + ["bindings", "bindings_hash", "expansion_port_version", "steps_hash"], +) +def test_pins_from_payload_raises_when_required_key_missing(missing_key: str) -> None: + """Parametrized: each of the 4 required keys must surface + RecipeExpansionRecordNotFoundError when absent.""" + full_payload: dict[str, object] = { + "bindings": {"a": 1.0}, + "bindings_hash": "abc", + "expansion_port_version": "v1", + "steps_hash": "def", + "recipe_version": "v1", + } + payload = {k: v for k, v in full_payload.items() if k != missing_key} + with pytest.raises(RecipeExpansionRecordNotFoundError) as exc: + pins_from_payload(_PROCEDURE_ID, payload) + assert exc.value.procedure_id == _PROCEDURE_ID + + +@pytest.mark.unit +def test_verify_bindings_hash_with_matching_hash_returns_none() -> None: + pins = _pins() + assert verify_bindings_hash(_PROCEDURE_ID, pins) is None + + +@pytest.mark.unit +def test_verify_bindings_hash_with_mismatch_raises_with_bindings_discriminator() -> None: + base = _pins() + drifted = RecipeExpansionPins( + recipe_version=base.recipe_version, + bindings=base.bindings, + bindings_hash="0" * 64, + steps_hash=base.steps_hash, + expansion_port_version=base.expansion_port_version, + ) + with pytest.raises(RecipeExpansionReplayMismatchError) as exc: + verify_bindings_hash(_PROCEDURE_ID, drifted) + assert exc.value.procedure_id == _PROCEDURE_ID + assert exc.value.mismatch_field == "bindings" + + +@pytest.mark.unit +def test_verify_steps_hash_with_matching_hash_returns_none() -> None: + pins = _pins() + steps = (SetpointStep(address="dev:x", value=1.0),) + assert verify_steps_hash(_PROCEDURE_ID, steps, pins) is None + + +@pytest.mark.unit +def test_verify_steps_hash_with_mismatch_raises_with_steps_discriminator() -> None: + pins = _pins() + drifted_steps = (SetpointStep(address="dev:x", value=999.0),) + with pytest.raises(RecipeExpansionReplayMismatchError) as exc: + verify_steps_hash(_PROCEDURE_ID, drifted_steps, pins) + assert exc.value.procedure_id == _PROCEDURE_ID + assert exc.value.mismatch_field == "steps" + + +@pytest.mark.unit +def test_verify_bindings_hash_uses_canonical_json_bytes_byte_equal_to_at_write() -> None: + """Bindings hash reproduces against the EXACT same canonical-JSON + bytes the at-write decider used (single-source via + cora.infrastructure.canonical_json). A divergence would silently + break replay verification for in-flight Procedures.""" + bindings = {"beta": 2.0, "alpha": 1.0} + direct = hashlib.sha256( + json.dumps(bindings, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + via_helper = hashlib.sha256(canonical_json_bytes(dict(bindings))).hexdigest() + assert direct == via_helper diff --git a/apps/api/tests/unit/operation/test_register_procedure_from_recipe_decider.py b/apps/api/tests/unit/operation/test_register_procedure_from_recipe_decider.py new file mode 100644 index 000000000..189cdafc4 --- /dev/null +++ b/apps/api/tests/unit/operation/test_register_procedure_from_recipe_decider.py @@ -0,0 +1,274 @@ +"""Unit tests for the `register_procedure_from_recipe` slice's pure decider.""" + +from collections.abc import Mapping +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from cora.operation._recipe_expansion import expand +from cora.operation.adapters.in_memory_recipe_expansion_port import ( + InMemoryRecipeExpansionPort, +) +from cora.operation.aggregates.procedure import ( + InvalidRecipeBindingsError, + Procedure, + ProcedureAlreadyExistsError, + ProcedureCapabilityExecutorMismatchError, + ProcedureName, + ProcedureRegistered, + ProcedureStatus, + RecipeExpansionDeterminismError, + RecipeExpansionOverflowError, + RecipeExpansionRecorded, +) +from cora.operation.conductor import Step +from cora.operation.features.register_procedure_from_recipe import ( + RegisterProcedureFromRecipe, + decide, +) +from cora.recipe.aggregates.capability import ( + Capability, + CapabilityCode, + CapabilityName, + ExecutorShape, +) +from cora.recipe.aggregates.recipe import ( + Recipe, + RecipeName, + RecipeSetpointStep, + RecipeStatus, + RecipeStep, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _capability( + *, + shapes: frozenset[ExecutorShape] | None = None, + parameters_schema: dict[str, object] | None = None, +) -> Capability: + return Capability( + id=uuid4(), + code=CapabilityCode("cora.capability.test"), + name=CapabilityName("Test"), + status=__import__( + "cora.recipe.aggregates.capability", fromlist=["CapabilityStatus"] + ).CapabilityStatus.DEFINED, + executor_shapes=shapes or frozenset({ExecutorShape.PROCEDURE}), + parameters_schema=parameters_schema, + ) + + +def _recipe(capability_id: UUID) -> Recipe: + return Recipe( + id=uuid4(), + name=RecipeName("R"), + capability_id=capability_id, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + status=RecipeStatus.DEFINED, + ) + + +def _cmd(recipe_id: UUID) -> RegisterProcedureFromRecipe: + return RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=recipe_id, + bindings={}, + ) + + +@pytest.mark.unit +def test_decide_emits_registered_plus_recipe_expansion_recorded() -> None: + cap = _capability() + recipe = _recipe(cap.id) + new_id = uuid4() + events = decide( + state=None, + command=_cmd(recipe.id), + recipe=recipe, + capability=cap, + expansion_port=InMemoryRecipeExpansionPort(), + now=_NOW, + new_id=new_id, + ) + assert len(events) == 2 + reg, prov = events + assert isinstance(reg, ProcedureRegistered) + assert reg.procedure_id == new_id + assert reg.recipe_id == recipe.id + assert reg.capability_id == cap.id + assert isinstance(prov, RecipeExpansionRecorded) + assert prov.recipe_id == recipe.id + assert prov.capability_id == cap.id + assert prov.expansion_port_version == "v1" + assert prov.step_count == 1 + + +@pytest.mark.unit +def test_decide_raises_already_exists_when_state_present() -> None: + cap = _capability() + recipe = _recipe(cap.id) + existing = Procedure( + id=uuid4(), + name=ProcedureName("X"), + kind="K", + target_asset_ids=frozenset(), + status=ProcedureStatus.DEFINED, + parent_run_id=None, + steps_logbook_id=None, + ) + with pytest.raises(ProcedureAlreadyExistsError): + decide( + state=existing, + command=_cmd(recipe.id), + recipe=recipe, + capability=cap, + expansion_port=InMemoryRecipeExpansionPort(), + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_raises_executor_mismatch_when_capability_excludes_procedure() -> None: + cap = _capability(shapes=frozenset({ExecutorShape.METHOD})) + recipe = _recipe(cap.id) + with pytest.raises(ProcedureCapabilityExecutorMismatchError): + decide( + state=None, + command=_cmd(recipe.id), + recipe=recipe, + capability=cap, + expansion_port=InMemoryRecipeExpansionPort(), + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_raises_invalid_bindings_when_values_fail_schema() -> None: + schema: dict[str, object] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"angle": {"type": "number"}}, + "required": ["angle"], + } + cap = _capability(parameters_schema=schema) + recipe = _recipe(cap.id) + cmd = RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=recipe.id, + bindings={"angle": "not-a-number"}, + ) + with pytest.raises(InvalidRecipeBindingsError): + decide( + state=None, + command=cmd, + recipe=recipe, + capability=cap, + expansion_port=InMemoryRecipeExpansionPort(), + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_raises_overflow_when_expansion_exceeds_cap() -> None: + cap = _capability() + big_recipe = Recipe( + id=uuid4(), + name=RecipeName("Big"), + capability_id=cap.id, + steps=tuple(RecipeSetpointStep(address=f"dev:{i}", value=float(i)) for i in range(3)), + ) + + class _FakeOverflowPort: + version = "v1" + + def expand( + self, + steps: tuple[RecipeStep, ...], + bindings: Mapping[str, Any], + ) -> tuple[Step, ...]: + _ = steps, bindings + from cora.operation.conductor import SetpointStep + + return tuple(SetpointStep(address=f"x:{i}", value=i) for i in range(10_001)) + + with pytest.raises(RecipeExpansionOverflowError) as exc: + decide( + state=None, + command=_cmd(big_recipe.id), + recipe=big_recipe, + capability=cap, + expansion_port=_FakeOverflowPort(), # type: ignore[arg-type] + now=_NOW, + new_id=uuid4(), + ) + assert exc.value.step_count == 10_001 + assert exc.value.cap == 10_000 + + +@pytest.mark.unit +def test_decide_raises_determinism_error_when_expansions_differ() -> None: + cap = _capability() + recipe = _recipe(cap.id) + + class _NonDeterministicPort: + version = "v1" + _calls = 0 + + def expand( + self, + steps: tuple[RecipeStep, ...], + bindings: Mapping[str, Any], + ) -> tuple[Step, ...]: + _ = steps, bindings + self._calls += 1 + from cora.operation.conductor import SetpointStep + + return (SetpointStep(address=f"call:{self._calls}", value=1.0),) + + with pytest.raises(RecipeExpansionDeterminismError) as exc: + decide( + state=None, + command=_cmd(recipe.id), + recipe=recipe, + capability=cap, + expansion_port=_NonDeterministicPort(), # type: ignore[arg-type] + now=_NOW, + new_id=uuid4(), + ) + assert exc.value.recipe_id == recipe.id + + +@pytest.mark.unit +def test_decide_with_real_expand_function_preserves_step_count() -> None: + """End-to-end sanity: the default `expand` is pure + matches the 1-step Recipe.""" + cap = _capability() + recipe = _recipe(cap.id) + # Direct sanity check on the bridge. + expanded = expand(recipe.steps, {}) + assert len(expanded) == 1 + # And via the decider: + events = decide( + state=None, + command=_cmd(recipe.id), + recipe=recipe, + capability=cap, + expansion_port=InMemoryRecipeExpansionPort(), + now=_NOW, + new_id=uuid4(), + ) + prov = events[1] + assert isinstance(prov, RecipeExpansionRecorded) + assert prov.step_count == 1 diff --git a/apps/api/tests/unit/operation/test_register_procedure_from_recipe_handler.py b/apps/api/tests/unit/operation/test_register_procedure_from_recipe_handler.py new file mode 100644 index 000000000..0b7ad49e6 --- /dev/null +++ b/apps/api/tests/unit/operation/test_register_procedure_from_recipe_handler.py @@ -0,0 +1,279 @@ +"""Unit tests for the `register_procedure_from_recipe` application handler. + +Covers the load-Recipe + load-Capability cross-aggregate fan-out plus +the BindingRef-stale guard (anti-hook 5 expansion-time half). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +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.operation.adapters.in_memory_recipe_expansion_port import ( + InMemoryRecipeExpansionPort, +) +from cora.operation.aggregates.procedure import ( + RecipeBindingsStaleAgainstCurrentCapabilityError, +) +from cora.operation.errors import UnauthorizedError +from cora.operation.features import register_procedure_from_recipe +from cora.operation.features.register_procedure_from_recipe import ( + RegisterProcedureFromRecipe, +) +from cora.recipe.aggregates.capability import CapabilityNotFoundError +from cora.recipe.aggregates.recipe import ( + BindingRef, + RecipeDefined, + RecipeNotFoundError, + RecipeSetpointStep, + event_type_name, + to_payload, +) +from tests.unit._helpers import build_deps, seed_capability + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_NEW_ID = UUID("01900000-0000-7000-8000-00000000ad01") +_EVENT_ID_A = UUID("01900000-0000-7000-8000-00000000ad02") +_EVENT_ID_B = UUID("01900000-0000-7000-8000-00000000ad03") +_RECIPE_ID = UUID("01900000-0000-7000-8000-00000000af01") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-00000000af02") + + +async def _seed_capability_with_schema( + store: InMemoryEventStore, + capability_id: UUID, + schema: dict[str, object] | None, +) -> None: + """Seed a Capability with an explicit parameters_schema (the shared + helper does not expose this kwarg).""" + from cora.recipe.aggregates.capability import ( + CapabilityCode, + CapabilityDefined, + CapabilityName, + ExecutorShape, + ) + from cora.recipe.aggregates.capability import event_type_name as cap_etn + from cora.recipe.aggregates.capability import to_payload as cap_tp + + cap_event = CapabilityDefined( + capability_id=capability_id, + code=CapabilityCode("cora.capability.test").value, + name=CapabilityName("Test").value, + required_affordances=frozenset(), + executor_shapes=frozenset({ExecutorShape.METHOD, ExecutorShape.PROCEDURE}), + parameters_schema=schema, + occurred_at=_NOW, + ) + await store.append( + stream_type="Capability", + stream_id=capability_id, + expected_version=0, + events=[ + to_new_event( + event_type=cap_etn(cap_event), + payload=cap_tp(cap_event), + occurred_at=_NOW, + event_id=UUID("01900000-0000-7000-8000-00000000af03"), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + +async def _seed_recipe( + store: InMemoryEventStore, + recipe_id: UUID, + capability_id: UUID, + *, + with_binding: bool = False, +) -> None: + steps = ( + RecipeSetpointStep( + address="dev:x", + value=BindingRef("angle") if with_binding else 1.0, + ), + ) + event = RecipeDefined( + recipe_id=recipe_id, + name="R", + capability_id=capability_id, + steps=steps, + occurred_at=_NOW, + ) + await store.append( + stream_type="Recipe", + stream_id=recipe_id, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=_NOW, + event_id=UUID("01900000-0000-7000-8000-00000000af04"), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + +async def _build_seeded_deps(*, deny: bool = False) -> tuple[InMemoryEventStore, Kernel]: + store = InMemoryEventStore() + await seed_capability(store, _CAPABILITY_ID) + await _seed_recipe(store, _RECIPE_ID, _CAPABILITY_ID) + deps = build_deps( + ids=[_NEW_ID, _EVENT_ID_A, _EVENT_ID_B], + now=_NOW, + event_store=store, + deny=deny, + ) + return store, deps + + +@pytest.mark.unit +async def test_handler_returns_generated_procedure_id() -> None: + store, deps = await _build_seeded_deps() + handler = register_procedure_from_recipe.bind( + deps, expansion_port=InMemoryRecipeExpansionPort() + ) + + result = await handler( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=_RECIPE_ID, + bindings={}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result == _NEW_ID + + events, version = await store.load("Procedure", _NEW_ID) + assert version == 2 + assert events[0].event_type == "ProcedureRegistered" + assert events[0].payload["recipe_id"] == str(_RECIPE_ID) + assert events[0].payload["capability_id"] == str(_CAPABILITY_ID) + assert events[1].event_type == "RecipeExpansionRecorded" + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + _, deps = await _build_seeded_deps(deny=True) + handler = register_procedure_from_recipe.bind( + deps, expansion_port=InMemoryRecipeExpansionPort() + ) + with pytest.raises(UnauthorizedError): + await handler( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=_RECIPE_ID, + bindings={}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_recipe_not_found_when_stream_missing() -> None: + store = InMemoryEventStore() + await seed_capability(store, _CAPABILITY_ID) + deps = build_deps(ids=[_NEW_ID, _EVENT_ID_A], now=_NOW, event_store=store) + handler = register_procedure_from_recipe.bind( + deps, expansion_port=InMemoryRecipeExpansionPort() + ) + with pytest.raises(RecipeNotFoundError): + await handler( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=_RECIPE_ID, + bindings={}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_capability_not_found_when_recipe_points_at_missing() -> None: + """Recipe exists but its capability_id has no Capability stream.""" + store = InMemoryEventStore() + await _seed_recipe(store, _RECIPE_ID, _CAPABILITY_ID) + # Note: NO seed_capability call. + deps = build_deps(ids=[_NEW_ID, _EVENT_ID_A], now=_NOW, event_store=store) + handler = register_procedure_from_recipe.bind( + deps, expansion_port=InMemoryRecipeExpansionPort() + ) + with pytest.raises(CapabilityNotFoundError): + await handler( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=_RECIPE_ID, + bindings={}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_rejects_when_capability_schema_drifted_since_recipe_write() -> None: + """Anti-hook 5 expansion-time half: Capability was re-versioned to drop + a parameter the Recipe binds; handler rejects with + RecipeBindingsStaleAgainstCurrentCapabilityError.""" + store = InMemoryEventStore() + # Capability with NO `angle` parameter (the Recipe's binding name) + await _seed_capability_with_schema( + store, + _CAPABILITY_ID, + schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"energy": {"type": "number"}}, + }, + ) + # Recipe binds an `angle` BindingRef that no longer resolves. + await _seed_recipe(store, _RECIPE_ID, _CAPABILITY_ID, with_binding=True) + deps = build_deps(ids=[_NEW_ID, _EVENT_ID_A], now=_NOW, event_store=store) + handler = register_procedure_from_recipe.bind( + deps, expansion_port=InMemoryRecipeExpansionPort() + ) + with pytest.raises(RecipeBindingsStaleAgainstCurrentCapabilityError) as exc: + await handler( + RegisterProcedureFromRecipe( + name="P", + kind="bakeout", + target_asset_ids=(), + parent_run_id=None, + recipe_id=_RECIPE_ID, + bindings={"angle": 30.0}, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert "angle" in exc.value.missing_binding_names + procs, version = await store.load("Procedure", _NEW_ID) + assert procs == [] + assert version == 0 diff --git a/apps/api/tests/unit/operation/test_register_procedure_handler.py b/apps/api/tests/unit/operation/test_register_procedure_handler.py index 33988b509..a57fd1758 100644 --- a/apps/api/tests/unit/operation/test_register_procedure_handler.py +++ b/apps/api/tests/unit/operation/test_register_procedure_handler.py @@ -101,6 +101,7 @@ async def test_handler_appends_procedure_registered_event_to_store() -> None: "target_asset_ids": [str(_ASSET_ID)], "parent_run_id": None, "capability_id": None, + "recipe_id": None, "occurred_at": _NOW.isoformat(), } assert stored.correlation_id == _CORRELATION_ID diff --git a/apps/api/tests/unit/recipe/test_define_recipe_decider.py b/apps/api/tests/unit/recipe/test_define_recipe_decider.py new file mode 100644 index 000000000..0404bf8b1 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_define_recipe_decider.py @@ -0,0 +1,91 @@ +"""Unit tests for the `define_recipe` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.recipe.aggregates.recipe import ( + BindingRef, + EmptyRecipeStepsError, + InvalidRecipeNameError, + Recipe, + RecipeAlreadyExistsError, + RecipeDefined, + RecipeName, + RecipeSetpointStep, +) +from cora.recipe.features.define_recipe import DefineRecipe, decide + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _cmd(**overrides: object) -> DefineRecipe: + base: dict[str, object] = dict( + name="R1", + capability_id=uuid4(), + steps=(RecipeSetpointStep(address="dev:x", value=BindingRef("angle")),), + ) + base.update(overrides) + return DefineRecipe(**base) # type: ignore[arg-type] + + +@pytest.mark.unit +def test_decide_emits_recipe_defined_for_fresh_stream() -> None: + new_id = uuid4() + events = decide(state=None, command=_cmd(name="tomography"), now=_NOW, new_id=new_id) + assert len(events) == 1 + event = events[0] + assert isinstance(event, RecipeDefined) + assert event.recipe_id == new_id + assert event.name == "tomography" + + +@pytest.mark.unit +def test_decide_raises_already_exists_when_state_present() -> None: + state = Recipe( + id=uuid4(), + name=RecipeName("R"), + capability_id=uuid4(), + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + ) + with pytest.raises(RecipeAlreadyExistsError) as exc: + decide(state=state, command=_cmd(), now=_NOW, new_id=uuid4()) + assert exc.value.recipe_id == state.id + + +@pytest.mark.unit +def test_decide_raises_on_whitespace_only_name() -> None: + with pytest.raises(InvalidRecipeNameError): + decide(state=None, command=_cmd(name=" "), now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_raises_on_empty_steps() -> None: + with pytest.raises(EmptyRecipeStepsError): + decide(state=None, command=_cmd(steps=()), now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_trims_name_via_value_object() -> None: + events = decide(state=None, command=_cmd(name=" R "), now=_NOW, new_id=uuid4()) + assert events[0].name == "R" + + +@pytest.mark.unit +def test_decide_preserves_steps_verbatim() -> None: + steps = ( + RecipeSetpointStep(address="dev:rot", value=BindingRef("angle")), + RecipeSetpointStep(address="dev:z", value=1.5), + ) + events = decide(state=None, command=_cmd(steps=steps), now=_NOW, new_id=uuid4()) + assert events[0].steps == steps + + +@pytest.mark.unit +def test_decide_is_pure() -> None: + new_id = uuid4() + cap_id = uuid4() + e1 = decide(state=None, command=_cmd(capability_id=cap_id), now=_NOW, new_id=new_id) + e2 = decide(state=None, command=_cmd(capability_id=cap_id), now=_NOW, new_id=new_id) + assert e1 == e2 diff --git a/apps/api/tests/unit/recipe/test_define_recipe_decider_properties.py b/apps/api/tests/unit/recipe/test_define_recipe_decider_properties.py new file mode 100644 index 000000000..e16a74512 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_define_recipe_decider_properties.py @@ -0,0 +1,160 @@ +"""Property-based tests for `define_recipe.decide` (Recipe BC). + +Mirrors the `define_capability` decider-PBT pattern on a Recipe BC +create-style command. Universal claims across generated inputs: + + - state=None + valid command emits a single RecipeDefined with + the injected new_id / now and preserves name / capability_id / + steps verbatim. + - state=Recipe always raises RecipeAlreadyExistsError, regardless + of command. + - Empty steps tuple always raises EmptyRecipeStepsError (via + Recipe.__post_init__). + - Pure: same (state, command, now, new_id) returns the same events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.recipe.aggregates.recipe import ( + EmptyRecipeStepsError, + Recipe, + RecipeAlreadyExistsError, + RecipeDefined, + RecipeName, + RecipeSetpointStep, + RecipeStep, +) +from cora.recipe.features import define_recipe +from cora.recipe.features.define_recipe import DefineRecipe +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +_NAME = printable_ascii_text(min_size=1, max_size=200) +_STEP = st.builds( + RecipeSetpointStep, + address=st.text(min_size=1, max_size=20), + value=st.floats(allow_nan=False, allow_infinity=False, width=32), + verify=st.booleans(), +) +_STEPS = st.lists(_STEP, min_size=1, max_size=5).map(tuple) + + +def _command( + *, + name: str, + capability_id: UUID, + steps: tuple[RecipeStep, ...], +) -> DefineRecipe: + return DefineRecipe(name=name, capability_id=capability_id, steps=steps) + + +def _recipe(recipe_id: UUID) -> Recipe: + return Recipe( + id=recipe_id, + name=RecipeName("R"), + capability_id=recipe_id, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + ) + + +@pytest.mark.unit +@given( + name=_NAME, + capability_id=st.uuids(), + steps=_STEPS, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_recipe_emits_exactly_one_event_with_injected_fields( + name: str, + capability_id: UUID, + steps: tuple[RecipeStep, ...], + now: datetime, + new_id: UUID, +) -> None: + """Empty stream + valid command -> single RecipeDefined with injected ids/time.""" + command = _command(name=name, capability_id=capability_id, steps=steps) + events = define_recipe.decide(state=None, command=command, now=now, new_id=new_id) + assert events == [ + RecipeDefined( + recipe_id=new_id, + name=name.strip(), + capability_id=capability_id, + steps=steps, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + existing_id=st.uuids(), + name=_NAME, + capability_id=st.uuids(), + steps=_STEPS, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_recipe_on_existing_state_always_raises_already_exists( + existing_id: UUID, + name: str, + capability_id: UUID, + steps: tuple[RecipeStep, ...], + now: datetime, + new_id: UUID, +) -> None: + """Any non-None state -> RecipeAlreadyExistsError, regardless of command.""" + command = _command(name=name, capability_id=capability_id, steps=steps) + with pytest.raises(RecipeAlreadyExistsError) as exc: + define_recipe.decide(state=_recipe(existing_id), command=command, now=now, new_id=new_id) + assert exc.value.recipe_id == existing_id + + +@pytest.mark.unit +@given( + name=_NAME, + capability_id=st.uuids(), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_recipe_with_empty_steps_always_raises_empty( + name: str, + capability_id: UUID, + now: datetime, + new_id: UUID, +) -> None: + """Empty steps tuple -> EmptyRecipeStepsError via Recipe.__post_init__.""" + command = _command(name=name, capability_id=capability_id, steps=()) + with pytest.raises(EmptyRecipeStepsError): + define_recipe.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_NAME, + capability_id=st.uuids(), + steps=_STEPS, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_recipe_is_pure_same_input_same_output( + name: str, + capability_id: UUID, + steps: tuple[RecipeStep, ...], + now: datetime, + new_id: UUID, +) -> None: + """Two calls with identical args return identical events (no clock leakage).""" + command = _command(name=name, capability_id=capability_id, steps=steps) + first = define_recipe.decide(state=None, command=command, now=now, new_id=new_id) + second = define_recipe.decide(state=None, command=command, now=now, new_id=new_id) + assert first == second diff --git a/apps/api/tests/unit/recipe/test_define_recipe_handler.py b/apps/api/tests/unit/recipe/test_define_recipe_handler.py new file mode 100644 index 000000000..a8533fff5 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_define_recipe_handler.py @@ -0,0 +1,205 @@ +"""Unit tests for the `define_recipe` application handler.""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from cora.recipe import UnauthorizedError +from cora.recipe.aggregates.capability import CapabilityNotFoundError +from cora.recipe.aggregates.recipe import ( + BindingRef, + EmptyRecipeStepsError, + RecipeBindingReferencesUnknownParameterError, + RecipeSetpointStep, +) +from cora.recipe.features import define_recipe +from cora.recipe.features.define_recipe import DefineRecipe +from tests.unit._helpers import build_deps, seed_capability + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-00000000ab10") +_EVENT_ID = UUID("01900000-0000-7000-8000-00000000ab11") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-00000000c0d2") + + +async def _seed_capability_with_schema( + store: InMemoryEventStore, + capability_id: UUID, + *, + parameters_schema: dict[str, object] | None = None, +) -> None: + """Seed a Capability stream directly to carry parameters_schema (the + `seed_capability` helper does not expose this field).""" + from cora.infrastructure.event_envelope import to_new_event + from cora.recipe.aggregates.capability import ( + CapabilityCode, + CapabilityDefined, + CapabilityName, + ExecutorShape, + event_type_name, + to_payload, + ) + + event = CapabilityDefined( + capability_id=capability_id, + code=CapabilityCode("cora.capability.test").value, + name=CapabilityName("TestCapability").value, + required_affordances=frozenset(), + executor_shapes=frozenset({ExecutorShape.METHOD, ExecutorShape.PROCEDURE}), + parameters_schema=parameters_schema, + occurred_at=_NOW, + ) + await store.append( + stream_type="Capability", + stream_id=capability_id, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=UUID("01900000-0000-7000-8000-00000000c0d3"), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + +async def _build_seeded_deps( + *, + ids: list[UUID] | None = None, + deny: bool = False, + parameters_schema: dict[str, object] | None = None, +) -> tuple[InMemoryEventStore, Kernel]: + store = InMemoryEventStore() + if parameters_schema is not None: + await _seed_capability_with_schema( + store, _CAPABILITY_ID, parameters_schema=parameters_schema + ) + else: + await seed_capability(store, _CAPABILITY_ID) + deps = build_deps(ids=ids or [_NEW_ID, _EVENT_ID], now=_NOW, event_store=store, deny=deny) + return store, deps + + +@pytest.mark.unit +async def test_handler_returns_generated_recipe_id() -> None: + _, deps = await _build_seeded_deps() + handler = define_recipe.bind(deps) + + result = await handler( + DefineRecipe( + name="R", + capability_id=_CAPABILITY_ID, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert result == _NEW_ID + + +@pytest.mark.unit +async def test_handler_appends_recipe_defined_event_to_store() -> None: + store, deps = await _build_seeded_deps() + handler = define_recipe.bind(deps) + + await handler( + DefineRecipe( + name="R", + capability_id=_CAPABILITY_ID, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Recipe", _NEW_ID) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "RecipeDefined" + assert stored.payload["recipe_id"] == str(_NEW_ID) + assert stored.payload["capability_id"] == str(_CAPABILITY_ID) + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + _, deps = await _build_seeded_deps(deny=True) + handler = define_recipe.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + DefineRecipe( + name="R", + capability_id=_CAPABILITY_ID, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_capability_not_found_when_stream_missing() -> None: + store = InMemoryEventStore() + deps = build_deps(ids=[_NEW_ID, _EVENT_ID], now=_NOW, event_store=store) + handler = define_recipe.bind(deps) + + bogus = UUID("01900000-0000-7000-8000-deadbeefcafe") + with pytest.raises(CapabilityNotFoundError): + await handler( + DefineRecipe( + name="R", + capability_id=bogus, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + recipe_events, version = await store.load("Recipe", _NEW_ID) + assert recipe_events == [] + assert version == 0 + + +@pytest.mark.unit +async def test_handler_raises_binding_unknown_parameter_when_schema_missing_key() -> None: + """BindingRef integrity validator fires before decider on Capability load.""" + _, deps = await _build_seeded_deps( + parameters_schema={"type": "object", "properties": {"angle": {"type": "number"}}} + ) + handler = define_recipe.bind(deps) + + with pytest.raises(RecipeBindingReferencesUnknownParameterError): + await handler( + DefineRecipe( + name="R", + capability_id=_CAPABILITY_ID, + steps=(RecipeSetpointStep(address="dev:x", value=BindingRef("enrgy")),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_empty_recipe_steps_when_command_steps_empty() -> None: + _, deps = await _build_seeded_deps() + handler = define_recipe.bind(deps) + + with pytest.raises(EmptyRecipeStepsError): + await handler( + DefineRecipe(name="R", capability_id=_CAPABILITY_ID, steps=()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/apps/api/tests/unit/recipe/test_deprecate_recipe_decider.py b/apps/api/tests/unit/recipe/test_deprecate_recipe_decider.py new file mode 100644 index 000000000..2c4bd454f --- /dev/null +++ b/apps/api/tests/unit/recipe/test_deprecate_recipe_decider.py @@ -0,0 +1,69 @@ +"""Unit tests for the `deprecate_recipe` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.recipe.aggregates.recipe import ( + Recipe, + RecipeCannotDeprecateError, + RecipeDeprecated, + RecipeName, + RecipeNotFoundError, + RecipeSetpointStep, + RecipeStatus, +) +from cora.recipe.features.deprecate_recipe import DeprecateRecipe, decide + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _state(status: RecipeStatus = RecipeStatus.DEFINED) -> Recipe: + return Recipe( + id=uuid4(), + name=RecipeName("R"), + capability_id=uuid4(), + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + status=status, + ) + + +@pytest.mark.unit +def test_decide_emits_recipe_deprecated_when_state_defined() -> None: + state = _state(RecipeStatus.DEFINED) + events = decide(state=state, command=DeprecateRecipe(recipe_id=state.id), now=_NOW) + assert len(events) == 1 + event = events[0] + assert isinstance(event, RecipeDeprecated) + assert event.recipe_id == state.id + assert event.replaced_by_recipe_id is None + + +@pytest.mark.unit +def test_decide_emits_recipe_deprecated_when_state_versioned() -> None: + state = _state(RecipeStatus.VERSIONED) + succ = uuid4() + events = decide( + state=state, + command=DeprecateRecipe(recipe_id=state.id, replaced_by_recipe_id=succ), + now=_NOW, + ) + assert events[0].replaced_by_recipe_id == succ + + +@pytest.mark.unit +def test_decide_raises_not_found_when_state_none() -> None: + rid = uuid4() + with pytest.raises(RecipeNotFoundError) as exc: + decide(state=None, command=DeprecateRecipe(recipe_id=rid), now=_NOW) + assert exc.value.recipe_id == rid + + +@pytest.mark.unit +def test_decide_raises_cannot_deprecate_when_already_deprecated() -> None: + """Strict-not-idempotent: re-deprecating raises.""" + state = _state(RecipeStatus.DEPRECATED) + with pytest.raises(RecipeCannotDeprecateError) as exc: + decide(state=state, command=DeprecateRecipe(recipe_id=state.id), now=_NOW) + assert exc.value.current_status == RecipeStatus.DEPRECATED diff --git a/apps/api/tests/unit/recipe/test_deprecate_recipe_handler.py b/apps/api/tests/unit/recipe/test_deprecate_recipe_handler.py new file mode 100644 index 000000000..4301d05d2 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_deprecate_recipe_handler.py @@ -0,0 +1,119 @@ +"""Unit tests for the `deprecate_recipe` application handler.""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.recipe import UnauthorizedError +from cora.recipe.aggregates.recipe import ( + RecipeDefined, + RecipeNotFoundError, + RecipeSetpointStep, + event_type_name, + to_payload, +) +from cora.recipe.features import deprecate_recipe +from cora.recipe.features.deprecate_recipe import DeprecateRecipe +from tests.unit._helpers import build_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_RECIPE_ID = UUID("01900000-0000-7000-8000-00000000ab30") +_EVENT_ID = UUID("01900000-0000-7000-8000-00000000ab31") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-00000000c0e0") + + +async def _seed_recipe(store: InMemoryEventStore) -> None: + event = RecipeDefined( + recipe_id=_RECIPE_ID, + name="R", + capability_id=_CAPABILITY_ID, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + occurred_at=_NOW, + ) + await store.append( + stream_type="Recipe", + stream_id=_RECIPE_ID, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=UUID("01900000-0000-7000-8000-00000000ab32"), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + +@pytest.mark.unit +async def test_handler_appends_recipe_deprecated_event() -> None: + store = InMemoryEventStore() + await _seed_recipe(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = deprecate_recipe.bind(deps) + + await handler( + DeprecateRecipe(recipe_id=_RECIPE_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Recipe", _RECIPE_ID) + assert version == 2 + assert events[1].event_type == "RecipeDeprecated" + + +@pytest.mark.unit +async def test_handler_passes_through_replaced_by_recipe_id() -> None: + store = InMemoryEventStore() + await _seed_recipe(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = deprecate_recipe.bind(deps) + + successor = UUID("01900000-0000-7000-8000-aceaceaceace") + await handler( + DeprecateRecipe(recipe_id=_RECIPE_ID, replaced_by_recipe_id=successor), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, _ = await store.load("Recipe", _RECIPE_ID) + assert events[1].payload["replaced_by_recipe_id"] == str(successor) + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + store = InMemoryEventStore() + await _seed_recipe(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store, deny=True) + handler = deprecate_recipe.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + DeprecateRecipe(recipe_id=_RECIPE_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_not_found_when_recipe_stream_empty() -> None: + store = InMemoryEventStore() + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = deprecate_recipe.bind(deps) + + with pytest.raises(RecipeNotFoundError): + await handler( + DeprecateRecipe(recipe_id=_RECIPE_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/apps/api/tests/unit/recipe/test_get_recipe_handler.py b/apps/api/tests/unit/recipe/test_get_recipe_handler.py new file mode 100644 index 000000000..d9fba58c5 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_get_recipe_handler.py @@ -0,0 +1,104 @@ +"""Unit tests for the `get_recipe` query handler.""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.recipe import UnauthorizedError +from cora.recipe.aggregates.recipe import ( + RecipeDefined, + RecipeSetpointStep, + RecipeStatus, + event_type_name, + to_payload, +) +from cora.recipe.features import get_recipe +from cora.recipe.features.get_recipe import GetRecipe +from tests.unit._helpers import build_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_RECIPE_ID = UUID("01900000-0000-7000-8000-00000000ab40") +_EVENT_ID = UUID("01900000-0000-7000-8000-00000000ab41") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-00000000c0f0") + + +async def _seed_recipe(store: InMemoryEventStore) -> None: + event = RecipeDefined( + recipe_id=_RECIPE_ID, + name="R", + capability_id=_CAPABILITY_ID, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + occurred_at=_NOW, + ) + await store.append( + stream_type="Recipe", + stream_id=_RECIPE_ID, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=UUID("01900000-0000-7000-8000-00000000ab42"), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + +@pytest.mark.unit +async def test_handler_returns_recipe_view_for_existing_recipe() -> None: + store = InMemoryEventStore() + await _seed_recipe(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = get_recipe.bind(deps) + + view = await handler( + GetRecipe(recipe_id=_RECIPE_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert view is not None + assert view.recipe.id == _RECIPE_ID + assert view.recipe.status == RecipeStatus.DEFINED + # In-memory deps have no pool; timestamps should be None. + assert view.timestamps is None + + +@pytest.mark.unit +async def test_handler_returns_none_when_recipe_stream_empty() -> None: + store = InMemoryEventStore() + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = get_recipe.bind(deps) + + view = await handler( + GetRecipe(recipe_id=_RECIPE_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert view is None + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + store = InMemoryEventStore() + await _seed_recipe(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store, deny=True) + handler = get_recipe.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + GetRecipe(recipe_id=_RECIPE_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/apps/api/tests/unit/recipe/test_load_recipe_at_version.py b/apps/api/tests/unit/recipe/test_load_recipe_at_version.py new file mode 100644 index 000000000..39e5c145d --- /dev/null +++ b/apps/api/tests/unit/recipe/test_load_recipe_at_version.py @@ -0,0 +1,179 @@ +"""Unit tests for `load_recipe_at_version`. + +Per [[project-run-procedure-replay-design]] §Cross-BC seam additions ++ §Locks. The helper resolves a Recipe to the snapshot pinned by an +earlier `RecipeExpansionRecorded.recipe_version` via first-match-from-head +semantics over the Recipe event stream. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.recipe.aggregates.recipe import ( + RecipeDefined, + RecipeDeprecated, + RecipeSetpointStep, + RecipeStatus, + RecipeVersioned, + RecipeVersionNotFoundError, + event_type_name, + load_recipe_at_version, + to_payload, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _seed_event( + store: InMemoryEventStore, + recipe_id: UUID, + expected_version: int, + event: object, +) -> None: + await store.append( + stream_type="Recipe", + stream_id=recipe_id, + expected_version=expected_version, + events=[ + to_new_event( + event_type=event_type_name(event), # type: ignore[arg-type] + payload=to_payload(event), # type: ignore[arg-type] + occurred_at=_NOW, + event_id=uuid4(), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ), + ], + ) + + +def _defined(recipe_id: UUID, capability_id: UUID) -> RecipeDefined: + return RecipeDefined( + recipe_id=recipe_id, + name="R", + capability_id=capability_id, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + occurred_at=_NOW, + ) + + +def _versioned(recipe_id: UUID, tag: str, value: float) -> RecipeVersioned: + return RecipeVersioned( + recipe_id=recipe_id, + version_tag=tag, + steps=(RecipeSetpointStep(address="dev:x", value=value),), + occurred_at=_NOW, + ) + + +@pytest.mark.unit +async def test_load_recipe_at_version_with_none_tag_returns_post_genesis_state() -> None: + store = InMemoryEventStore() + recipe_id = uuid4() + cap_id = uuid4() + await _seed_event(store, recipe_id, 0, _defined(recipe_id, cap_id)) + + state = await load_recipe_at_version(store, recipe_id, None) + + assert state is not None + assert state.id == recipe_id + assert state.status == RecipeStatus.DEFINED + assert state.version is None + + +@pytest.mark.unit +async def test_load_recipe_at_version_with_matching_tag_returns_post_version_fold() -> None: + store = InMemoryEventStore() + recipe_id = uuid4() + cap_id = uuid4() + await _seed_event(store, recipe_id, 0, _defined(recipe_id, cap_id)) + await _seed_event(store, recipe_id, 1, _versioned(recipe_id, "v1", 2.0)) + + state = await load_recipe_at_version(store, recipe_id, "v1") + + assert state is not None + assert state.version == "v1" + assert state.status == RecipeStatus.VERSIONED + assert state.steps[0].value == 2.0 # type: ignore[union-attr] + + +@pytest.mark.unit +async def test_load_recipe_at_version_with_two_matches_returns_first_match() -> None: + """version_recipe allows tag re-use (no UNIQUE constraint per the + Recipe BC state docstring); first-match-from-head is the deterministic + choice because the second match could not have existed when the earlier + RecipeExpansionRecorded was written.""" + store = InMemoryEventStore() + recipe_id = uuid4() + cap_id = uuid4() + await _seed_event(store, recipe_id, 0, _defined(recipe_id, cap_id)) + await _seed_event(store, recipe_id, 1, _versioned(recipe_id, "v1", 2.0)) + await _seed_event(store, recipe_id, 2, _versioned(recipe_id, "v2", 3.0)) + await _seed_event(store, recipe_id, 3, _versioned(recipe_id, "v1", 4.0)) + + state = await load_recipe_at_version(store, recipe_id, "v1") + + assert state is not None + assert state.steps[0].value == 2.0 # type: ignore[union-attr] # first v1 wins + + +@pytest.mark.unit +async def test_load_recipe_at_version_with_no_matching_tag_raises_not_found() -> None: + store = InMemoryEventStore() + recipe_id = uuid4() + cap_id = uuid4() + await _seed_event(store, recipe_id, 0, _defined(recipe_id, cap_id)) + await _seed_event(store, recipe_id, 1, _versioned(recipe_id, "v1", 2.0)) + + with pytest.raises(RecipeVersionNotFoundError) as exc: + await load_recipe_at_version(store, recipe_id, "vX") + + assert exc.value.recipe_id == recipe_id + assert exc.value.version_tag == "vX" + + +@pytest.mark.unit +async def test_load_recipe_at_version_with_empty_stream_returns_none() -> None: + store = InMemoryEventStore() + recipe_id = uuid4() + + state = await load_recipe_at_version(store, recipe_id, None) + + assert state is None + + +@pytest.mark.unit +async def test_load_recipe_at_version_folds_deprecated_event_before_matching_version() -> None: + """Defensive: the helper does not assume FSM cleanliness; if a + `RecipeDeprecated` event appears before the matching `RecipeVersioned` + (the FSM forbids it today, but the helper folds whatever the stream + carries), the fold runs through and the matching version is still + located.""" + store = InMemoryEventStore() + recipe_id = uuid4() + cap_id = uuid4() + await _seed_event(store, recipe_id, 0, _defined(recipe_id, cap_id)) + await _seed_event( + store, + recipe_id, + 1, + RecipeDeprecated( + recipe_id=recipe_id, + replaced_by_recipe_id=None, + occurred_at=_NOW, + ), + ) + await _seed_event(store, recipe_id, 2, _versioned(recipe_id, "v1", 2.0)) + + state = await load_recipe_at_version(store, recipe_id, "v1") + + assert state is not None + assert state.version == "v1" diff --git a/apps/api/tests/unit/recipe/test_recipe_body.py b/apps/api/tests/unit/recipe/test_recipe_body.py new file mode 100644 index 000000000..b7eb496ee --- /dev/null +++ b/apps/api/tests/unit/recipe/test_recipe_body.py @@ -0,0 +1,158 @@ +"""Unit tests for `cora.recipe.aggregates.recipe.body`: RecipeStep VOs + wire-format roundtrip.""" + +import pytest + +from cora.recipe.aggregates.recipe import ( + BindingRef, + InvalidRecipeStepShapeError, + RecipeActionStep, + RecipeCheckStep, + RecipeSetpointStep, + UnboundRecipeBindingError, + resolve_value, + steps_from_dict, + steps_to_dict, +) + + +@pytest.mark.unit +def test_binding_ref_is_a_value_object() -> None: + a = BindingRef("dwell") + b = BindingRef("dwell") + c = BindingRef("repetitions") + assert a == b + assert a != c + assert hash(a) == hash(b) + + +@pytest.mark.unit +def test_recipe_setpoint_step_default_verify_false() -> None: + step = RecipeSetpointStep(address="dev:rot:val", value=1.0) + assert step.verify is False + + +@pytest.mark.unit +def test_recipe_setpoint_step_accepts_literal_and_binding_value() -> None: + literal = RecipeSetpointStep(address="dev:rot:val", value=1.0) + bound = RecipeSetpointStep(address="dev:rot:val", value=BindingRef("angle")) + assert literal.value == 1.0 + assert isinstance(bound.value, BindingRef) + + +@pytest.mark.unit +def test_recipe_action_step_params_default_empty() -> None: + step = RecipeActionStep(name="wait") + assert step.params == {} + + +@pytest.mark.unit +def test_recipe_action_step_params_can_carry_binding_refs() -> None: + step = RecipeActionStep(name="wait", params={"seconds": BindingRef("dwell")}) + assert isinstance(step.params["seconds"], BindingRef) + + +@pytest.mark.unit +def test_recipe_check_step_carries_criterion_dict() -> None: + step = RecipeCheckStep(address="dev:rot:val", criterion={"kind": "equals", "expected": 1.0}) + assert step.criterion["kind"] == "equals" + + +@pytest.mark.unit +def test_to_dict_from_dict_roundtrip_preserves_setpoint_step_literal_value() -> None: + steps = (RecipeSetpointStep(address="dev:rot:val", value=1.0, verify=True),) + rebuilt = steps_from_dict(steps_to_dict(steps)) + assert rebuilt == steps + + +@pytest.mark.unit +def test_to_dict_from_dict_roundtrip_preserves_setpoint_binding_ref() -> None: + steps = (RecipeSetpointStep(address="dev:rot:val", value=BindingRef("angle")),) + rebuilt = steps_from_dict(steps_to_dict(steps)) + assert rebuilt == steps + head = rebuilt[0] + assert isinstance(head, RecipeSetpointStep) + assert isinstance(head.value, BindingRef) + + +@pytest.mark.unit +def test_to_dict_from_dict_roundtrip_preserves_action_step_with_mixed_params() -> None: + steps = ( + RecipeActionStep( + name="wait", + params={"seconds": BindingRef("dwell"), "label": "settle"}, + ), + ) + rebuilt = steps_from_dict(steps_to_dict(steps)) + assert rebuilt == steps + + +@pytest.mark.unit +def test_to_dict_from_dict_roundtrip_preserves_check_step() -> None: + steps = ( + RecipeCheckStep( + address="dev:rot:val", + criterion={"kind": "equals", "expected": 1.0}, + ), + ) + rebuilt = steps_from_dict(steps_to_dict(steps)) + assert rebuilt == steps + + +@pytest.mark.unit +def test_to_dict_from_dict_roundtrip_preserves_multi_step_sequence() -> None: + steps = ( + RecipeSetpointStep(address="dev:rot:val", value=BindingRef("angle")), + RecipeActionStep(name="acquire", params={"dwell": BindingRef("dwell")}), + RecipeCheckStep(address="dev:rot:val", criterion={"kind": "equals", "expected": 1.0}), + ) + rebuilt = steps_from_dict(steps_to_dict(steps)) + assert rebuilt == steps + + +@pytest.mark.unit +def test_from_dict_rejects_missing_steps_key() -> None: + with pytest.raises(InvalidRecipeStepShapeError): + steps_from_dict({}) + + +@pytest.mark.unit +def test_from_dict_rejects_step_missing_kind() -> None: + with pytest.raises(InvalidRecipeStepShapeError): + steps_from_dict({"steps": [{"address": "x"}]}) + + +@pytest.mark.unit +def test_from_dict_rejects_unknown_step_kind() -> None: + with pytest.raises(InvalidRecipeStepShapeError) as exc: + steps_from_dict({"steps": [{"kind": "wait"}]}) + assert "unknown" in str(exc.value).lower() + + +@pytest.mark.unit +def test_from_dict_rejects_setpoint_missing_address() -> None: + with pytest.raises(InvalidRecipeStepShapeError): + steps_from_dict({"steps": [{"kind": "setpoint", "value": 1.0}]}) + + +@pytest.mark.unit +def test_from_dict_returns_empty_tuple_when_steps_list_empty() -> None: + """body.from_dict does NOT enforce non-emptiness; Recipe.__post_init__ does.""" + rebuilt = steps_from_dict({"steps": []}) + assert rebuilt == () + + +@pytest.mark.unit +def test_resolve_value_returns_literal_unchanged() -> None: + assert resolve_value(1.0, {}) == 1.0 + + +@pytest.mark.unit +def test_resolve_value_returns_mapped_value_for_binding_ref() -> None: + assert resolve_value(BindingRef("dwell"), {"dwell": 2.5}) == 2.5 + + +@pytest.mark.unit +def test_resolve_value_raises_when_binding_name_missing() -> None: + with pytest.raises(UnboundRecipeBindingError) as exc: + resolve_value(BindingRef("dwell"), {}) + assert exc.value.name == "dwell" diff --git a/apps/api/tests/unit/recipe/test_recipe_body_roundtrip_properties.py b/apps/api/tests/unit/recipe/test_recipe_body_roundtrip_properties.py new file mode 100644 index 000000000..785de7785 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_recipe_body_roundtrip_properties.py @@ -0,0 +1,121 @@ +"""Property-based tests for `cora.recipe.aggregates.recipe.body` wire-format roundtrip. + +Pins three invariants: + - `from_dict(to_dict(steps)) == steps` for arbitrary Hypothesis-generated + RecipeStep tuples (idempotent roundtrip) + - `Recipe.__post_init__` raises `EmptyRecipeStepsError` for empty tuples + and succeeds for non-empty ones + - `_BINDING_KEY`-distinguished wire serialization is canonical under + shuffled-key dicts (the `__binding__` sentinel survives dict-key + reordering) +""" + +from uuid import uuid4 + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from cora.recipe.aggregates.recipe import ( + BindingRef, + EmptyRecipeStepsError, + Recipe, + RecipeActionStep, + RecipeCheckStep, + RecipeName, + RecipeSetpointStep, + RecipeStep, + steps_from_dict, + steps_to_dict, +) + +_binding_or_literal = st.one_of( + st.integers(min_value=-1000, max_value=1000), + st.floats(allow_nan=False, allow_infinity=False, width=32), + st.booleans(), + st.text(min_size=1, max_size=20), + st.builds(BindingRef, st.text(min_size=1, max_size=12)), +) + +_setpoint_strategy = st.builds( + RecipeSetpointStep, + address=st.text(min_size=1, max_size=20), + value=_binding_or_literal, + verify=st.booleans(), +) + +_action_strategy = st.builds( + RecipeActionStep, + name=st.text(min_size=1, max_size=20), + params=st.dictionaries( + keys=st.text(min_size=1, max_size=10), + values=_binding_or_literal, + max_size=4, + ), +) + +_check_strategy = st.builds( + RecipeCheckStep, + address=st.text(min_size=1, max_size=20), + criterion=st.fixed_dictionaries( + { + "kind": st.sampled_from(["equals", "within_tolerance"]), + "expected": st.floats(allow_nan=False, allow_infinity=False, width=32), + } + ), +) + +_step_strategy = st.one_of(_setpoint_strategy, _action_strategy, _check_strategy) + + +@pytest.mark.unit +@settings(max_examples=100, deadline=2000) +@given(steps=st.lists(_step_strategy, min_size=1, max_size=10)) +def test_body_roundtrip_is_idempotent(steps: list[RecipeStep]) -> None: + steps_tuple = tuple(steps) + rebuilt = steps_from_dict(steps_to_dict(steps_tuple)) + assert rebuilt == steps_tuple + + +@pytest.mark.unit +@settings(max_examples=50, deadline=2000) +@given(steps=st.lists(_step_strategy, min_size=1, max_size=5)) +def test_recipe_post_init_accepts_any_nonempty_step_tuple(steps: list[RecipeStep]) -> None: + Recipe( + id=uuid4(), + name=RecipeName("R"), + capability_id=uuid4(), + steps=tuple(steps), + ) + + +@pytest.mark.unit +def test_recipe_post_init_rejects_empty_step_tuple() -> None: + with pytest.raises(EmptyRecipeStepsError): + Recipe(id=uuid4(), name=RecipeName("R"), capability_id=uuid4(), steps=()) + + +@pytest.mark.unit +@settings(max_examples=50, deadline=2000) +@given( + name=st.text(min_size=1, max_size=20), + bindings=st.dictionaries( + keys=st.text(min_size=1, max_size=10), + values=st.one_of(st.integers(), st.floats(allow_nan=False, allow_infinity=False)), + max_size=4, + ), +) +def test_binding_sentinel_survives_dict_key_reorder(name: str, bindings: dict[str, object]) -> None: + """The `__binding__` sentinel must distinguish BindingRefs from literal dicts. + + A `{key: bindings}` value that happens to carry `__binding__` would + be misread; the v1 contract forbids that key in literal payloads. + """ + bindings_no_collision = {k: v for k, v in bindings.items() if k != "__binding__"} + step = RecipeActionStep( + name=name, + params={"x": BindingRef("p"), **bindings_no_collision}, + ) + rebuilt = steps_from_dict(steps_to_dict((step,))) + assert isinstance(rebuilt[0], RecipeActionStep) + assert isinstance(rebuilt[0].params["x"], BindingRef) diff --git a/apps/api/tests/unit/recipe/test_recipe_events.py b/apps/api/tests/unit/recipe/test_recipe_events.py new file mode 100644 index 000000000..a9ba47e68 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_recipe_events.py @@ -0,0 +1,206 @@ +"""Unit tests for the Recipe aggregate's event (de)serialization helpers.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.infrastructure.ports.event_store import StoredEvent +from cora.recipe.aggregates.recipe import ( + BindingRef, + RecipeDefined, + RecipeDeprecated, + RecipeSetpointStep, + RecipeVersioned, + event_type_name, + from_stored, + to_payload, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _steps() -> tuple[RecipeSetpointStep, ...]: + return (RecipeSetpointStep(address="dev:x", value=BindingRef("angle")),) + + +def _make_defined(rid: object, cid: object) -> RecipeDefined: + return RecipeDefined( + recipe_id=rid, # type: ignore[arg-type] + name="R", + capability_id=cid, # type: ignore[arg-type] + steps=_steps(), + occurred_at=_NOW, + ) + + +def _stored(event_type: str, payload: dict[str, object]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Recipe", + stream_id=uuid4(), + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +@pytest.mark.unit +def test_event_type_name_returns_class_name_for_each_arm() -> None: + rid, cid = uuid4(), uuid4() + defn = _make_defined(rid, cid) + ver = RecipeVersioned(recipe_id=rid, version_tag="v1", steps=_steps(), occurred_at=_NOW) + dep = RecipeDeprecated(recipe_id=rid, occurred_at=_NOW) + assert event_type_name(defn) == "RecipeDefined" + assert event_type_name(ver) == "RecipeVersioned" + assert event_type_name(dep) == "RecipeDeprecated" + + +@pytest.mark.unit +def test_to_payload_recipe_defined_serializes_full_payload() -> None: + rid, cid = uuid4(), uuid4() + defn = _make_defined(rid, cid) + payload = to_payload(defn) + assert payload["recipe_id"] == str(rid) + assert payload["capability_id"] == str(cid) + assert payload["name"] == "R" + assert payload["occurred_at"] == _NOW.isoformat() + assert "steps" in payload + + +@pytest.mark.unit +def test_to_payload_recipe_versioned_serializes_version_tag_and_steps() -> None: + rid = uuid4() + ver = RecipeVersioned(recipe_id=rid, version_tag="v2", steps=_steps(), occurred_at=_NOW) + payload = to_payload(ver) + assert payload["version_tag"] == "v2" + assert "steps" in payload + assert "name" not in payload # name preserved on state, not in payload + assert "capability_id" not in payload # capability_id preserved on state, not in payload + + +@pytest.mark.unit +def test_to_payload_recipe_deprecated_serializes_replaced_by_or_none() -> None: + rid, succ = uuid4(), uuid4() + dep_none = RecipeDeprecated(recipe_id=rid, occurred_at=_NOW) + dep_with = RecipeDeprecated(recipe_id=rid, replaced_by_recipe_id=succ, occurred_at=_NOW) + assert to_payload(dep_none)["replaced_by_recipe_id"] is None + assert to_payload(dep_with)["replaced_by_recipe_id"] == str(succ) + + +@pytest.mark.unit +def test_from_stored_round_trips_recipe_defined() -> None: + rid, cid = uuid4(), uuid4() + original = _make_defined(rid, cid) + stored = _stored("RecipeDefined", to_payload(original)) + rebuilt = from_stored(stored) + assert rebuilt == original + + +@pytest.mark.unit +def test_from_stored_round_trips_recipe_versioned() -> None: + rid = uuid4() + original = RecipeVersioned(recipe_id=rid, version_tag="v3", steps=_steps(), occurred_at=_NOW) + stored = _stored("RecipeVersioned", to_payload(original)) + rebuilt = from_stored(stored) + assert rebuilt == original + + +@pytest.mark.unit +def test_from_stored_round_trips_recipe_deprecated_with_replacement() -> None: + rid, succ = uuid4(), uuid4() + original = RecipeDeprecated(recipe_id=rid, replaced_by_recipe_id=succ, occurred_at=_NOW) + stored = _stored("RecipeDeprecated", to_payload(original)) + rebuilt = from_stored(stored) + assert rebuilt == original + + +@pytest.mark.unit +def test_from_stored_round_trips_recipe_deprecated_without_replacement() -> None: + rid = uuid4() + original = RecipeDeprecated(recipe_id=rid, occurred_at=_NOW) + stored = _stored("RecipeDeprecated", to_payload(original)) + rebuilt = from_stored(stored) + assert rebuilt == original + + +@pytest.mark.unit +def test_from_stored_rejects_unknown_event_type() -> None: + stored = _stored("RecipeFooBar", {}) + with pytest.raises(ValueError, match="Unknown RecipeEvent"): + from_stored(stored) + + +@pytest.mark.unit +def test_from_stored_wraps_malformed_recipe_defined_payload() -> None: + """Missing keys / wrong types route through `deserialize_or_raise`.""" + stored = _stored("RecipeDefined", {}) # missing every required key + with pytest.raises(ValueError, match="Malformed RecipeDefined payload"): + from_stored(stored) + + +@pytest.mark.unit +def test_from_stored_wraps_malformed_recipe_versioned_payload() -> None: + stored = _stored("RecipeVersioned", {"recipe_id": str(uuid4())}) # missing version_tag, steps + with pytest.raises(ValueError, match="Malformed RecipeVersioned payload"): + from_stored(stored) + + +@pytest.mark.unit +def test_from_stored_wraps_malformed_recipe_deprecated_payload() -> None: + stored = _stored("RecipeDeprecated", {}) # missing recipe_id + occurred_at + with pytest.raises(ValueError, match="Malformed RecipeDeprecated payload"): + from_stored(stored) + + +@pytest.mark.unit +def test_from_stored_wraps_recipe_defined_with_bad_uuid_payload() -> None: + stored = _stored( + "RecipeDefined", + { + "recipe_id": "not-a-uuid", + "name": "R", + "capability_id": str(uuid4()), + "steps": {"steps": []}, + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed RecipeDefined payload"): + from_stored(stored) + + +@pytest.mark.unit +def test_from_stored_wraps_recipe_defined_with_unknown_step_kind() -> None: + stored = _stored( + "RecipeDefined", + { + "recipe_id": str(uuid4()), + "name": "R", + "capability_id": str(uuid4()), + "steps": {"steps": [{"kind": "unknown_kind"}]}, + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed RecipeDefined payload"): + from_stored(stored) + + +@pytest.mark.unit +def test_from_stored_wraps_recipe_versioned_with_unknown_step_kind() -> None: + stored = _stored( + "RecipeVersioned", + { + "recipe_id": str(uuid4()), + "version_tag": "v1", + "steps": {"steps": [{"kind": "unknown_kind"}]}, + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed RecipeVersioned payload"): + from_stored(stored) diff --git a/apps/api/tests/unit/recipe/test_recipe_evolver.py b/apps/api/tests/unit/recipe/test_recipe_evolver.py new file mode 100644 index 000000000..4c1cd45a5 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_recipe_evolver.py @@ -0,0 +1,178 @@ +"""Unit tests for the Recipe aggregate's evolver.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.recipe.aggregates.recipe import ( + BindingRef, + RecipeDefined, + RecipeDeprecated, + RecipeSetpointStep, + RecipeStatus, + RecipeVersioned, + evolve, + fold, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _defined(**overrides: object) -> RecipeDefined: + base: dict[str, object] = dict( + recipe_id=uuid4(), + name="R", + capability_id=uuid4(), + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + occurred_at=_NOW, + ) + base.update(overrides) + return RecipeDefined(**base) # type: ignore[arg-type] + + +@pytest.mark.unit +def test_recipe_defined_folds_into_defined_status() -> None: + state = evolve(None, _defined()) + assert state.status == RecipeStatus.DEFINED + assert state.version is None + assert state.replaced_by_recipe_id is None + + +@pytest.mark.unit +def test_recipe_defined_folds_name_capability_id_and_steps() -> None: + rid, cid = uuid4(), uuid4() + event = _defined( + recipe_id=rid, + name="tomography", + capability_id=cid, + steps=(RecipeSetpointStep(address="dev:x", value=BindingRef("angle")),), + ) + state = evolve(None, event) + assert state.id == rid + assert state.name.value == "tomography" + assert state.capability_id == cid + assert len(state.steps) == 1 + + +@pytest.mark.unit +def test_recipe_versioned_replaces_steps_wholesale_and_preserves_identity() -> None: + rid, cid = uuid4(), uuid4() + state = evolve( + None, + _defined( + recipe_id=rid, + capability_id=cid, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + ), + ) + new_steps = ( + RecipeSetpointStep(address="dev:x", value=2.0), + RecipeSetpointStep(address="dev:y", value=3.0), + ) + state2 = evolve( + state, + RecipeVersioned(recipe_id=rid, version_tag="v1", steps=new_steps, occurred_at=_NOW), + ) + assert state2.status == RecipeStatus.VERSIONED + assert state2.version == "v1" + assert state2.steps == new_steps + assert state2.id == rid # identity preserved + assert state2.capability_id == cid # capability_id IMMUTABLE per Pattern P + assert state2.name == state.name + + +@pytest.mark.unit +def test_recipe_deprecated_preserves_steps_and_capability_id_for_audit() -> None: + rid, cid, succ = uuid4(), uuid4(), uuid4() + state = evolve(None, _defined(recipe_id=rid, capability_id=cid)) + state2 = evolve( + state, + RecipeDeprecated(recipe_id=rid, replaced_by_recipe_id=succ, occurred_at=_NOW), + ) + assert state2.status == RecipeStatus.DEPRECATED + assert state2.replaced_by_recipe_id == succ + assert state2.steps == state.steps # PRESERVED + assert state2.capability_id == cid # PRESERVED + + +@pytest.mark.unit +def test_recipe_deprecated_without_replacement_carries_none_pointer() -> None: + rid = uuid4() + state = evolve(None, _defined(recipe_id=rid)) + state2 = evolve(state, RecipeDeprecated(recipe_id=rid, occurred_at=_NOW)) + assert state2.status == RecipeStatus.DEPRECATED + assert state2.replaced_by_recipe_id is None + + +@pytest.mark.unit +def test_recipe_versioned_preserves_replaced_by_pointer_if_set() -> None: + """Defensive: a Versioned event after Deprecated would never happen in well-formed + streams; the evolver still preserves any prior replaced_by_recipe_id.""" + rid, succ = uuid4(), uuid4() + state = evolve(None, _defined(recipe_id=rid)) + state = evolve( + state, + RecipeDeprecated(recipe_id=rid, replaced_by_recipe_id=succ, occurred_at=_NOW), + ) + state2 = evolve( + state, + RecipeVersioned( + recipe_id=rid, + version_tag="vX", + steps=(RecipeSetpointStep(address="dev:x", value=9.0),), + occurred_at=_NOW, + ), + ) + assert state2.replaced_by_recipe_id == succ + + +@pytest.mark.unit +def test_evolve_versioned_on_empty_state_raises() -> None: + with pytest.raises(ValueError): + evolve( + None, + RecipeVersioned( + recipe_id=uuid4(), + version_tag="v1", + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + occurred_at=_NOW, + ), + ) + + +@pytest.mark.unit +def test_evolve_deprecated_on_empty_state_raises() -> None: + with pytest.raises(ValueError): + evolve(None, RecipeDeprecated(recipe_id=uuid4(), occurred_at=_NOW)) + + +@pytest.mark.unit +def test_fold_replays_defined_only_stream() -> None: + state = fold([_defined()]) + assert state is not None + assert state.status == RecipeStatus.DEFINED + + +@pytest.mark.unit +def test_fold_replays_defined_versioned_deprecated_chain() -> None: + rid = uuid4() + events = [ + _defined(recipe_id=rid), + RecipeVersioned( + recipe_id=rid, + version_tag="v1", + steps=(RecipeSetpointStep(address="dev:x", value=2.0),), + occurred_at=_NOW, + ), + RecipeDeprecated(recipe_id=rid, occurred_at=_NOW), + ] + state = fold(events) + assert state is not None + assert state.status == RecipeStatus.DEPRECATED + assert state.version == "v1" # last-emitted version_tag preserved across deprecation + + +@pytest.mark.unit +def test_fold_empty_stream_returns_none() -> None: + assert fold([]) is None diff --git a/apps/api/tests/unit/recipe/test_recipe_state.py b/apps/api/tests/unit/recipe/test_recipe_state.py new file mode 100644 index 000000000..3339e0102 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_recipe_state.py @@ -0,0 +1,125 @@ +"""Unit tests for the Recipe aggregate's state, status, and value objects.""" + +from uuid import uuid4 + +import pytest + +from cora.recipe.aggregates.recipe import ( + RECIPE_NAME_MAX_LENGTH, + RECIPE_VERSION_TAG_MAX_LENGTH, + EmptyRecipeStepsError, + InvalidRecipeNameError, + InvalidRecipeVersionTagError, + Recipe, + RecipeAlreadyExistsError, + RecipeCannotDeprecateError, + RecipeCannotVersionError, + RecipeName, + RecipeNotFoundError, + RecipeSetpointStep, + RecipeStatus, +) + + +def _steps() -> tuple[RecipeSetpointStep, ...]: + return (RecipeSetpointStep(address="dev:x", value=1.0),) + + +@pytest.mark.unit +def test_recipe_status_values_match_bc_map() -> None: + assert RecipeStatus.DEFINED.value == "Defined" + assert RecipeStatus.VERSIONED.value == "Versioned" + assert RecipeStatus.DEPRECATED.value == "Deprecated" + + +@pytest.mark.unit +def test_recipe_name_trims_whitespace() -> None: + assert RecipeName(" tomography continuous ").value == "tomography continuous" + + +@pytest.mark.unit +def test_recipe_name_rejects_empty_after_trim() -> None: + with pytest.raises(InvalidRecipeNameError): + RecipeName(" ") + + +@pytest.mark.unit +def test_recipe_name_rejects_too_long() -> None: + too_long = "x" * (RECIPE_NAME_MAX_LENGTH + 1) + with pytest.raises(InvalidRecipeNameError): + RecipeName(too_long) + + +@pytest.mark.unit +def test_recipe_constructs_with_required_fields() -> None: + rid = uuid4() + cid = uuid4() + recipe = Recipe(id=rid, name=RecipeName("R1"), capability_id=cid, steps=_steps()) + assert recipe.id == rid + assert recipe.capability_id == cid + assert recipe.status == RecipeStatus.DEFINED + assert recipe.version is None + assert recipe.replaced_by_recipe_id is None + assert len(recipe.steps) == 1 + + +@pytest.mark.unit +def test_recipe_rejects_empty_steps_at_post_init() -> None: + with pytest.raises(EmptyRecipeStepsError): + Recipe(id=uuid4(), name=RecipeName("R1"), capability_id=uuid4(), steps=()) + + +@pytest.mark.unit +def test_recipe_already_exists_error_carries_recipe_id() -> None: + rid = uuid4() + err = RecipeAlreadyExistsError(rid) + assert err.recipe_id == rid + assert str(rid) in str(err) + + +@pytest.mark.unit +def test_recipe_not_found_error_carries_recipe_id() -> None: + rid = uuid4() + err = RecipeNotFoundError(rid) + assert err.recipe_id == rid + + +@pytest.mark.unit +def test_recipe_cannot_version_error_carries_status() -> None: + rid = uuid4() + err = RecipeCannotVersionError(rid, RecipeStatus.DEPRECATED) + assert err.recipe_id == rid + assert err.current_status == RecipeStatus.DEPRECATED + assert "Deprecated" in str(err) + + +@pytest.mark.unit +def test_recipe_cannot_deprecate_error_carries_status() -> None: + rid = uuid4() + err = RecipeCannotDeprecateError(rid, RecipeStatus.DEPRECATED) + assert err.recipe_id == rid + assert err.current_status == RecipeStatus.DEPRECATED + + +@pytest.mark.unit +def test_invalid_recipe_version_tag_error_carries_value() -> None: + err = InvalidRecipeVersionTagError("") + assert err.value == "" + too_long = "v" * (RECIPE_VERSION_TAG_MAX_LENGTH + 1) + err2 = InvalidRecipeVersionTagError(too_long) + assert err2.value == too_long + + +@pytest.mark.unit +def test_empty_recipe_steps_error_message_is_actionable() -> None: + err = EmptyRecipeStepsError() + assert "non-empty" in str(err).lower() + + +@pytest.mark.unit +def test_recipe_is_frozen_dataclass() -> None: + from dataclasses import FrozenInstanceError + + recipe = Recipe(id=uuid4(), name=RecipeName("R1"), capability_id=uuid4(), steps=_steps()) + with pytest.raises(FrozenInstanceError): + recipe.version = "v1" # type: ignore[misc] diff --git a/apps/api/tests/unit/recipe/test_recipe_steps_validation.py b/apps/api/tests/unit/recipe/test_recipe_steps_validation.py new file mode 100644 index 000000000..bd97e34aa --- /dev/null +++ b/apps/api/tests/unit/recipe/test_recipe_steps_validation.py @@ -0,0 +1,100 @@ +"""Unit tests for `cora.recipe.aggregates.recipe.steps_validation`.""" + +from typing import Any + +import pytest + +from cora.recipe.aggregates.recipe import ( + BindingRef, + RecipeActionStep, + RecipeBindingReferencesUnknownParameterError, + RecipeCheckStep, + RecipeRequiresCapabilityParametersSchemaError, + RecipeSetpointStep, + collect_binding_names, + validate_recipe_steps_against_capability_schema, +) + + +@pytest.mark.unit +def test_collect_binding_names_returns_empty_for_literal_only_steps() -> None: + steps = ( + RecipeSetpointStep(address="dev:x", value=1.0), + RecipeActionStep(name="wait", params={"seconds": 2.0}), + RecipeCheckStep(address="dev:x", criterion={"kind": "equals", "expected": 1.0}), + ) + assert collect_binding_names(steps) == frozenset() + + +@pytest.mark.unit +def test_collect_binding_names_picks_up_setpoint_binding() -> None: + steps = (RecipeSetpointStep(address="dev:x", value=BindingRef("angle")),) + assert collect_binding_names(steps) == frozenset({"angle"}) + + +@pytest.mark.unit +def test_collect_binding_names_picks_up_action_param_bindings() -> None: + steps = ( + RecipeActionStep( + name="acquire", + params={"dwell": BindingRef("dwell"), "label": "main"}, + ), + ) + assert collect_binding_names(steps) == frozenset({"dwell"}) + + +@pytest.mark.unit +def test_collect_binding_names_unions_across_steps() -> None: + steps = ( + RecipeSetpointStep(address="dev:x", value=BindingRef("a")), + RecipeActionStep(name="acquire", params={"b": BindingRef("b")}), + ) + assert collect_binding_names(steps) == frozenset({"a", "b"}) + + +@pytest.mark.unit +def test_validator_accepts_steps_with_no_bindings_when_schema_none() -> None: + steps = (RecipeSetpointStep(address="dev:x", value=1.0),) + validate_recipe_steps_against_capability_schema(steps, None) + + +@pytest.mark.unit +def test_validator_accepts_steps_with_bindings_when_schema_declares_them() -> None: + steps = (RecipeSetpointStep(address="dev:x", value=BindingRef("angle")),) + schema = {"type": "object", "properties": {"angle": {"type": "number"}}} + validate_recipe_steps_against_capability_schema(steps, schema) + + +@pytest.mark.unit +def test_validator_rejects_bindings_when_schema_is_none() -> None: + steps = (RecipeSetpointStep(address="dev:x", value=BindingRef("angle")),) + with pytest.raises(RecipeRequiresCapabilityParametersSchemaError) as exc: + validate_recipe_steps_against_capability_schema(steps, None) + assert "angle" in str(exc.value) + + +@pytest.mark.unit +def test_validator_rejects_unknown_binding_against_schema() -> None: + steps = (RecipeSetpointStep(address="dev:x", value=BindingRef("enrgy")),) + schema = {"type": "object", "properties": {"energy": {"type": "number"}}} + with pytest.raises(RecipeBindingReferencesUnknownParameterError) as exc: + validate_recipe_steps_against_capability_schema(steps, schema) + assert exc.value.name == "enrgy" + assert exc.value.schema_properties == frozenset({"energy"}) + + +@pytest.mark.unit +def test_validator_treats_non_dict_properties_as_empty() -> None: + steps = (RecipeSetpointStep(address="dev:x", value=BindingRef("a")),) + schema: dict[str, Any] = {"type": "object", "properties": []} + with pytest.raises(RecipeBindingReferencesUnknownParameterError): + validate_recipe_steps_against_capability_schema(steps, schema) + + +@pytest.mark.unit +def test_validator_error_message_lists_declared_names_sorted() -> None: + steps = (RecipeSetpointStep(address="dev:x", value=BindingRef("zzz")),) + schema: dict[str, Any] = {"type": "object", "properties": {"b": {}, "a": {}}} + with pytest.raises(RecipeBindingReferencesUnknownParameterError) as exc: + validate_recipe_steps_against_capability_schema(steps, schema) + assert "['a', 'b']" in str(exc.value) diff --git a/apps/api/tests/unit/recipe/test_recipe_steps_validation_properties.py b/apps/api/tests/unit/recipe/test_recipe_steps_validation_properties.py new file mode 100644 index 000000000..532812efe --- /dev/null +++ b/apps/api/tests/unit/recipe/test_recipe_steps_validation_properties.py @@ -0,0 +1,94 @@ +"""Property-based tests for the Recipe BindingRef-integrity validator. + +Pins three invariants over Hypothesis-generated schema/steps pairs: + - Steps with BindingRefs that fully cover a schema's declared + properties pass validation + - Steps with at least one BindingRef name absent from the schema + raise `RecipeBindingReferencesUnknownParameterError` + - Steps with BindingRefs against a None schema raise + `RecipeRequiresCapabilityParametersSchemaError` +""" + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from cora.recipe.aggregates.recipe import ( + BindingRef, + RecipeActionStep, + RecipeBindingReferencesUnknownParameterError, + RecipeRequiresCapabilityParametersSchemaError, + RecipeSetpointStep, + collect_binding_names, + validate_recipe_steps_against_capability_schema, +) + +_name_strategy = st.text( + alphabet=st.characters( + whitelist_categories=("Ll", "Lu", "Nd"), + min_codepoint=48, + max_codepoint=122, + ), + min_size=1, + max_size=10, +) + + +@pytest.mark.unit +@settings(max_examples=50, deadline=2000) +@given(declared=st.sets(_name_strategy, min_size=1, max_size=5)) +def test_validator_accepts_when_all_bindings_in_schema(declared: set[str]) -> None: + schema = {"type": "object", "properties": {n: {"type": "number"} for n in declared}} + steps = tuple(RecipeSetpointStep(address=f"dev:{n}", value=BindingRef(n)) for n in declared) + validate_recipe_steps_against_capability_schema(steps, schema) + + +@pytest.mark.unit +@settings(max_examples=50, deadline=2000) +@given( + declared=st.sets(_name_strategy, min_size=1, max_size=5), + extra=_name_strategy, +) +def test_validator_rejects_when_any_binding_missing_from_schema( + declared: set[str], extra: str +) -> None: + if extra in declared: + return # vacuous; the bound name IS in the schema + schema = {"type": "object", "properties": {n: {"type": "number"} for n in declared}} + steps = (RecipeSetpointStep(address="dev:x", value=BindingRef(extra)),) + with pytest.raises(RecipeBindingReferencesUnknownParameterError): + validate_recipe_steps_against_capability_schema(steps, schema) + + +@pytest.mark.unit +@settings(max_examples=50, deadline=2000) +@given(names=st.sets(_name_strategy, min_size=1, max_size=5)) +def test_validator_rejects_any_bindings_when_schema_is_none(names: set[str]) -> None: + steps = tuple(RecipeSetpointStep(address=f"dev:{n}", value=BindingRef(n)) for n in names) + with pytest.raises(RecipeRequiresCapabilityParametersSchemaError): + validate_recipe_steps_against_capability_schema(steps, None) + + +@pytest.mark.unit +@settings(max_examples=50, deadline=2000) +@given( + setpoint_bindings=st.sets(_name_strategy, max_size=3), + action_bindings=st.sets(_name_strategy, max_size=3), +) +def test_collect_binding_names_equals_union_across_step_kinds( + setpoint_bindings: set[str], action_bindings: set[str] +) -> None: + steps: list[object] = [] + steps.extend( + RecipeSetpointStep(address=f"dev:{n}", value=BindingRef(n)) for n in setpoint_bindings + ) + if action_bindings: + steps.append( + RecipeActionStep( + name="act", + params={n: BindingRef(n) for n in action_bindings}, + ) + ) + expected = setpoint_bindings | action_bindings + collected = collect_binding_names(tuple(steps)) # type: ignore[arg-type] + assert collected == frozenset(expected) diff --git a/apps/api/tests/unit/recipe/test_version_recipe_decider.py b/apps/api/tests/unit/recipe/test_version_recipe_decider.py new file mode 100644 index 000000000..d341d45d0 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_version_recipe_decider.py @@ -0,0 +1,109 @@ +"""Unit tests for the `version_recipe` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.recipe.aggregates.recipe import ( + EmptyRecipeStepsError, + InvalidRecipeVersionTagError, + Recipe, + RecipeCannotVersionError, + RecipeName, + RecipeNotFoundError, + RecipeSetpointStep, + RecipeStatus, + RecipeVersioned, +) +from cora.recipe.features.version_recipe import VersionRecipe, decide + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _state(status: RecipeStatus = RecipeStatus.DEFINED) -> Recipe: + return Recipe( + id=uuid4(), + name=RecipeName("R"), + capability_id=uuid4(), + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + status=status, + ) + + +def _cmd(recipe_id: UUID, **overrides: object) -> VersionRecipe: + base: dict[str, object] = dict( + recipe_id=recipe_id, + version_tag="v1", + steps=(RecipeSetpointStep(address="dev:x", value=2.0),), + ) + base.update(overrides) + return VersionRecipe(**base) # type: ignore[arg-type] + + +@pytest.mark.unit +def test_decide_emits_recipe_versioned_when_state_defined() -> None: + state = _state(RecipeStatus.DEFINED) + events = decide(state=state, command=_cmd(state.id), now=_NOW) + assert len(events) == 1 + event = events[0] + assert isinstance(event, RecipeVersioned) + assert event.recipe_id == state.id + assert event.version_tag == "v1" + + +@pytest.mark.unit +def test_decide_emits_recipe_versioned_when_state_versioned() -> None: + state = _state(RecipeStatus.VERSIONED) + events = decide(state=state, command=_cmd(state.id, version_tag="v2"), now=_NOW) + assert len(events) == 1 + assert events[0].version_tag == "v2" + + +@pytest.mark.unit +def test_decide_raises_not_found_when_state_none() -> None: + rid = uuid4() + with pytest.raises(RecipeNotFoundError) as exc: + decide(state=None, command=_cmd(rid), now=_NOW) + assert exc.value.recipe_id == rid + + +@pytest.mark.unit +def test_decide_raises_cannot_version_when_state_deprecated() -> None: + state = _state(RecipeStatus.DEPRECATED) + with pytest.raises(RecipeCannotVersionError) as exc: + decide(state=state, command=_cmd(state.id), now=_NOW) + assert exc.value.current_status == RecipeStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_raises_on_whitespace_only_version_tag() -> None: + state = _state() + with pytest.raises(InvalidRecipeVersionTagError): + decide(state=state, command=_cmd(state.id, version_tag=" "), now=_NOW) + + +@pytest.mark.unit +def test_decide_trims_version_tag() -> None: + state = _state() + events = decide(state=state, command=_cmd(state.id, version_tag=" v3 "), now=_NOW) + assert events[0].version_tag == "v3" + + +@pytest.mark.unit +def test_decide_raises_on_empty_steps() -> None: + state = _state() + with pytest.raises(EmptyRecipeStepsError): + decide(state=state, command=_cmd(state.id, steps=()), now=_NOW) + + +@pytest.mark.unit +def test_decide_emits_event_on_byte_equal_re_call() -> None: + """Re-attestation is the audit signal; no no-op rule. Mirrors version_capability.""" + state = _state(RecipeStatus.VERSIONED) + steps = (RecipeSetpointStep(address="dev:x", value=9.0),) + cmd = _cmd(state.id, version_tag="v9", steps=steps) + first = decide(state=state, command=cmd, now=_NOW) + second = decide(state=state, command=cmd, now=_NOW) + assert first == second + assert len(first) == 1 diff --git a/apps/api/tests/unit/recipe/test_version_recipe_handler.py b/apps/api/tests/unit/recipe/test_version_recipe_handler.py new file mode 100644 index 000000000..555018904 --- /dev/null +++ b/apps/api/tests/unit/recipe/test_version_recipe_handler.py @@ -0,0 +1,182 @@ +"""Unit tests for the `version_recipe` application handler.""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +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.recipe import UnauthorizedError +from cora.recipe.aggregates.recipe import ( + BindingRef, + RecipeBindingReferencesUnknownParameterError, + RecipeDefined, + RecipeNotFoundError, + RecipeSetpointStep, + event_type_name, + to_payload, +) +from cora.recipe.features import version_recipe +from cora.recipe.features.version_recipe import VersionRecipe +from tests.unit._helpers import build_deps, seed_capability + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-00000000c0d4") +_RECIPE_ID = UUID("01900000-0000-7000-8000-00000000ab20") +_EVENT_ID = UUID("01900000-0000-7000-8000-00000000ab21") + + +async def _seed_recipe(store: InMemoryEventStore) -> None: + event = RecipeDefined( + recipe_id=_RECIPE_ID, + name="R", + capability_id=_CAPABILITY_ID, + steps=(RecipeSetpointStep(address="dev:x", value=1.0),), + occurred_at=_NOW, + ) + await store.append( + stream_type="Recipe", + stream_id=_RECIPE_ID, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=UUID("01900000-0000-7000-8000-00000000ab22"), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + +async def _build_seeded_deps(*, deny: bool = False) -> tuple[InMemoryEventStore, Kernel]: + store = InMemoryEventStore() + await seed_capability(store, _CAPABILITY_ID) + await _seed_recipe(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store, deny=deny) + return store, deps + + +@pytest.mark.unit +async def test_handler_appends_recipe_versioned_event() -> None: + store, deps = await _build_seeded_deps() + handler = version_recipe.bind(deps) + + await handler( + VersionRecipe( + recipe_id=_RECIPE_ID, + version_tag="v1", + steps=(RecipeSetpointStep(address="dev:x", value=2.0),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Recipe", _RECIPE_ID) + assert version == 2 + assert events[1].event_type == "RecipeVersioned" + assert events[1].payload["version_tag"] == "v1" + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + _, deps = await _build_seeded_deps(deny=True) + handler = version_recipe.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + VersionRecipe( + recipe_id=_RECIPE_ID, + version_tag="v1", + steps=(RecipeSetpointStep(address="dev:x", value=2.0),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_not_found_when_recipe_stream_empty() -> None: + store = InMemoryEventStore() + await seed_capability(store, _CAPABILITY_ID) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = version_recipe.bind(deps) + + with pytest.raises(RecipeNotFoundError): + await handler( + VersionRecipe( + recipe_id=_RECIPE_ID, + version_tag="v1", + steps=(RecipeSetpointStep(address="dev:x", value=2.0),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_re_validates_binding_refs_against_capability_schema() -> None: + """Anti-hook 5: BindingRef integrity re-fires at version_recipe. + + Mirrors `test_handler_raises_binding_unknown_parameter_when_schema_missing_key` from + define_recipe but at version time. Closes the operator-side half of the + Capability-re-version race. + """ + from cora.recipe.aggregates.capability import ( + CapabilityCode, + CapabilityDefined, + CapabilityName, + ExecutorShape, + ) + from cora.recipe.aggregates.capability import event_type_name as cap_event_type_name + from cora.recipe.aggregates.capability import to_payload as cap_to_payload + + store = InMemoryEventStore() + cap_event = CapabilityDefined( + capability_id=_CAPABILITY_ID, + code=CapabilityCode("cora.capability.test").value, + name=CapabilityName("TestCapability").value, + required_affordances=frozenset(), + executor_shapes=frozenset({ExecutorShape.METHOD, ExecutorShape.PROCEDURE}), + parameters_schema={"type": "object", "properties": {"angle": {"type": "number"}}}, + occurred_at=_NOW, + ) + await store.append( + stream_type="Capability", + stream_id=_CAPABILITY_ID, + expected_version=0, + events=[ + to_new_event( + event_type=cap_event_type_name(cap_event), + payload=cap_to_payload(cap_event), + occurred_at=cap_event.occurred_at, + event_id=UUID("01900000-0000-7000-8000-00000000c0d5"), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + await _seed_recipe(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = version_recipe.bind(deps) + + with pytest.raises(RecipeBindingReferencesUnknownParameterError): + await handler( + VersionRecipe( + recipe_id=_RECIPE_ID, + version_tag="v2", + steps=(RecipeSetpointStep(address="dev:x", value=BindingRef("enrgy")),), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/infra/atlas/migrations/20260602124500_init_proj_recipe_recipe_summary.sql b/infra/atlas/migrations/20260602124500_init_proj_recipe_recipe_summary.sql new file mode 100644 index 000000000..55c08c3db --- /dev/null +++ b/infra/atlas/migrations/20260602124500_init_proj_recipe_recipe_summary.sql @@ -0,0 +1,71 @@ +-- Recipe BC's new Recipe aggregate projection. +-- +-- Folds the Recipe aggregate's lifecycle events +-- (RecipeDefined / RecipeVersioned / RecipeDeprecated) into the +-- `proj_recipe_recipe_summary` read model that backs +-- `GET /recipes/{recipe_id}` and future list endpoints. +-- +-- Distinct from `proj_recipe_capability_summary`. Capability is the +-- declarative contract aggregate; Recipe is the deployment-bound +-- executable step body that references a Capability per +-- [[project-recipe-aggregate-design]]. The split was locked via +-- [[capability-naming-split-lock]] (Shape 2: 5-peer aggregates in +-- Recipe BC). +-- +-- Subscribed events: +-- - RecipeDefined -> INSERT (status=Defined, version_tag=NULL, +-- replaced_by_recipe_id=NULL, +-- steps_count from payload) +-- - RecipeVersioned -> UPDATE status=Versioned + version_tag + +-- refresh steps_count +-- (a new version IS a new declaration) +-- - RecipeDeprecated -> UPDATE status=Deprecated + +-- replaced_by_recipe_id +-- (steps + capability_id PRESERVED +-- for audit) +-- +-- `version_tag` is nullable: Defined has no label until first +-- version. `replaced_by_recipe_id` is nullable: Defined / Versioned +-- never have it; Deprecated may or may not (depending on whether +-- the operator pointed at a successor). +-- `steps_count` is the number of `RecipeStep`s in the latest event +-- (denormalized from the wire-format `{steps: {steps: [...]}}` +-- payload); the steps themselves live in the event stream per +-- [[project-pg-smart-logic-observation]] to keep the summary table +-- small. +-- +-- Lifecycle timestamps (`versioned_at`, `deprecated_at`) ship as +-- nullable columns now; the projection updates them on the matching +-- event. Mirrors the May-2026 template-aggregate-timestamps sweep +-- across Family/Capability/Method/Plan/Practice. +-- +-- Mutable read model. cora_app gets full DML. + +CREATE TABLE proj_recipe_recipe_summary ( + recipe_id UUID PRIMARY KEY, + name TEXT NOT NULL, + capability_id UUID NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('Defined', 'Versioned', 'Deprecated') + ), + version_tag TEXT, + steps_count INTEGER NOT NULL DEFAULT 0, + replaced_by_recipe_id UUID, + created_at TIMESTAMPTZ NOT NULL, + versioned_at TIMESTAMPTZ, + deprecated_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX proj_recipe_recipe_summary_keyset_idx + ON proj_recipe_recipe_summary (created_at, recipe_id); + +CREATE INDEX proj_recipe_recipe_summary_capability_id_idx + ON proj_recipe_recipe_summary (capability_id); + +GRANT SELECT, INSERT, UPDATE, DELETE + ON proj_recipe_recipe_summary TO cora_app; + +INSERT INTO projection_bookmarks (name) +VALUES ('proj_recipe_recipe_summary') +ON CONFLICT DO NOTHING; diff --git a/infra/atlas/migrations/20260602124600_procedure_summary_add_recipe_id.sql b/infra/atlas/migrations/20260602124600_procedure_summary_add_recipe_id.sql new file mode 100644 index 000000000..c1bb04a3b --- /dev/null +++ b/infra/atlas/migrations/20260602124600_procedure_summary_add_recipe_id.sql @@ -0,0 +1,22 @@ +-- Procedure summary projection: additive recipe_id column. +-- +-- Pre-Recipe-rewrite Procedures (registered via the legacy +-- `register_procedure` slice, NOT `register_procedure_from_recipe`) +-- carry `recipe_id=NULL` in state and now also in the projection. +-- Read paths filtering `WHERE recipe_id IS NOT NULL` correctly +-- exclude ceremony Procedures; the `recipe_id` column is NOT total +-- over Procedures. Do not assume otherwise in audit-query authoring. +-- +-- Additive evolution: existing rows keep recipe_id=NULL until +-- explicit backfill (deferred; ceremony Procedures legitimately have +-- no Recipe binding). +-- +-- Mutable read model. cora_app keeps its existing DML grants on +-- proj_operation_procedure_summary. + +ALTER TABLE proj_operation_procedure_summary + ADD COLUMN recipe_id UUID; + +CREATE INDEX proj_operation_procedure_summary_recipe_id_idx + ON proj_operation_procedure_summary (recipe_id) + WHERE recipe_id IS NOT NULL; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index a86021af6..62b595061 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:X6NVsA1+mN1Y2YnG+KY0j94no+Z6xDdC4o+F6yH0VYc= +h1:P0B14+bIVNis7yj9KucpzmgjhlbK4wF+IalghhtccdY= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -97,4 +97,6 @@ h1:X6NVsA1+mN1Y2YnG+KY0j94no+Z6xDdC4o+F6yH0VYc= 20260602110000_add_asset_summary_model.sql h1:fyL6fqusTn9AvPbtE5dX+eqAVdlXRVQEZPzRu2/Dokg= 20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:0Jzc1PKLIbu/IoegZmqFPG1WqCItM9e8uh64F2B2kco= 20260602120000_rename_model_summary_declared_families_column.sql h1:c8xf8/DHlMAT96RKzQra1N7TkcA39gh2cXVbIyBH8LA= -20260603100000_add_asset_summary_alternate_identifiers.sql h1:dZkm4Oi83YzjJBAC4xto7iy+dE+E6+p4mgzsysRE3dc= +20260602124500_init_proj_recipe_recipe_summary.sql h1:nRhKhd5PVNiLUIVma4TiGcAUNp+mVfQZ6k3ghhnJd8M= +20260602124600_procedure_summary_add_recipe_id.sql h1:s1082wIi7qRaRRvqaihYa+C0eM1mpe00NCFx/ZOiv6s= +20260603100000_add_asset_summary_alternate_identifiers.sql h1:nkHWcWAuMr34p00haKYszvpqkA1Omwo8bJs4JZzX2ys=