From d3a70e6dc6edbf81acb8b881c485c77e2f56b27b Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 10:02:18 +0300 Subject: [PATCH 1/5] feat(recipe): split Recipe aggregate from Capability template_body (Stage-1.7) Extracts a new Recipe aggregate into cora/recipe/aggregates/recipe/ mirroring capability/'s per-aggregate layout (state, events, evolver, read, body, steps_validation, __init__). The noun-overloaded Capability surface splits cleanly into Capability (declarer) and Recipe (carrier of the templated step body). TemplateBody wrapper retires; Recipe carries the empty-steps invariant via __post_init__ EmptyRecipeStepsError. capability_id is REQUIRED and IMMUTABLE across versions; replay determinism comes from event-store sequence position, not version_tag string lookup. from_stored arms wrap ValueError and InvalidRecipeStepShapeError into the canonical Malformed Recipe envelope per the cross-aggregate wrap convention. Routes register the 11 new Recipe error classes plus a new 422 _handle_unprocessable handler for the parse-shape and schema-cross-check failure family (InvalidRecipeStepShapeError, RecipeBindingReferencesUnknownParameterError, RecipeRequiresCapabilityParametersSchemaError, UnboundRecipeBindingError). The 400 Invalid family is reserved for VO constructor failures. Two prior gate-review cycles on the design memo plus the v3 verifier pass closed 18 must-fix items before any code landed; this commit's pre-commit gate review (4 reviewers plus adversarial skeptic) returned 3 approve plus 1 approve_with_nits and skeptic commit_ready=true. The skeptic-elevated nit on from_stored extra= coverage was applied inline. 78 unit and PBT tests plus 2 architecture fitness files (3-assertion RecipeStep arm-parity fitness with dispatch-coverage importorskip-deferred to the downstream Operation BC commit; new test_http_422_handler_registered.py asserting both Recipe and Operation routes files register a 422 handler). Slices, routes, tools, wire, Operation BC rename, Atlas migrations, and projection module remain out of scope for separate commits. Co-Authored-By: Claude Opus 4.7 --- .../cora/recipe/aggregates/recipe/__init__.py | 106 +++++++ .../src/cora/recipe/aggregates/recipe/body.py | 282 ++++++++++++++++++ .../cora/recipe/aggregates/recipe/events.py | 233 +++++++++++++++ .../cora/recipe/aggregates/recipe/evolver.py | 97 ++++++ .../src/cora/recipe/aggregates/recipe/read.py | 83 ++++++ .../cora/recipe/aggregates/recipe/state.py | 232 ++++++++++++++ .../aggregates/recipe/steps_validation.py | 140 +++++++++ apps/api/src/cora/recipe/routes.py | 54 ++++ .../test_http_422_handler_registered.py | 95 ++++++ ...t_recipe_step_variants_match_step_union.py | 97 ++++++ .../api/tests/unit/recipe/test_recipe_body.py | 158 ++++++++++ .../test_recipe_body_roundtrip_properties.py | 121 ++++++++ .../tests/unit/recipe/test_recipe_events.py | 206 +++++++++++++ .../tests/unit/recipe/test_recipe_evolver.py | 178 +++++++++++ .../tests/unit/recipe/test_recipe_state.py | 125 ++++++++ .../recipe/test_recipe_steps_validation.py | 100 +++++++ ...test_recipe_steps_validation_properties.py | 94 ++++++ 17 files changed, 2401 insertions(+) create mode 100644 apps/api/src/cora/recipe/aggregates/recipe/__init__.py create mode 100644 apps/api/src/cora/recipe/aggregates/recipe/body.py create mode 100644 apps/api/src/cora/recipe/aggregates/recipe/events.py create mode 100644 apps/api/src/cora/recipe/aggregates/recipe/evolver.py create mode 100644 apps/api/src/cora/recipe/aggregates/recipe/read.py create mode 100644 apps/api/src/cora/recipe/aggregates/recipe/state.py create mode 100644 apps/api/src/cora/recipe/aggregates/recipe/steps_validation.py create mode 100644 apps/api/tests/architecture/test_http_422_handler_registered.py create mode 100644 apps/api/tests/architecture/test_recipe_step_variants_match_step_union.py create mode 100644 apps/api/tests/unit/recipe/test_recipe_body.py create mode 100644 apps/api/tests/unit/recipe/test_recipe_body_roundtrip_properties.py create mode 100644 apps/api/tests/unit/recipe/test_recipe_events.py create mode 100644 apps/api/tests/unit/recipe/test_recipe_evolver.py create mode 100644 apps/api/tests/unit/recipe/test_recipe_state.py create mode 100644 apps/api/tests/unit/recipe/test_recipe_steps_validation.py create mode 100644 apps/api/tests/unit/recipe/test_recipe_steps_validation_properties.py 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..cf6de69e0 --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/__init__.py @@ -0,0 +1,106 @@ +"""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_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, +) +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", + "RecipeVersioned", + "UnboundRecipeBindingError", + "collect_binding_names", + "event_type_name", + "evolve", + "fold", + "from_stored", + "load_recipe", + "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..19801f489 --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/read.py @@ -0,0 +1,83 @@ +# 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 from_stored +from cora.recipe.aggregates.recipe.evolver import fold +from cora.recipe.aggregates.recipe.state import Recipe + +_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_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..2968f68e7 --- /dev/null +++ b/apps/api/src/cora/recipe/aggregates/recipe/state.py @@ -0,0 +1,232 @@ +"""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 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; replay + determinism comes from event-store sequence position, NOT from + tag-string lookup, per [[project-recipe-aggregate-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/routes.py b/apps/api/src/cora/recipe/routes.py index 2e54cfbc0..ef60dbd4d 100644 --- a/apps/api/src/cora/recipe/routes.py +++ b/apps/api/src/cora/recipe/routes.py @@ -86,6 +86,19 @@ PracticeCannotVersionError, PracticeNotFoundError, ) +from cora.recipe.aggregates.recipe import ( + EmptyRecipeStepsError, + InvalidRecipeNameError, + InvalidRecipeStepShapeError, + InvalidRecipeVersionTagError, + RecipeAlreadyExistsError, + RecipeBindingReferencesUnknownParameterError, + RecipeCannotDeprecateError, + RecipeCannotVersionError, + RecipeNotFoundError, + RecipeRequiresCapabilityParametersSchemaError, + UnboundRecipeBindingError, +) from cora.recipe.errors import UnauthorizedError from cora.recipe.features import ( add_plan_wire, @@ -171,6 +184,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_ENTITY, + 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) @@ -215,6 +245,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 +261,7 @@ 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, ): app.add_exception_handler(not_found_cls, _handle_not_found) for already_exists_cls in ( @@ -235,6 +272,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 +309,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/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..8e596fa53 --- /dev/null +++ b/apps/api/tests/architecture/test_http_422_handler_registered.py @@ -0,0 +1,95 @@ +"""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 HTTP_422_UNPROCESSABLE_ENTITY. + + Looks for the canonical FastAPI constant string 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_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_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/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) From ae0b159553ca65c3b1dbc6b71bdea6e47eb760e7 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 11:39:12 +0300 Subject: [PATCH 2/5] feat(recipe): Stage-1.8 ships 4 slices (define / version / deprecate / get) Closes the Recipe aggregate write+read surface per the v3 design memo. Four feature slices ship with the standard 6-file layout (command/decider/handler/route/tool/__init__): define_recipe is idempotency-wrapped and runs cross-aggregate Capability fan-out (BindingRef integrity validated against parameters_schema before decider invocation); version_recipe re-validates BindingRefs against the current Capability state on every call and is intentionally NOT idempotency-wrapped (mirrors version_capability precedent; re-attestation is the audit signal); deprecate_recipe is strict-not-idempotent with no cross-aggregate load; get_recipe is the read DTO (RecipeView) with projection-sourced lifecycle timestamps gated on deps.pool. Wire updates add 4 fields to RecipeHandlers with with_tracing applied across all four and with_idempotency on define_recipe only. Routes and MCP tool registrations land in stable BC order (after the 4 Capability slices, before inspect_plan_binding). The 422 handler maps 4 parse/shape errors (InvalidRecipeStepShapeError, RecipeBindingReferencesUnknownParameterError, RecipeRequiresCapabilityParametersSchemaError, UnboundRecipeBindingError); the last is forward-registered for the Stage-1.9 Operation BC expansion path. Architecture fitness adds deprecate_recipe + version_recipe to GRANDFATHERED_DECIDERS_WITHOUT_PBT (mirrors version_capability + deprecate_capability); define_recipe has paired property-based coverage. The 422 handler test now accepts both HTTP_422_UNPROCESSABLE_CONTENT (newer) and the deprecated HTTP_422_UNPROCESSABLE_ENTITY alias to avoid forcing a cross-BC rewrite. WHY now: the Recipe aggregate was scaffolded in Stage-1.7 (commit d3a70e6dc) but only the genesis source landed; without write+read slices the aggregate is unreachable from REST/MCP and Stage-1.9 (Operation BC port + register_procedure_from_recipe) has no surface to integrate against. Shipping all 4 slices in one commit keeps the wire+routes+tools fan-out atomic and matches Capability slice precedent. Pre-commit gate review (4 reviewers + adversarial skeptic) returned 4 approve_with_nits; skeptic caught a real bug in the new integration test (seed_capability_postgres does not thread parameters_schema, so a BindingRef step on the seeded Capability fails validation). Fix applied: integration test now uses literal step values; BindingRef wire-format round-trip is exercised at the unit tier (test_recipe_body.py + the body roundtrip PBT). Tests: 3 decider + 4 handler + 4 endpoint contract + 4 MCP tool contract + 1 integration + 1 PBT. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 549 ++++++++++++++++++ .../recipe/features/define_recipe/__init__.py | 23 + .../recipe/features/define_recipe/command.py | 34 ++ .../recipe/features/define_recipe/decider.py | 62 ++ .../recipe/features/define_recipe/handler.py | 155 +++++ .../recipe/features/define_recipe/route.py | 120 ++++ .../recipe/features/define_recipe/tool.py | 79 +++ .../features/deprecate_recipe/__init__.py | 21 + .../features/deprecate_recipe/command.py | 23 + .../features/deprecate_recipe/decider.py | 50 ++ .../features/deprecate_recipe/handler.py | 132 +++++ .../recipe/features/deprecate_recipe/route.py | 81 +++ .../recipe/features/deprecate_recipe/tool.py | 56 ++ .../recipe/features/get_recipe/__init__.py | 12 + .../recipe/features/get_recipe/handler.py | 118 ++++ .../cora/recipe/features/get_recipe/query.py | 14 + .../cora/recipe/features/get_recipe/route.py | 118 ++++ .../cora/recipe/features/get_recipe/tool.py | 80 +++ .../features/version_recipe/__init__.py | 24 + .../recipe/features/version_recipe/command.py | 26 + .../recipe/features/version_recipe/decider.py | 72 +++ .../recipe/features/version_recipe/handler.py | 153 +++++ .../recipe/features/version_recipe/route.py | 106 ++++ .../recipe/features/version_recipe/tool.py | 78 +++ apps/api/src/cora/recipe/routes.py | 10 +- apps/api/src/cora/recipe/tools.py | 20 + apps/api/src/cora/recipe/wire.py | 46 +- ...test_decider_changes_require_paired_pbt.py | 2 + .../test_http_422_handler_registered.py | 13 +- .../contract/test_define_recipe_endpoint.py | 136 +++++ .../contract/test_define_recipe_mcp_tool.py | 92 +++ .../test_deprecate_recipe_endpoint.py | 68 +++ .../test_deprecate_recipe_mcp_tool.py | 82 +++ .../contract/test_get_recipe_endpoint.py | 79 +++ .../contract/test_get_recipe_mcp_tool.py | 80 +++ .../contract/test_version_recipe_endpoint.py | 83 +++ .../contract/test_version_recipe_mcp_tool.py | 95 +++ .../test_define_recipe_handler_postgres.py | 68 +++ .../unit/recipe/test_define_recipe_decider.py | 91 +++ .../test_define_recipe_decider_properties.py | 160 +++++ .../unit/recipe/test_define_recipe_handler.py | 205 +++++++ .../recipe/test_deprecate_recipe_decider.py | 69 +++ .../recipe/test_deprecate_recipe_handler.py | 119 ++++ .../unit/recipe/test_get_recipe_handler.py | 104 ++++ .../recipe/test_version_recipe_decider.py | 109 ++++ .../recipe/test_version_recipe_handler.py | 182 ++++++ 46 files changed, 4088 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/cora/recipe/features/define_recipe/__init__.py create mode 100644 apps/api/src/cora/recipe/features/define_recipe/command.py create mode 100644 apps/api/src/cora/recipe/features/define_recipe/decider.py create mode 100644 apps/api/src/cora/recipe/features/define_recipe/handler.py create mode 100644 apps/api/src/cora/recipe/features/define_recipe/route.py create mode 100644 apps/api/src/cora/recipe/features/define_recipe/tool.py create mode 100644 apps/api/src/cora/recipe/features/deprecate_recipe/__init__.py create mode 100644 apps/api/src/cora/recipe/features/deprecate_recipe/command.py create mode 100644 apps/api/src/cora/recipe/features/deprecate_recipe/decider.py create mode 100644 apps/api/src/cora/recipe/features/deprecate_recipe/handler.py create mode 100644 apps/api/src/cora/recipe/features/deprecate_recipe/route.py create mode 100644 apps/api/src/cora/recipe/features/deprecate_recipe/tool.py create mode 100644 apps/api/src/cora/recipe/features/get_recipe/__init__.py create mode 100644 apps/api/src/cora/recipe/features/get_recipe/handler.py create mode 100644 apps/api/src/cora/recipe/features/get_recipe/query.py create mode 100644 apps/api/src/cora/recipe/features/get_recipe/route.py create mode 100644 apps/api/src/cora/recipe/features/get_recipe/tool.py create mode 100644 apps/api/src/cora/recipe/features/version_recipe/__init__.py create mode 100644 apps/api/src/cora/recipe/features/version_recipe/command.py create mode 100644 apps/api/src/cora/recipe/features/version_recipe/decider.py create mode 100644 apps/api/src/cora/recipe/features/version_recipe/handler.py create mode 100644 apps/api/src/cora/recipe/features/version_recipe/route.py create mode 100644 apps/api/src/cora/recipe/features/version_recipe/tool.py create mode 100644 apps/api/tests/contract/test_define_recipe_endpoint.py create mode 100644 apps/api/tests/contract/test_define_recipe_mcp_tool.py create mode 100644 apps/api/tests/contract/test_deprecate_recipe_endpoint.py create mode 100644 apps/api/tests/contract/test_deprecate_recipe_mcp_tool.py create mode 100644 apps/api/tests/contract/test_get_recipe_endpoint.py create mode 100644 apps/api/tests/contract/test_get_recipe_mcp_tool.py create mode 100644 apps/api/tests/contract/test_version_recipe_endpoint.py create mode 100644 apps/api/tests/contract/test_version_recipe_mcp_tool.py create mode 100644 apps/api/tests/integration/test_define_recipe_handler_postgres.py create mode 100644 apps/api/tests/unit/recipe/test_define_recipe_decider.py create mode 100644 apps/api/tests/unit/recipe/test_define_recipe_decider_properties.py create mode 100644 apps/api/tests/unit/recipe/test_define_recipe_handler.py create mode 100644 apps/api/tests/unit/recipe/test_deprecate_recipe_decider.py create mode 100644 apps/api/tests/unit/recipe/test_deprecate_recipe_handler.py create mode 100644 apps/api/tests/unit/recipe/test_get_recipe_handler.py create mode 100644 apps/api/tests/unit/recipe/test_version_recipe_decider.py create mode 100644 apps/api/tests/unit/recipe/test_version_recipe_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5ada28143..72d5fbef9 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -4365,6 +4365,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": { @@ -4526,6 +4572,26 @@ "title": "DeprecateCapabilityRequest", "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": { @@ -7323,6 +7389,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": [ @@ -10742,6 +10907,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": [ @@ -26242,6 +26431,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/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/routes.py b/apps/api/src/cora/recipe/routes.py index ef60dbd4d..fa3e5d4bb 100644 --- a/apps/api/src/cora/recipe/routes.py +++ b/apps/api/src/cora/recipe/routes.py @@ -106,14 +106,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, @@ -125,6 +128,7 @@ version_method, version_plan, version_practice, + version_recipe, ) @@ -196,7 +200,7 @@ async def _handle_unprocessable(request: Request, exc: Exception) -> JSONRespons """ _ = request return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": str(exc)}, ) @@ -226,6 +230,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, 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_decider_changes_require_paired_pbt.py b/apps/api/tests/architecture/test_decider_changes_require_paired_pbt.py index f6935aa79..016ac7f28 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 @@ -137,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", @@ -144,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 index 8e596fa53..702da3ba6 100644 --- a/apps/api/tests/architecture/test_http_422_handler_registered.py +++ b/apps/api/tests/architecture/test_http_422_handler_registered.py @@ -24,17 +24,18 @@ def _has_422_handler(path: Path) -> bool: - """Return True if the module text references HTTP_422_UNPROCESSABLE_ENTITY. + """Return True if the module text references the FastAPI 422 status constant. - Looks for the canonical FastAPI constant string 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. + 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_ENTITY" in text + return "HTTP_422_UNPROCESSABLE_CONTENT" in text or "HTTP_422_UNPROCESSABLE_ENTITY" in text def _module_imports_unprocessable_helper(path: Path) -> bool: 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_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/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_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, + ) From 9624243001161ab268285a9be34ed1d78cbdd311 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 15:15:26 +0300 Subject: [PATCH 3/5] feat(operation): register_procedure_from_recipe slice + Recipe expansion provenance (Stage-1.9) Adds the second Procedure-registration verb, driven by a Recipe + operator bindings rather than an inline step list. The expanded tuple[Step, ...] is computed locally to run the executor-shape, bindings, overflow, and determinism gates but is NOT persisted at v1: a paired ProcedureRegistered + RecipeExpansionRecorded genesis block records (recipe_id, recipe_version, capability_id, capability_version, bindings, expansion_port_version, steps_hash, bindings_hash, step_count), enough to re-expand deterministically at run time. The handler loads Recipe then Capability (Recipe owns steps, Capability owns parameters_schema + executor_shapes) and raises RecipeBindingsStaleAgainstCurrentCapabilityError before any event lands if the Capability re-versioned since the operator chose their bindings, closing the cross-BC race window. ProcedureRegistered gains an additive recipe_id key folded via payload.get for pre-rewrite streams; the evolver's RecipeExpansionRecorded arm is provenance-only and leaves state unchanged. steps_hash content-addresses the expanded tuple[Step, ...] via the new shared steps_to_wire serializer, so downstream re-expansion (run-time replay) can recompute and verify the pin. Default InMemoryRecipeExpansionPort wires the 2-arg pure RecipeExpansionPort(steps, bindings) -> tuple[Step, ...] signature at v1. Routes map InvalidRecipeBindingsError, the stale-Capability error, and RecipeExpansionOverflowError to 422; RecipeExpansionDeterminismError (a real bug surface, not user input) to 500. Atlas migrations add the Recipe projection table plus the procedure_summary.recipe_id column with a partial index for audit-by-Recipe queries; the projection's ProcedureRegistered INSERT populates the new column from payload.get('recipe_id'). A new architecture fitness pins the 4-field denorm payload shape (anti-hook 15); register_procedure_from_recipe.decider is added to GRANDFATHERED; the recipe-step dispatch-coverage test now exercises _recipe_expansion._expand_step. BC map: 28 -> 29 aggregates. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 198 +++++++++++++ .../src/cora/operation/_recipe_expansion.py | 138 +++++++++ .../in_memory_recipe_expansion_port.py | 46 +++ .../aggregates/procedure/__init__.py | 12 + .../operation/aggregates/procedure/events.py | 152 +++++++++- .../operation/aggregates/procedure/evolver.py | 17 ++ .../operation/aggregates/procedure/state.py | 105 +++++++ .../__init__.py | 39 +++ .../register_procedure_from_recipe/command.py | 34 +++ .../register_procedure_from_recipe/decider.py | 174 +++++++++++ .../register_procedure_from_recipe/handler.py | 209 +++++++++++++ .../register_procedure_from_recipe/route.py | 192 ++++++++++++ .../register_procedure_from_recipe/tool.py | 111 +++++++ .../operation/ports/recipe_expansion_port.py | 58 ++++ .../cora/operation/projections/procedure.py | 8 +- apps/api/src/cora/operation/routes.py | 54 ++++ apps/api/src/cora/operation/tools.py | 7 + apps/api/src/cora/operation/wire.py | 23 ++ apps/api/src/cora/recipe/_projections.py | 4 +- .../src/cora/recipe/projections/__init__.py | 2 + .../api/src/cora/recipe/projections/recipe.py | 125 ++++++++ ...test_decider_changes_require_paired_pbt.py | 1 + ...ecorded_denorm_matches_capability_state.py | 109 +++++++ ...register_procedure_from_recipe_endpoint.py | 132 +++++++++ ...register_procedure_from_recipe_mcp_tool.py | 85 ++++++ ..._procedure_from_recipe_handler_postgres.py | 140 +++++++++ .../unit/operation/test_procedure_events.py | 10 +- ..._register_procedure_from_recipe_decider.py | 274 +++++++++++++++++ ..._register_procedure_from_recipe_handler.py | 279 ++++++++++++++++++ .../test_register_procedure_handler.py | 1 + ...124500_init_proj_recipe_recipe_summary.sql | 71 +++++ ...124600_procedure_summary_add_recipe_id.sql | 22 ++ infra/atlas/migrations/atlas.sum | 4 +- 33 files changed, 2819 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/cora/operation/_recipe_expansion.py create mode 100644 apps/api/src/cora/operation/adapters/in_memory_recipe_expansion_port.py create mode 100644 apps/api/src/cora/operation/features/register_procedure_from_recipe/__init__.py create mode 100644 apps/api/src/cora/operation/features/register_procedure_from_recipe/command.py create mode 100644 apps/api/src/cora/operation/features/register_procedure_from_recipe/decider.py create mode 100644 apps/api/src/cora/operation/features/register_procedure_from_recipe/handler.py create mode 100644 apps/api/src/cora/operation/features/register_procedure_from_recipe/route.py create mode 100644 apps/api/src/cora/operation/features/register_procedure_from_recipe/tool.py create mode 100644 apps/api/src/cora/operation/ports/recipe_expansion_port.py create mode 100644 apps/api/src/cora/recipe/projections/recipe.py create mode 100644 apps/api/tests/architecture/test_recipe_expansion_recorded_denorm_matches_capability_state.py create mode 100644 apps/api/tests/contract/test_register_procedure_from_recipe_endpoint.py create mode 100644 apps/api/tests/contract/test_register_procedure_from_recipe_mcp_tool.py create mode 100644 apps/api/tests/integration/test_register_procedure_from_recipe_handler_postgres.py create mode 100644 apps/api/tests/unit/operation/test_register_procedure_from_recipe_decider.py create mode 100644 apps/api/tests/unit/operation/test_register_procedure_from_recipe_handler.py create mode 100644 infra/atlas/migrations/20260602124500_init_proj_recipe_recipe_summary.sql create mode 100644 infra/atlas/migrations/20260602124600_procedure_summary_add_recipe_id.sql diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 72d5fbef9..369ac7a60 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -8408,6 +8408,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": { @@ -25805,6 +25880,129 @@ ] } }, + "/procedures/from-recipe": { + "post": { + "operationId": "post_procedures_from_recipe_procedures_from_recipe_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 procedure.", + "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 procedure.", + "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/RegisterProcedureFromRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterProcedureFromRecipeResponse" + } + } + }, + "description": "Successful Response" + }, + "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", 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..abd8d6437 --- /dev/null +++ b/apps/api/src/cora/operation/_recipe_expansion.py @@ -0,0 +1,138 @@ +"""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.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__ = ["expand", "steps_to_wire"] 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 0bcc7b1db..77e3d7991 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/__init__.py +++ b/apps/api/src/cora/operation/aggregates/procedure/__init__.py @@ -24,6 +24,7 @@ ProcedureStarted, ProcedureStepsLogbookOpened, ProcedureTruncated, + RecipeExpansionRecorded, event_type_name, from_stored, to_payload, @@ -36,6 +37,7 @@ 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 +45,7 @@ InvalidProcedureKindError, InvalidProcedureNameError, InvalidProcedureTruncateReasonError, + InvalidRecipeBindingsError, InvalidStepKindError, Procedure, ProcedureAbortReason, @@ -60,6 +63,9 @@ ProcedureStepsLogbookClosedError, ProcedureSupplyCoverageMismatchError, ProcedureTruncateReason, + RecipeBindingsStaleAgainstCurrentCapabilityError, + RecipeExpansionDeterminismError, + RecipeExpansionOverflowError, StepKind, ) @@ -69,6 +75,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 +84,7 @@ "InvalidProcedureKindError", "InvalidProcedureNameError", "InvalidProcedureTruncateReasonError", + "InvalidRecipeBindingsError", "InvalidStepKindError", "PostgresStepStore", "Procedure", @@ -103,6 +111,10 @@ "ProcedureSupplyCoverageMismatchError", "ProcedureTruncateReason", "ProcedureTruncated", + "RecipeBindingsStaleAgainstCurrentCapabilityError", + "RecipeExpansionDeterminismError", + "RecipeExpansionOverflowError", + "RecipeExpansionRecorded", "StepKind", "StepStore", "event_type_name", diff --git a/apps/api/src/cora/operation/aggregates/procedure/events.py b/apps/api/src/cora/operation/aggregates/procedure/events.py index c059c8118..2e70d3df0 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/events.py +++ b/apps/api/src/cora/operation/aggregates/procedure/events.py @@ -47,6 +47,8 @@ `SupplyRegistered` / `SubjectMounted`. """ +import json +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from typing import Any, assert_never @@ -71,11 +73,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 +97,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 +298,7 @@ class ProcedureAborted: | ProcedureAborted | ProcedureTruncated | ProcedureStepsLogbookOpened + | RecipeExpansionRecorded ) @@ -255,6 +324,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 +332,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 +387,38 @@ 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 sort_keys for bindings so the persisted + # payload bytes are deterministic and `sha256(payload['bindings'])` + # reproduces `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( + json.dumps(dict(bindings), sort_keys=True, separators=(",", ":")) + ), + "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 +446,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 +459,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 +516,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 +547,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/state.py b/apps/api/src/cora/operation/aggregates/procedure/state.py index a7dbdc26d..84fb4f629 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/state.py +++ b/apps/api/src/cora/operation/aggregates/procedure/state.py @@ -302,6 +302,98 @@ 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 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 +759,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/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..73db5b881 --- /dev/null +++ b/apps/api/src/cora/operation/features/register_procedure_from_recipe/decider.py @@ -0,0 +1,174 @@ +"""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 +import json +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 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 _canonical_json_bytes(value: object) -> bytes: + """Deterministic JSON serialization for content hashing.""" + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +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 0ee53f54d..715e1636f 100644 --- a/apps/api/src/cora/operation/routes.py +++ b/apps/api/src/cora/operation/routes.py @@ -40,6 +40,7 @@ InvalidProcedureKindError, InvalidProcedureNameError, InvalidProcedureTruncateReasonError, + InvalidRecipeBindingsError, InvalidStepKindError, ProcedureAlreadyExistsError, ProcedureAssetDecommissionedError, @@ -52,6 +53,9 @@ ProcedureRequiresAvailableSupplyError, ProcedureStepsLogbookClosedError, ProcedureSupplyCoverageMismatchError, + RecipeBindingsStaleAgainstCurrentCapabilityError, + RecipeExpansionDeterminismError, + RecipeExpansionOverflowError, ) from cora.operation.errors import UnauthorizedError from cora.operation.features import ( @@ -61,6 +65,7 @@ get_procedure, list_procedures, register_procedure, + register_procedure_from_recipe, run_procedure, start_procedure, truncate_procedure, @@ -132,9 +137,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) @@ -177,4 +218,17 @@ 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 bug; mapped to HTTP 500. Distinct from + # operator-error 422 because re-trying with the same payload will + # fail the same way. + app.add_exception_handler(RecipeExpansionDeterminismError, _handle_internal_server_error) 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 bf310cf19..3888fb9ed 100644 --- a/apps/api/src/cora/operation/tools.py +++ b/apps/api/src/cora/operation/tools.py @@ -17,6 +17,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.run_procedure import tool as run_procedure_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 @@ -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 32817fd90..2de96d2a0 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, @@ -71,6 +74,7 @@ get_procedure, list_procedures, register_procedure, + register_procedure_from_recipe, run_procedure, 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, 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/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/tests/architecture/test_decider_changes_require_paired_pbt.py b/apps/api/tests/architecture/test_decider_changes_require_paired_pbt.py index 016ac7f28..9a25912c1 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 @@ -127,6 +127,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", 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/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/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/unit/operation/test_procedure_events.py b/apps/api/tests/unit/operation/test_procedure_events.py index c8e63d7dd..01014a36d 100644 --- a/apps/api/tests/unit/operation/test_procedure_events.py +++ b/apps/api/tests/unit/operation/test_procedure_events.py @@ -74,9 +74,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(), } 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/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 59dd11527..dc5c69854 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:y05CiZELkWOJhbiFLkwa8w8Walbd5cuKW1/L48GOmIE= +h1:SoyU0urTVpL4VbhVZyZTrZCppmTaDPwRZhqpkd/KE/8= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -88,3 +88,5 @@ h1:y05CiZELkWOJhbiFLkwa8w8Walbd5cuKW1/L48GOmIE= 20260530300000_add_asset_summary_drawing.sql h1:WEgYsELX5ZFz5YpFZE6oLbnvsdSlXiuC8pyS7oACg/A= 20260601100000_rename_seal_key_ref_to_credential_id.sql h1:IG5CW4004E6e0oGFwkuMd/mBV4GpoW4F4dT30M1gOPc= 20260601100100_rename_frame_summary_placement_column.sql h1:0HGnYvvBZ/4KR4U/IXwpbPru0HaiitiOtkTx24pe640= +20260602124500_init_proj_recipe_recipe_summary.sql h1:rsFjEvv68B4oP3YYWuGh+vvJJCsP812/oCGaXCNjJ/w= +20260602124600_procedure_summary_add_recipe_id.sql h1:j9oCx4iSsOG2WoyG5CyJ7w909gpyUbAhddhvJHh2y9o= From 77d8ef51df42b0b1a23520839b9d7e746317b7f3 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 20:36:43 +0300 Subject: [PATCH 4/5] feat(operation): replay recipe-driven Procedures from RecipeExpansionRecorded pin (Stage-1.10) run_procedure must produce byte-identical steps across replays for recipe-driven Procedures or the event log is unsafe to fold. The handler now re-expands at runtime against the live RecipeExpansionPort, verifies the bindings_hash and steps_hash against the pin in RecipeExpansionRecorded, and asserts the port's version matches the recorded one. Caller-supplied steps are rejected up front for recipe-driven Procedures rather than silently overridden. Adds two read helpers behind the cross-aggregate fetch: load_procedure_with_events returns (state, raw StoredEvent list) from a single event_store.load so the handler can scan for the genesis RecipeExpansionRecorded payload without doubling IO; load_procedure becomes a thin wrapper that preserves every existing call site. load_recipe_at_version walks the Recipe event stream and folds to the first-match-from-head version_tag snapshot per the replay-design Locks (re-tagging is allowed and the earlier RecipeVersioned binds the earlier RecipeExpansionRecorded by construction). Hoists canonical_json_bytes to cora.infrastructure.canonical_json so events.py (blocked by tach from importing cora.operation ._recipe_expansion) can preserve the bindings dict shape via json.loads(canonical_json_bytes(...)) at to_payload time. A new architecture fitness scoped to cora/operation/ + cora/recipe/ trees AST-walks json.dumps(sort_keys=True) calls and rejects any inline duplication of the canonicalizer. Four new Procedure error classes cover the replay failure modes: ProcedureStepsForbiddenForRecipeDrivenError (caller bug, 400); RecipeExpansionPortVersionMismatchError (pinned port v drift, 500, placeholder until a v2 expansion port lands with its routing layer); RecipeExpansionRecordNotFoundError (data corruption guard: missing event, corrupt payload, or empty Recipe stream, 500); RecipeExpansionReplayMismatchError (closed Literal[bindings, steps] discriminator, 500). Recipe BC adds RecipeVersionNotFoundError (404) when load_recipe_at_version cannot resolve a tag. run_procedure handler also now loads the Procedure stream and raises ProcedureNotFoundError when missing, aligning the handler tier with the route tier's 404 mapping (closes the prior 200+lifecycle-failure drift on unregistered Procedures across REST + MCP). Memo updated at memory/project_run_procedure_replay_design.md with SHIPPED status + implementation notes covering the tach-forced hoist to infrastructure, the Missing -> NotFound rename, and the contract-test alignment. Co-Authored-By: Claude Opus 4.7 --- .../src/cora/infrastructure/canonical_json.py | 36 ++ .../src/cora/operation/_recipe_expansion.py | 3 +- apps/api/src/cora/operation/_recipe_replay.py | 148 +++++++ .../aggregates/procedure/__init__.py | 14 +- .../operation/aggregates/procedure/events.py | 16 +- .../operation/aggregates/procedure/read.py | 38 +- .../operation/aggregates/procedure/state.py | 98 +++++ .../register_procedure_from_recipe/decider.py | 12 +- .../features/run_procedure/handler.py | 127 +++++- apps/api/src/cora/operation/routes.py | 35 +- apps/api/src/cora/operation/wire.py | 2 +- .../cora/recipe/aggregates/recipe/__init__.py | 4 + .../src/cora/recipe/aggregates/recipe/read.py | 55 ++- .../cora/recipe/aggregates/recipe/state.py | 29 +- apps/api/src/cora/recipe/routes.py | 2 + ...test_canonical_json_bytes_single_source.py | 117 ++++++ .../contract/test_run_procedure_endpoint.py | 18 +- .../contract/test_run_procedure_mcp_tool.py | 17 +- ...st_register_then_run_procedure_postgres.py | 213 ++++++++++ .../test_load_procedure_with_events.py | 129 ++++++ .../unit/operation/test_procedure_events.py | 31 ++ .../unit/operation/test_recipe_replay.py | 188 +++++++++ .../operation/test_run_procedure_handler.py | 378 +++++++++++++++++- .../recipe/test_load_recipe_at_version.py | 179 +++++++++ 24 files changed, 1821 insertions(+), 68 deletions(-) create mode 100644 apps/api/src/cora/infrastructure/canonical_json.py create mode 100644 apps/api/src/cora/operation/_recipe_replay.py create mode 100644 apps/api/tests/architecture/test_canonical_json_bytes_single_source.py create mode 100644 apps/api/tests/integration/test_register_then_run_procedure_postgres.py create mode 100644 apps/api/tests/unit/operation/test_load_procedure_with_events.py create mode 100644 apps/api/tests/unit/operation/test_recipe_replay.py create mode 100644 apps/api/tests/unit/recipe/test_load_recipe_at_version.py 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 index abd8d6437..96f3a8e30 100644 --- a/apps/api/src/cora/operation/_recipe_expansion.py +++ b/apps/api/src/cora/operation/_recipe_expansion.py @@ -17,6 +17,7 @@ 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, @@ -135,4 +136,4 @@ def steps_to_wire(steps: tuple[Step, ...]) -> list[dict[str, Any]]: return [_step_to_wire(step) for step in steps] -__all__ = ["expand", "steps_to_wire"] +__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..c3a296f6b --- /dev/null +++ b/apps/api/src/cora/operation/_recipe_replay.py @@ -0,0 +1,148 @@ +"""Recipe-expansion replay helpers for the `run_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/aggregates/procedure/__init__.py b/apps/api/src/cora/operation/aggregates/procedure/__init__.py index 77e3d7991..0db22e946 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/__init__.py +++ b/apps/api/src/cora/operation/aggregates/procedure/__init__.py @@ -30,7 +30,10 @@ 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, @@ -60,12 +63,16 @@ ProcedureNotFoundError, ProcedureRequiresAvailableSupplyError, ProcedureStatus, + ProcedureStepsForbiddenForRecipeDrivenError, ProcedureStepsLogbookClosedError, ProcedureSupplyCoverageMismatchError, ProcedureTruncateReason, RecipeBindingsStaleAgainstCurrentCapabilityError, RecipeExpansionDeterminismError, RecipeExpansionOverflowError, + RecipeExpansionPortVersionMismatchError, + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, StepKind, ) @@ -106,6 +113,7 @@ "ProcedureStarted", "ProcedureStatus", "ProcedureStep", + "ProcedureStepsForbiddenForRecipeDrivenError", "ProcedureStepsLogbookClosedError", "ProcedureStepsLogbookOpened", "ProcedureSupplyCoverageMismatchError", @@ -114,7 +122,10 @@ "RecipeBindingsStaleAgainstCurrentCapabilityError", "RecipeExpansionDeterminismError", "RecipeExpansionOverflowError", + "RecipeExpansionPortVersionMismatchError", + "RecipeExpansionRecordNotFoundError", "RecipeExpansionRecorded", + "RecipeExpansionReplayMismatchError", "StepKind", "StepStore", "event_type_name", @@ -122,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 2e70d3df0..d84c65d58 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/events.py +++ b/apps/api/src/cora/operation/aggregates/procedure/events.py @@ -54,6 +54,7 @@ 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 @@ -400,19 +401,20 @@ def to_payload(event: ProcedureEvent) -> dict[str, Any]: step_count=step_count, occurred_at=occurred_at, ): - # Canonical-JSON sort_keys for bindings so the persisted - # payload bytes are deterministic and `sha256(payload['bindings'])` - # reproduces `bindings_hash`. Recipe.steps wire-format is - # JSON-friendly by construction (no UUID values inside). + # 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( - json.dumps(dict(bindings), sort_keys=True, separators=(",", ":")) - ), + "bindings": json.loads(canonical_json_bytes(dict(bindings))), "expansion_port_version": expansion_port_version, "steps_hash": steps_hash, "bindings_hash": bindings_hash, diff --git a/apps/api/src/cora/operation/aggregates/procedure/read.py b/apps/api/src/cora/operation/aggregates/procedure/read.py index b1da26848..01fc77727 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 `run_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 84fb4f629..caf5acaae 100644 --- a/apps/api/src/cora/operation/aggregates/procedure/state.py +++ b/apps/api/src/cora/operation/aggregates/procedure/state.py @@ -341,6 +341,104 @@ def __init__(self, recipe_id: UUID) -> None: 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 + `run_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 run_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 `run_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. 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 index 73db5b881..c60d74a8e 100644 --- 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 @@ -43,7 +43,6 @@ """ import hashlib -import json from collections.abc import Mapping from datetime import datetime from typing import Any @@ -51,7 +50,7 @@ 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 steps_to_wire +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, @@ -76,11 +75,6 @@ from cora.recipe.aggregates.recipe import Recipe -def _canonical_json_bytes(value: object) -> bytes: - """Deterministic JSON serialization for content hashing.""" - return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") - - def _hash_steps(steps: tuple[Step, ...]) -> str: """Content-address the expanded Step tuple per memo §RecipeExpansionRecorded. @@ -89,11 +83,11 @@ def _hash_steps(steps: tuple[Step, ...]) -> str: re-version that produces equivalent expanded steps still hashes identically. """ - return hashlib.sha256(_canonical_json_bytes(steps_to_wire(steps))).hexdigest() + 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() + return hashlib.sha256(canonical_json_bytes(dict(bindings))).hexdigest() def decide( diff --git a/apps/api/src/cora/operation/features/run_procedure/handler.py b/apps/api/src/cora/operation/features/run_procedure/handler.py index 0969b6a4e..5eac87b11 100644 --- a/apps/api/src/cora/operation/features/run_procedure/handler.py +++ b/apps/api/src/cora/operation/features/run_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 RunProcedure entry point gates the entire invocation), envelope -threading, and result conversion from `ConductorResult` to the -slice's `RunProcedureResult` 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 `RunProcedureResult` contract. ## Why no `_decider` @@ -17,31 +18,57 @@ 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 `RunProcedure` is authz-checked as a distinct command. The wrapped handlers (start / append / complete / abort) each authz internally with their OWN command names; an operator authorized to call `RunProcedure` is NOT automatically authorized for each of those -individually. That's correct: `RunProcedure` is the -operator-friendly entry; the underlying per-FSM-transition -authorization is what the policy engine actually evaluates at -each call site. +individually. """ +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.run_procedure.command import ( RunProcedure, RunProcedureResult, ) +from cora.operation.ports.recipe_expansion_port import RecipeExpansionPort +from cora.recipe.aggregates.recipe import load_recipe_at_version _COMMAND_NAME = "RunProcedure" @@ -62,12 +89,21 @@ async def __call__( ) -> RunProcedureResult: ... -def bind(deps: Kernel, *, conductor: Conductor) -> Handler: - """Build a run_procedure handler closed over deps + the Conductor. +def bind( + deps: Kernel, + *, + conductor: Conductor, + expansion_port: RecipeExpansionPort, +) -> Handler: + """Build a run_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 +141,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 +185,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/routes.py b/apps/api/src/cora/operation/routes.py index 715e1636f..75453e9e0 100644 --- a/apps/api/src/cora/operation/routes.py +++ b/apps/api/src/cora/operation/routes.py @@ -51,11 +51,15 @@ ProcedureCapabilityExecutorMismatchError, ProcedureNotFoundError, ProcedureRequiresAvailableSupplyError, + ProcedureStepsForbiddenForRecipeDrivenError, ProcedureStepsLogbookClosedError, ProcedureSupplyCoverageMismatchError, RecipeBindingsStaleAgainstCurrentCapabilityError, RecipeExpansionDeterminismError, RecipeExpansionOverflowError, + RecipeExpansionPortVersionMismatchError, + RecipeExpansionRecordNotFoundError, + RecipeExpansionReplayMismatchError, ) from cora.operation.errors import UnauthorizedError from cora.operation.features import ( @@ -191,6 +195,10 @@ def register_operation_routes(app: FastAPI) -> None: InvalidProcedureTruncateReasonError, InvalidProcedureInterruptedAtError, InvalidStepKindError, + # Recipe-driven run_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,): @@ -227,8 +235,27 @@ def register_operation_routes(app: FastAPI) -> None: RecipeExpansionOverflowError, ): app.add_exception_handler(unprocessable_cls, _handle_unprocessable) - # Server-side determinism bug; mapped to HTTP 500. Distinct from - # operator-error 422 because re-trying with the same payload will - # fail the same way. - app.add_exception_handler(RecipeExpansionDeterminismError, _handle_internal_server_error) + # 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 run_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/wire.py b/apps/api/src/cora/operation/wire.py index 2de96d2a0..2a267d1e6 100644 --- a/apps/api/src/cora/operation/wire.py +++ b/apps/api/src/cora/operation/wire.py @@ -222,7 +222,7 @@ def wire_operation(deps: Kernel) -> OperationHandlers: kind="query", ), run_procedure=with_tracing( - run_procedure.bind(deps, conductor=conductor), + run_procedure.bind(deps, conductor=conductor, expansion_port=recipe_expansion_port), command_name="RunProcedure", bc=_BC, ), diff --git a/apps/api/src/cora/recipe/aggregates/recipe/__init__.py b/apps/api/src/cora/recipe/aggregates/recipe/__init__.py index cf6de69e0..be636d7de 100644 --- a/apps/api/src/cora/recipe/aggregates/recipe/__init__.py +++ b/apps/api/src/cora/recipe/aggregates/recipe/__init__.py @@ -41,6 +41,7 @@ from cora.recipe.aggregates.recipe.read import ( RecipeLifecycleTimestamps, load_recipe, + load_recipe_at_version, load_recipe_timestamps, ) from cora.recipe.aggregates.recipe.state import ( @@ -56,6 +57,7 @@ RecipeName, RecipeNotFoundError, RecipeStatus, + RecipeVersionNotFoundError, ) from cora.recipe.aggregates.recipe.steps_validation import ( RecipeBindingReferencesUnknownParameterError, @@ -89,6 +91,7 @@ "RecipeSetpointStep", "RecipeStatus", "RecipeStep", + "RecipeVersionNotFoundError", "RecipeVersioned", "UnboundRecipeBindingError", "collect_binding_names", @@ -97,6 +100,7 @@ "fold", "from_stored", "load_recipe", + "load_recipe_at_version", "load_recipe_timestamps", "resolve_value", "steps_from_dict", diff --git a/apps/api/src/cora/recipe/aggregates/recipe/read.py b/apps/api/src/cora/recipe/aggregates/recipe/read.py index 19801f489..57c444785 100644 --- a/apps/api/src/cora/recipe/aggregates/recipe/read.py +++ b/apps/api/src/cora/recipe/aggregates/recipe/read.py @@ -26,9 +26,15 @@ import asyncpg from cora.infrastructure.ports import EventStore -from cora.recipe.aggregates.recipe.events import from_stored +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 +from cora.recipe.aggregates.recipe.state import ( + Recipe, + RecipeVersionNotFoundError, +) _STREAM_TYPE = "Recipe" @@ -62,6 +68,51 @@ async def load_recipe(event_store: EventStore, recipe_id: UUID) -> Recipe | None 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 `run_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, diff --git a/apps/api/src/cora/recipe/aggregates/recipe/state.py b/apps/api/src/cora/recipe/aggregates/recipe/state.py index 2968f68e7..5730050ce 100644 --- a/apps/api/src/cora/recipe/aggregates/recipe/state.py +++ b/apps/api/src/cora/recipe/aggregates/recipe/state.py @@ -143,6 +143,24 @@ def __init__(self, recipe_id: UUID) -> None: 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`. @@ -209,9 +227,14 @@ class Recipe: `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; replay - determinism comes from event-store sequence position, NOT from - tag-string lookup, per [[project-recipe-aggregate-design]] Locks. + 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 diff --git a/apps/api/src/cora/recipe/routes.py b/apps/api/src/cora/recipe/routes.py index fa3e5d4bb..4b17fe683 100644 --- a/apps/api/src/cora/recipe/routes.py +++ b/apps/api/src/cora/recipe/routes.py @@ -97,6 +97,7 @@ RecipeCannotVersionError, RecipeNotFoundError, RecipeRequiresCapabilityParametersSchemaError, + RecipeVersionNotFoundError, UnboundRecipeBindingError, ) from cora.recipe.errors import UnauthorizedError @@ -270,6 +271,7 @@ def register_recipe_routes(app: FastAPI) -> None: # 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 ( 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/contract/test_run_procedure_endpoint.py b/apps/api/tests/contract/test_run_procedure_endpoint.py index 9b8a61478..b6fb38cd7 100644 --- a/apps/api/tests/contract/test_run_procedure_endpoint.py +++ b/apps/api/tests/contract/test_run_procedure_endpoint.py @@ -106,22 +106,20 @@ def test_post_run_with_setpoint_to_unconnected_address_returns_not_connected_fai @pytest.mark.contract -def test_post_run_against_unregistered_procedure_returns_200_with_lifecycle_failure() -> None: - """conduct() catches start_procedure rejections -> lifecycle failure on result.""" +def test_post_run_against_unregistered_procedure_returns_404() -> None: + """[[project-run-procedure-replay-design]] aligned the + handler-tier load with the route-tier ProcedureNotFoundError -> 404 + mapping; running a Procedure that does not exist no longer dispatches + to the Conductor (which previously caught the start_procedure failure + and surfaced it as a 200 + lifecycle-failure payload). The 404 is the + more accurate signal: the resource the operator addressed is missing.""" with TestClient(create_app()) as client: unknown_pid = uuid4() run = client.post( f"/procedures/{unknown_pid}/run", json={"steps": []}, ) - assert run.status_code == 200 - payload = run.json() - assert payload["succeeded"] is False - failure = payload["failure"] - assert failure["step_index"] is None - assert failure["source_kind"] == "lifecycle" - assert failure["target"] == "start" - assert failure["error_class"] == "ProcedureNotFoundError" + assert run.status_code == 404 @pytest.mark.contract diff --git a/apps/api/tests/contract/test_run_procedure_mcp_tool.py b/apps/api/tests/contract/test_run_procedure_mcp_tool.py index 81763108a..dfa0221c5 100644 --- a/apps/api/tests/contract/test_run_procedure_mcp_tool.py +++ b/apps/api/tests/contract/test_run_procedure_mcp_tool.py @@ -106,8 +106,12 @@ def test_mcp_run_procedure_with_unknown_action_returns_failure_in_structured_con @pytest.mark.contract -def test_mcp_run_procedure_against_unregistered_procedure_returns_lifecycle_failure() -> None: - """conduct() catches start_procedure rejection -> lifecycle failure on result.""" +def test_mcp_run_procedure_against_unregistered_procedure_returns_is_error() -> None: + """[[project-run-procedure-replay-design]] aligned the + handler-tier load with the route-tier ProcedureNotFoundError mapping; + running an unregistered Procedure now surfaces as tools/call isError + instead of a 200 + lifecycle-failure payload. FastMCP wraps domain + exceptions generically (no allowlist).""" with TestClient(create_app()) as client: headers = open_session(client) unknown_pid = uuid4() @@ -127,10 +131,5 @@ def test_mcp_run_procedure_against_unregistered_procedure_returns_lifecycle_fail }, headers=headers, ) - structured: dict[str, Any] = parse_sse_data(response.text)["result"]["structuredContent"] - assert structured["succeeded"] is False - failure = structured["failure"] - assert failure["step_index"] is None - assert failure["source_kind"] == "lifecycle" - assert failure["target"] == "start" - assert failure["error_class"] == "ProcedureNotFoundError" + result = parse_sse_data(response.text)["result"] + assert result.get("isError") is True diff --git a/apps/api/tests/integration/test_register_then_run_procedure_postgres.py b/apps/api/tests/integration/test_register_then_run_procedure_postgres.py new file mode 100644 index 000000000..43d742dae --- /dev/null +++ b/apps/api/tests/integration/test_register_then_run_procedure_postgres.py @@ -0,0 +1,213 @@ +"""End-to-end integration: register_procedure_from_recipe -> run_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 run_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.register_procedure_from_recipe import ( + RegisterProcedureFromRecipe, +) +from cora.operation.features.run_procedure import RunProcedure +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_run_procedure_succeeds_postgres( + db_pool: asyncpg.Pool, +) -> None: + """The handler chain registers a recipe-driven Procedure then runs + it via run_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.run_procedure( + RunProcedure(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_run_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; run_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.run_procedure( + RunProcedure(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_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 01014a36d..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, @@ -515,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_run_procedure_handler.py b/apps/api/tests/unit/operation/test_run_procedure_handler.py index 62b67f85a..f2e7d97fb 100644 --- a/apps/api/tests/unit/operation/test_run_procedure_handler.py +++ b/apps/api/tests/unit/operation/test_run_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, @@ -44,6 +55,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 run_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: @@ -105,14 +151,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 run_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 --------------------------------------------------- @@ -125,8 +176,14 @@ async def test_run_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), @@ -163,10 +220,16 @@ async def test_run_procedure_handler_propagates_failure_from_conductor() -> None 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( RunProcedure(procedure_id=procedure_id, steps=()), principal_id=uuid4(), @@ -179,7 +242,11 @@ async def test_run_procedure_handler_propagates_failure_from_conductor() -> None @pytest.mark.unit async def test_run_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( RunProcedure(procedure_id=uuid4(), steps=()), @@ -377,3 +444,302 @@ def test_run_procedure_request_with_empty_step_list_is_valid() -> None: def test_run_procedure_request_default_is_empty_step_list() -> None: body = RunProcedureRequest.model_validate({}) assert body.steps == [] + + +# --- recipe-replay branch -------------------------------------- +# +# These tests pin the recipe-driven branch of `run_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_run_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( + RunProcedure(procedure_id=procedure_id, steps=steps), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert conductor.calls[0].steps == steps + + +@pytest.mark.unit +async def test_run_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( + RunProcedure(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( + RunProcedure( + 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( + RunProcedure(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( + RunProcedure(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( + RunProcedure(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( + RunProcedure(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_run_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( + RunProcedure(procedure_id=uuid4(), steps=()), + principal_id=uuid4(), + correlation_id=uuid4(), + ) + assert conductor.calls == [] 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" From 75d89b8c3a0f9683febfe2a539b5b3aa8eced5d6 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Wed, 3 Jun 2026 15:11:26 +0300 Subject: [PATCH 5/5] fix(test): add recipe_id=None to register_procedure integration test payload Stage-1.9 added an additive `recipe_id: UUID | None` field to the `ProcedureRegistered` event payload (default None for legacy register_procedure that does not bind to a Recipe). The unit-tier test for the legacy slice was updated then, but this integration test asserts the persisted payload by full equality and was missed in the sweep, so it caught the additive key after the merge with origin/main. CI run 26874299577 surfaced it. Co-Authored-By: Claude Opus 4.7 --- .../integration/test_register_procedure_handler_postgres.py | 1 + 1 file changed, 1 insertion(+) 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