diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 939860791..37fdb26e9 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7714,6 +7714,19 @@ "$ref": "#/components/schemas/AssetLevel", "description": "Hierarchical level. One of: Enterprise (root, requires null parent_id), Site, Area, Unit, Assembly, Device." }, + "model_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional reference to the Model catalog entry this Asset is an instance of (Family -> Model -> Assembly -> Asset ladder). Set ONCE at registration; rebind path is decommission + re-register. The handler verifies the Model stream exists before invoking the decider (404 if missing); no subset check at register time because the genesis Asset families set is empty.", + "title": "Model Id" + }, "name": { "description": "Display name for the new asset.", "maxLength": 200, @@ -13434,6 +13447,16 @@ }, "description": "Authorize port denied the command." }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "model_id was supplied but the referenced Model stream does not exist (ModelNotFoundError)." + }, "422": { "description": "Request body failed schema validation (unknown level, missing fields, malformed UUID) OR Idempotency-Key was reused with a different request body." } diff --git a/apps/api/src/cora/equipment/aggregates/asset/__init__.py b/apps/api/src/cora/equipment/aggregates/asset/__init__.py index f7a171690..44a8f5d8b 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/asset/__init__.py @@ -45,6 +45,7 @@ AssetCondition, AssetLevel, AssetLifecycle, + AssetModelMismatchError, AssetName, AssetNotFoundError, AssetPort, @@ -83,6 +84,7 @@ "AssetLifecycle", "AssetMaintenanceEntered", "AssetMaintenanceExited", + "AssetModelMismatchError", "AssetName", "AssetNotFoundError", "AssetPort", diff --git a/apps/api/src/cora/equipment/aggregates/asset/events.py b/apps/api/src/cora/equipment/aggregates/asset/events.py index 470dcf443..474d4c0e2 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/events.py +++ b/apps/api/src/cora/equipment/aggregates/asset/events.py @@ -78,6 +78,16 @@ class AssetRegistered: the engineering build-to spec for the physical specimen. Defaults to None so legacy AssetRegistered streams (no drawing in the payload) fold cleanly via the additive-payload pattern. + + `model_id` is an optional reference to the Model catalog entry + this Asset is an instance of (Family -> Model -> Assembly -> + Asset ladder). Set at registration per the model-binding design + memo (Lock A); rebind path is decommission + re-register. + Defaults to None so legacy AssetRegistered streams (no model_id + in the payload) fold cleanly via the additive-payload pattern; + `to_payload` uses the omit-when-None convention (key absent + rather than serialized as JSON null) to mirror the `drawing` + precedent. """ asset_id: UUID @@ -86,6 +96,7 @@ class AssetRegistered: parent_id: UUID | None occurred_at: datetime drawing: Drawing | None = None + model_id: UUID | None = None @dataclass(frozen=True) @@ -355,6 +366,7 @@ def to_payload(event: AssetEvent) -> dict[str, Any]: parent_id=parent_id, occurred_at=occurred_at, drawing=drawing, + model_id=model_id, ): payload: dict[str, Any] = { "asset_id": str(asset_id), @@ -369,6 +381,8 @@ def to_payload(event: AssetEvent) -> dict[str, Any]: "number": drawing.number, "revision": drawing.revision, } + if model_id is not None: + payload["model_id"] = str(model_id) return payload case AssetActivated(asset_id=asset_id, occurred_at=occurred_at): return { @@ -495,6 +509,8 @@ def _build_registered() -> AssetRegistered: if raw_drawing is not None else None ) + raw_model_id = payload.get("model_id") + model_id = UUID(raw_model_id) if raw_model_id is not None else None return AssetRegistered( asset_id=UUID(payload["asset_id"]), name=payload["name"], @@ -502,6 +518,7 @@ def _build_registered() -> AssetRegistered: parent_id=UUID(raw_parent) if raw_parent is not None else None, occurred_at=datetime.fromisoformat(payload["occurred_at"]), drawing=drawing, + model_id=model_id, ) return deserialize_or_raise("AssetRegistered", _build_registered) diff --git a/apps/api/src/cora/equipment/aggregates/asset/evolver.py b/apps/api/src/cora/equipment/aggregates/asset/evolver.py index d9903a3dd..f53616f69 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/evolver.py +++ b/apps/api/src/cora/equipment/aggregates/asset/evolver.py @@ -38,19 +38,24 @@ **Critical invariant**: every transition arm MUST carry `family_ids` AND `condition` AND `settings` AND `ports` AND -`drawing` through from prior state. Constructing `Asset(id=..., -name=..., level=..., parent_id=..., lifecycle=...)` without -explicitly passing them would silently WIPE the fields to their -defaults (empty frozenset / NOMINAL / empty dict / empty frozenset -/ None). `family_ids` was added with a default solely for additive- -state forward compatibility on genesis events; `condition`, -`settings`, `ports`, and `drawing` followed the same additive -pattern. Transition arms must explicitly carry all five. Pinned -by `test_evolve__preserves_capabilities`, +`drawing` AND `model_id` through from prior state. Constructing +`Asset(id=..., name=..., level=..., parent_id=..., lifecycle=...)` +without explicitly passing them would silently WIPE the fields to +their defaults (empty frozenset / NOMINAL / empty dict / empty +frozenset / None / None). `family_ids` was added with a default +solely for additive-state forward compatibility on genesis events; +`condition`, `settings`, `ports`, `drawing`, and `model_id` +followed the same additive pattern. Transition arms must +explicitly carry all six. `model_id` is set ONCE at registration +per the model-binding design memo (Lock A) and never changes +post-genesis, but transition arms still must carry it forward +like any other Asset field. Pinned by +`test_evolve__preserves_capabilities`, `test_evolve__preserves_condition`, `test_evolve__preserves_settings`, -`test_evolve__preserves_ports`, and -`test_evolve__preserves_drawing` for each transition. +`test_evolve__preserves_ports`, +`test_evolve__preserves_drawing`, and +`test_evolve__preserves_model_id` for each transition. Transition events applied to empty state raise ValueError: they can never appear before `AssetRegistered` in a well-formed stream. @@ -99,6 +104,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: level=level, parent_id=parent_id, drawing=drawing, + model_id=model_id, ): _ = state # AssetRegistered is the genesis event; prior state ignored return Asset( @@ -108,6 +114,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: parent_id=parent_id, lifecycle=AssetLifecycle.COMMISSIONED, drawing=drawing, + model_id=model_id, # family_ids defaults to empty frozenset; condition # defaults to NOMINAL. Additive-state pattern: both # default-via-state so legacy streams without these @@ -126,6 +133,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetDecommissioned(): prior = require_state(state, "AssetDecommissioned") @@ -140,6 +148,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetRelocated(to_parent_id=to_parent_id): # Hierarchy mutation: only parent_id changes; lifecycle / level @@ -159,6 +168,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetMaintenanceEntered(): prior = require_state(state, "AssetMaintenanceEntered") @@ -173,6 +183,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetMaintenanceExited(): prior = require_state(state, "AssetMaintenanceExited") @@ -187,6 +198,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetFamilyAdded(family_id=family_id): # Family mutation: only `family_ids` changes; everything @@ -206,6 +218,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetFamilyRemoved(family_id=family_id): # Mirror of AssetFamilyAdded. Frozenset difference is a @@ -226,6 +239,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetDegraded(): # Condition mutation: only `condition` changes; everything @@ -245,6 +259,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetFaulted(): prior = require_state(state, "AssetFaulted") @@ -259,6 +274,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetRestored(): prior = require_state(state, "AssetRestored") @@ -273,6 +289,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetSettingsUpdated(settings=settings): # Settings mutation: only `settings` changes. Event payload @@ -294,6 +311,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=dict(settings), ports=prior.ports, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetPortAdded( port_name=port_name, @@ -323,6 +341,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=prior.ports | {new_port}, drawing=prior.drawing, + model_id=prior.model_id, ) case AssetPortRemoved(port_name=port_name): # Mirror of AssetPortAdded. Removes the port whose `name` @@ -345,6 +364,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: settings=prior.settings, ports=frozenset(p for p in prior.ports if p.name != port_name), drawing=prior.drawing, + model_id=prior.model_id, ) case _: # pragma: no cover # exhaustiveness guard assert_never(event) diff --git a/apps/api/src/cora/equipment/aggregates/asset/state.py b/apps/api/src/cora/equipment/aggregates/asset/state.py index 98f934cea..0d4bc8dc8 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/state.py +++ b/apps/api/src/cora/equipment/aggregates/asset/state.py @@ -452,6 +452,45 @@ def __init__(self, asset_id: UUID, port_name: str, reason: str) -> None: self.reason = reason +class AssetModelMismatchError(Exception): + """The Asset's families set does not satisfy the bound Model's declared families. + + Cross-BC subset invariant: when an Asset is bound to a Model via + `model_id`, the Asset's `family_ids` must be a superset of the + Model's `declared_families`. The check fires at `add_asset_family` + against a freshly loaded Model snapshot; if the post-add families + set is not a superset of `declared_families`, this error is raised + and no event is emitted. + + The message lists both sets verbatim so operators reading the API + error response see immediately which Families are missing on the + Asset (or, in the cascade case, which Families the Model has added + since the binding). Mapped to HTTP 409 via the + `cannot_transition_cls` tuple in `routes.py`. + + Per the model-binding design memo (Lock E), this class lives in + the Asset BC per the per-BC error-class convention; the Model-side + equivalent does not exist because the binding is one-directional. + """ + + def __init__( + self, + asset_id: UUID, + model_id: UUID, + declared_families: frozenset[UUID], + asset_family_ids: frozenset[UUID], + ) -> None: + super().__init__( + f"Asset {asset_id} bound to Model {model_id} which declares families " + f"{sorted(declared_families)}, but Asset families would be " + f"{sorted(asset_family_ids)} after this transition" + ) + self.asset_id = asset_id + self.model_id = model_id + self.declared_families = declared_families + self.asset_family_ids = asset_family_ids + + class AssetCannotRelocateError(Exception): """Attempted to relocate an asset under disqualifying conditions. @@ -553,6 +592,16 @@ class Asset: AssetRegistered streams without the drawing field fold cleanly via the additive-state pattern. + `model_id`: optional reference to the Model catalog entry this + Asset is an instance of (Family -> Model -> Assembly -> Asset + ladder). Set ONCE at `register_asset` time per the model-binding + design memo (Lock A); rebind path is decommission + re-register. + Carries the cross-BC subset invariant + `Model.declared_families ⊆ Asset.family_ids`, enforced at + `add_asset_family` against a freshly loaded Model snapshot. + Defaults to None; legacy AssetRegistered streams without the + model_id field fold cleanly via the additive-state pattern. + Future additive facets: `owner`, `persistent_id`. The state- level fields land with defaults for the same forward- compatibility reason. @@ -579,3 +628,4 @@ class Asset: # Same parametrized-callable trick as family_ids. ports: frozenset[AssetPort] = field(default_factory=frozenset[AssetPort]) drawing: Drawing | None = None + model_id: UUID | None = None diff --git a/apps/api/src/cora/equipment/features/add_asset_family/handler.py b/apps/api/src/cora/equipment/features/add_asset_family/handler.py index 03f7b79df..16635e0a6 100644 --- a/apps/api/src/cora/equipment/features/add_asset_family/handler.py +++ b/apps/api/src/cora/equipment/features/add_asset_family/handler.py @@ -18,11 +18,13 @@ from cora.equipment.aggregates.asset import ( AssetEvent, + AssetModelMismatchError, event_type_name, fold, from_stored, to_payload, ) +from cora.equipment.aggregates.model import ModelNotFoundError, load_model from cora.equipment.errors import UnauthorizedError from cora.equipment.features.add_asset_family.command import AddAssetFamily from cora.equipment.features.add_asset_family.decider import decide @@ -101,6 +103,28 @@ async def handler( history: list[AssetEvent] = [from_stored(s) for s in stored] state = fold(history) + # Cross-BC subset gate: when the Asset is bound to a Model + # via model_id, the post-add family set must be a superset + # of the Model's declared families. Lives in the handler + # (not the decider) because the Model snapshot is loaded + # at decide time from a stream the Asset aggregate does not + # own. Same precedent as `update_asset_settings` loading + # Family streams to validate against schemas. Single-stream + # write discipline preserved: load Model read-only, append + # only to the Asset stream. + if state is not None and state.model_id is not None: + model = await load_model(deps.event_store, state.model_id) + if model is None: + raise ModelNotFoundError(state.model_id) + post_add_family_ids = state.family_ids | {command.family_id} + if not model.declared_families.issubset(post_add_family_ids): + raise AssetModelMismatchError( + asset_id=state.id, + model_id=state.model_id, + declared_families=model.declared_families, + asset_family_ids=post_add_family_ids, + ) + domain_events = decide(state=state, command=command, now=now) new_events = [ diff --git a/apps/api/src/cora/equipment/features/register_asset/command.py b/apps/api/src/cora/equipment/features/register_asset/command.py index c7c5b14eb..73fabe5cf 100644 --- a/apps/api/src/cora/equipment/features/register_asset/command.py +++ b/apps/api/src/cora/equipment/features/register_asset/command.py @@ -1,22 +1,32 @@ -"""The `RegisterAsset` command — intent dataclass for this slice. +"""The `RegisterAsset` command, intent dataclass for this slice. Carries the caller-controlled fields: the asset's display name, -its hierarchical level, and its parent_id (None only for -Enterprise-level roots — enforced by the decider). Server-side -concerns (new aggregate id, wall-clock timestamp, correlation id, -per-event ids) are injected by the handler from infrastructure -ports, matching the cross-BC create-style command shape locked in -Access / Trust / Subject / Equipment. +its hierarchical level, its parent_id (None only for +Enterprise-level roots, enforced by the decider), an optional +Drawing reference, and an optional `model_id` Model-binding ref. +Server-side concerns (new aggregate id, wall-clock timestamp, +correlation id, per-event ids) are injected by the handler from +infrastructure ports, matching the cross-BC create-style command +shape locked in Access / Trust / Subject / Equipment. `level` is typed as `AssetLevel` (the StrEnum) so callers cannot pass an invalid value; the route's Pydantic body and the MCP tool's argument schema both enforce this at the API boundary. -`parent_id` is `UUID | None` — required for non-Enterprise +`parent_id` is `UUID | None`, required for non-Enterprise levels, must be null for Enterprise. Eventual-consistency stance for the parent ref: the decider does NOT verify the referenced parent Asset exists in the event store (same precedent as Trust's Conduit zone refs). + +`model_id` is `UUID | None`, optional reference to the Model +catalog entry this Asset is an instance of. Set ONCE at +registration per the model-binding design memo (Lock A); rebind +path is decommission + re-register. The handler verifies the +referenced Model stream exists before invoking the decider +(`ModelNotFoundError` -> 404); the decider does NOT need a Model +snapshot because the genesis Asset's families set is empty so the +subset invariant is vacuously satisfied at registration (Lock B). """ from dataclasses import dataclass @@ -28,9 +38,14 @@ @dataclass(frozen=True) class RegisterAsset: - """Register a new asset with the given name, level, parent, and optional drawing.""" + """Register a new asset. + + Carries the display name, hierarchical level, parent_id, optional + Drawing reference, and optional `model_id` Model-binding ref. + """ name: str level: AssetLevel parent_id: UUID | None drawing: Drawing | None = None + model_id: UUID | None = None diff --git a/apps/api/src/cora/equipment/features/register_asset/decider.py b/apps/api/src/cora/equipment/features/register_asset/decider.py index e14fdfd91..c66e9b9fc 100644 --- a/apps/api/src/cora/equipment/features/register_asset/decider.py +++ b/apps/api/src/cora/equipment/features/register_asset/decider.py @@ -25,6 +25,19 @@ check that a Device's parent is an Assembly (etc). Device-in- Device is allowed when reality demands it (smart instruments with addressable sub-modules). + +## Model binding (Lock B) + +`command.model_id` flows through to the emitted AssetRegistered +event without inspection. The decider does NOT load the Model +snapshot: at registration the Asset's families set is empty, so +the cross-BC subset invariant +`Model.declared_families subset-of Asset.family_ids` is vacuously +satisfied and there is nothing to validate against. The handler +enforces Model existence (raises `ModelNotFoundError` -> 404) +before invoking decide; the first meaningful subset enforcement +fires at the first `add_asset_family` call against the bound +Asset. """ from datetime import datetime @@ -83,5 +96,6 @@ def decide( parent_id=command.parent_id, occurred_at=now, drawing=command.drawing, + model_id=command.model_id, ) ] diff --git a/apps/api/src/cora/equipment/features/register_asset/handler.py b/apps/api/src/cora/equipment/features/register_asset/handler.py index 559971c05..ea8288e1b 100644 --- a/apps/api/src/cora/equipment/features/register_asset/handler.py +++ b/apps/api/src/cora/equipment/features/register_asset/handler.py @@ -7,12 +7,21 @@ The cross-BC create-style template extraction stays parked per the post-domain-audit (defer hoisting until divergence pressure or ~10 instances; we're at 7). + +When `command.model_id is not None` the handler loads the Model +stream via `load_model` BEFORE invoking the decider and raises +`ModelNotFoundError` (mapped to HTTP 404 by the BC's exception +handler tuple) when the stream returns no state. This is a load- +only cross-BC dependency; no Model snapshot is threaded into the +decider because the subset invariant is vacuously satisfied at +register-time per Lock B of the model-binding design memo. """ from typing import Protocol from uuid import UUID from cora.equipment.aggregates.asset import event_type_name, to_payload +from cora.equipment.aggregates.model import ModelNotFoundError, load_model from cora.equipment.errors import UnauthorizedError from cora.equipment.features.register_asset.command import RegisterAsset from cora.equipment.features.register_asset.decider import decide @@ -99,6 +108,19 @@ async def handler( ) raise UnauthorizedError(decision.reason) + if command.model_id is not None: + model = await load_model(deps.event_store, command.model_id) + if model is None: + _log.info( + "register_asset.model_not_found", + 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, + model_id=str(command.model_id), + ) + raise ModelNotFoundError(command.model_id) + new_id = deps.id_generator.new_id() now = deps.clock.now() diff --git a/apps/api/src/cora/equipment/features/register_asset/route.py b/apps/api/src/cora/equipment/features/register_asset/route.py index b88a0a59c..baeafaf15 100644 --- a/apps/api/src/cora/equipment/features/register_asset/route.py +++ b/apps/api/src/cora/equipment/features/register_asset/route.py @@ -70,6 +70,18 @@ class RegisterAssetRequest(BaseModel): "Captured at registration only; not mutable in v1." ), ) + model_id: UUID | None = Field( + None, + description=( + "Optional reference to the Model catalog entry this Asset " + "is an instance of (Family -> Model -> Assembly -> Asset " + "ladder). Set ONCE at registration; rebind path is " + "decommission + re-register. The handler verifies the " + "Model stream exists before invoking the decider (404 if " + "missing); no subset check at register time because the " + "genesis Asset families set is empty." + ), + ) class RegisterAssetResponse(BaseModel): @@ -104,6 +116,13 @@ def _get_handler(request: Request) -> IdempotentHandler: "model": ErrorResponse, "description": "Authorize port denied the command.", }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": ( + "model_id was supplied but the referenced Model stream " + "does not exist (ModelNotFoundError)." + ), + }, status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": ( "Request body failed schema validation (unknown level, " @@ -138,6 +157,7 @@ async def post_assets( level=body.level, parent_id=body.parent_id, drawing=body.drawing.to_domain() if body.drawing is not None else None, + model_id=body.model_id, ), principal_id=principal_id, correlation_id=cid, diff --git a/apps/api/src/cora/equipment/features/register_asset/tool.py b/apps/api/src/cora/equipment/features/register_asset/tool.py index 6252c0050..1be19c737 100644 --- a/apps/api/src/cora/equipment/features/register_asset/tool.py +++ b/apps/api/src/cora/equipment/features/register_asset/tool.py @@ -80,6 +80,19 @@ async def register_asset_tool( # pyright: ignore[reportUnusedFunction] ), ), ] = None, + model_id: Annotated[ + UUID | None, + Field( + default=None, + description=( + "Optional reference to the Model catalog entry " + "this Asset is an instance of. Set ONCE at " + "registration; rebind path is decommission + " + "re-register. Raises 404 if the Model stream " + "does not exist." + ), + ), + ] = None, ) -> RegisterAssetOutput: handler = get_handler() asset_id = await handler( @@ -88,6 +101,7 @@ async def register_asset_tool( # pyright: ignore[reportUnusedFunction] level=level, parent_id=parent_id, drawing=drawing.to_domain() if drawing is not None else None, + model_id=model_id, ), principal_id=get_mcp_principal_id(ctx), correlation_id=current_correlation_id(), diff --git a/apps/api/src/cora/equipment/projections/asset.py b/apps/api/src/cora/equipment/projections/asset.py index b64e86a16..f9019fb54 100644 --- a/apps/api/src/cora/equipment/projections/asset.py +++ b/apps/api/src/cora/equipment/projections/asset.py @@ -42,8 +42,9 @@ _INSERT_ASSET_SQL = """ INSERT INTO proj_equipment_asset_summary (asset_id, name, level, lifecycle, condition, parent_id, - drawing_system, drawing_number, drawing_revision, created_at) -VALUES ($1, $2, $3, 'Commissioned', 'Nominal', $4, $5, $6, $7, $8) + drawing_system, drawing_number, drawing_revision, model_id, + created_at) +VALUES ($1, $2, $3, 'Commissioned', 'Nominal', $4, $5, $6, $7, $8, $9) ON CONFLICT (asset_id) DO NOTHING """ @@ -100,6 +101,8 @@ async def apply( drawing_system = drawing["system"] if drawing is not None else None drawing_number = drawing["number"] if drawing is not None else None drawing_revision = drawing.get("revision") if drawing is not None else None + model_id_raw = event.payload.get("model_id") + model_id = UUID(model_id_raw) if model_id_raw else None await conn.execute( _INSERT_ASSET_SQL, UUID(event.payload["asset_id"]), @@ -109,6 +112,7 @@ async def apply( drawing_system, drawing_number, drawing_revision, + model_id, datetime.fromisoformat(event.payload["occurred_at"]), ) case "AssetActivated" | "AssetMaintenanceExited": diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index d418a7e24..f19138415 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -48,6 +48,7 @@ AssetCannotRelocateError, AssetCannotRemoveFamilyError, AssetCannotRemovePortError, + AssetModelMismatchError, AssetNotFoundError, InvalidAssetNameError, InvalidAssetParentError, @@ -309,6 +310,7 @@ def register_equipment_routes(app: FastAPI) -> None: AssetCannotRemoveFamilyError, AssetCannotAddPortError, AssetCannotRemovePortError, + AssetModelMismatchError, FamilyCannotVersionError, FamilyCannotDeprecateError, FrameCannotUpdateError, 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 66954275d..3177ce330 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 @@ -99,7 +99,6 @@ "cora.equipment.features.exit_maintenance.decider", "cora.equipment.features.fault_asset.decider", "cora.equipment.features.install_asset.decider", - "cora.equipment.features.register_asset.decider", "cora.equipment.features.register_frame.decider", "cora.equipment.features.relocate_asset.decider", "cora.equipment.features.remove_asset_family.decider", diff --git a/apps/api/tests/architecture/test_no_em_dashes.py b/apps/api/tests/architecture/test_no_em_dashes.py index e031e2a50..63cd1995c 100644 --- a/apps/api/tests/architecture/test_no_em_dashes.py +++ b/apps/api/tests/architecture/test_no_em_dashes.py @@ -144,7 +144,6 @@ "src/cora/equipment/features/get_family/query.py", "src/cora/equipment/features/get_family/tool.py", "src/cora/equipment/features/list_assets/query.py", - "src/cora/equipment/features/register_asset/command.py", "src/cora/equipment/features/register_asset/decider.py", "src/cora/equipment/features/register_asset/handler.py", "src/cora/equipment/features/relocate_asset/command.py", diff --git a/apps/api/tests/contract/test_add_asset_family_endpoint.py b/apps/api/tests/contract/test_add_asset_family_endpoint.py index 998f16f5c..0898d321d 100644 --- a/apps/api/tests/contract/test_add_asset_family_endpoint.py +++ b/apps/api/tests/contract/test_add_asset_family_endpoint.py @@ -5,12 +5,35 @@ mutation. Pinned: get_asset reflects the new capability after add. """ -from uuid import uuid4 +import asyncio +from datetime import UTC, datetime +from uuid import UUID, uuid4 import pytest +from fastapi import FastAPI from fastapi.testclient import TestClient from cora.api.main import create_app +from cora.equipment.aggregates.asset import AssetLevel +from cora.equipment.aggregates.asset.events import AssetRegistered +from cora.equipment.aggregates.asset.events import ( + event_type_name as asset_event_type_name, +) +from cora.equipment.aggregates.asset.events import to_payload as asset_to_payload +from cora.equipment.aggregates.model.events import ModelDefined +from cora.equipment.aggregates.model.events import ( + event_type_name as model_event_type_name, +) +from cora.equipment.aggregates.model.events import to_payload as model_to_payload +from cora.equipment.aggregates.model.state import ( + Manufacturer, + ManufacturerName, +) +from cora.infrastructure.event_envelope import to_new_event + +_SEED_NOW = datetime(2026, 5, 10, 11, 0, 0, tzinfo=UTC) +_SEED_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_SEED_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") def _register_asset(client: TestClient, name: str = "APS-2BM") -> str: @@ -23,6 +46,87 @@ def _register_asset(client: TestClient, name: str = "APS-2BM") -> str: return asset_id +async def _seed_model_with_declared_families( + app: FastAPI, + *, + model_id: UUID, + declared_family_ids: frozenset[UUID], +) -> None: + """Append a `ModelDefined` event with `declared_families` set + directly via the app's wired kernel. + + The contract test needs a Model carrying specific declared_families + so the cross-BC subset gate at `add_asset_family` can be exercised. + Going through `define_model` would require the Families to exist + in the projection too (cross-BC lookup at the Model handler); + seeding the event directly bypasses that and is faithful to the + invariant the Asset handler is meant to enforce. + """ + deps = app.state.deps + event = ModelDefined( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=declared_family_ids, + occurred_at=_SEED_NOW, + ) + new_event = to_new_event( + event_type=model_event_type_name(event), + payload=model_to_payload(event), + occurred_at=_SEED_NOW, + event_id=uuid4(), + command_name="DefineModel", + correlation_id=_SEED_CORRELATION_ID, + principal_id=_SEED_PRINCIPAL_ID, + ) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=0, + events=[new_event], + ) + + +async def _seed_asset_bound_to_model( + app: FastAPI, + *, + asset_id: UUID, + model_id: UUID, +) -> None: + """Append an `AssetRegistered` event with `model_id` set directly. + + The current `register_asset` slice does not yet accept a + `model_id` argument, so contract tests that need to trigger the + cross-BC subset gate seed the Asset's genesis event with the + model binding via the event store. + """ + deps = app.state.deps + registered = AssetRegistered( + asset_id=asset_id, + name="APS-2BM", + level=AssetLevel.UNIT, + parent_id=uuid4(), + occurred_at=_SEED_NOW, + model_id=model_id, + ) + new_event = to_new_event( + event_type=asset_event_type_name(registered), + payload=asset_to_payload(registered), + occurred_at=_SEED_NOW, + event_id=uuid4(), + command_name="RegisterAsset", + correlation_id=_SEED_CORRELATION_ID, + principal_id=_SEED_PRINCIPAL_ID, + ) + await deps.event_store.append( + stream_type="Asset", + stream_id=asset_id, + expected_version=0, + events=[new_event], + ) + + @pytest.mark.contract def test_post_add_family_returns_204_on_happy_path() -> None: cap = str(uuid4()) @@ -130,3 +234,40 @@ def test_post_add_family_with_x_principal_id_header_succeeds() -> None: headers={"X-Principal-Id": pid}, ) assert response.status_code == 204 + + +@pytest.mark.contract +def test_post_add_family_returns_409_when_asset_model_mismatch() -> None: + """Cross-BC subset gate: an Asset bound to a Model whose + `declared_families` are not satisfied by the post-add Asset + family set raises `AssetModelMismatchError`, mapped to 409 via the + `cannot_transition_cls` tuple in `routes.py`.""" + asset_id = UUID("01900000-0000-7000-8000-0000000e0d01") + model_id = UUID("01900000-0000-7000-8000-0000000e0d02") + declared_a = UUID("01900000-0000-7000-8000-0000000e0d03") + declared_b = UUID("01900000-0000-7000-8000-0000000e0d04") + family_to_add = declared_a + + app = create_app() + with TestClient(app) as client: + # Model declares TWO families; we add only one; subset gate + # fails because the post-add Asset families is {declared_a} + # which is not a superset of {declared_a, declared_b}. + asyncio.run( + _seed_model_with_declared_families( + app, + model_id=model_id, + declared_family_ids=frozenset({declared_a, declared_b}), + ) + ) + asyncio.run(_seed_asset_bound_to_model(app, asset_id=asset_id, model_id=model_id)) + + response = client.post( + f"/assets/{asset_id}/add-family", + json={"family_id": str(family_to_add)}, + ) + + assert response.status_code == 409 + detail = response.json()["detail"] + assert str(model_id) in detail + assert str(asset_id) in detail diff --git a/apps/api/tests/contract/test_add_asset_family_mcp_tool.py b/apps/api/tests/contract/test_add_asset_family_mcp_tool.py index 796f9c30e..089c0d3a9 100644 --- a/apps/api/tests/contract/test_add_asset_family_mcp_tool.py +++ b/apps/api/tests/contract/test_add_asset_family_mcp_tool.py @@ -3,14 +3,110 @@ Mirrors `test_relocate_asset_mcp_tool.py` (also two-id-arg). """ +import asyncio +from datetime import UTC, datetime from uuid import UUID, uuid4 import pytest +from fastapi import FastAPI from fastapi.testclient import TestClient from cora.api.main import create_app +from cora.equipment.aggregates.asset import AssetLevel +from cora.equipment.aggregates.asset.events import AssetRegistered +from cora.equipment.aggregates.asset.events import ( + event_type_name as asset_event_type_name, +) +from cora.equipment.aggregates.asset.events import to_payload as asset_to_payload +from cora.equipment.aggregates.model.events import ModelDefined +from cora.equipment.aggregates.model.events import ( + event_type_name as model_event_type_name, +) +from cora.equipment.aggregates.model.events import to_payload as model_to_payload +from cora.equipment.aggregates.model.state import ( + Manufacturer, + ManufacturerName, +) +from cora.infrastructure.event_envelope import to_new_event from tests.contract._mcp_helpers import open_session, parse_sse_data +_SEED_NOW = datetime(2026, 5, 10, 11, 0, 0, tzinfo=UTC) +_SEED_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_SEED_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") + + +async def _seed_model_with_declared_families( + app: FastAPI, + *, + model_id: UUID, + declared_family_ids: frozenset[UUID], +) -> None: + """Append a `ModelDefined` event with `declared_families` set + directly via the app's wired kernel; mirrors the REST endpoint + test's seeder.""" + deps = app.state.deps + event = ModelDefined( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=declared_family_ids, + occurred_at=_SEED_NOW, + ) + new_event = to_new_event( + event_type=model_event_type_name(event), + payload=model_to_payload(event), + occurred_at=_SEED_NOW, + event_id=uuid4(), + command_name="DefineModel", + correlation_id=_SEED_CORRELATION_ID, + principal_id=_SEED_PRINCIPAL_ID, + ) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=0, + events=[new_event], + ) + + +async def _seed_asset_bound_to_model( + app: FastAPI, + *, + asset_id: UUID, + model_id: UUID, +) -> None: + """Append an `AssetRegistered` event with `model_id` set directly. + + The current `register_asset` tool does not yet accept `model_id`; + the MCP contract test for the cross-BC subset gate seeds the + Asset's genesis event via the event store, same shape as the + REST endpoint test.""" + deps = app.state.deps + registered = AssetRegistered( + asset_id=asset_id, + name="APS-2BM", + level=AssetLevel.UNIT, + parent_id=uuid4(), + occurred_at=_SEED_NOW, + model_id=model_id, + ) + new_event = to_new_event( + event_type=asset_event_type_name(registered), + payload=asset_to_payload(registered), + occurred_at=_SEED_NOW, + event_id=uuid4(), + command_name="RegisterAsset", + correlation_id=_SEED_CORRELATION_ID, + principal_id=_SEED_PRINCIPAL_ID, + ) + await deps.event_store.append( + stream_type="Asset", + stream_id=asset_id, + expected_version=0, + events=[new_event], + ) + def _register_asset_via_tool( client: TestClient, @@ -137,3 +233,51 @@ def test_mcp_add_asset_family_tool_returns_iserror_when_already_present() -> Non body = parse_sse_data(response.text) assert body["result"]["isError"] is True assert "already" in body["result"]["content"][0]["text"] + + +@pytest.mark.contract +def test_mcp_add_asset_family_tool_returns_iserror_when_asset_model_mismatch() -> None: + """Cross-BC subset gate: an Asset bound to a Model whose + `declared_families` are not satisfied by the post-add Asset + family set raises `AssetModelMismatchError`, surfaced as + `isError: true` with the bound model_id in the error text.""" + asset_id = UUID("01900000-0000-7000-8000-0000000e0e01") + model_id = UUID("01900000-0000-7000-8000-0000000e0e02") + declared_a = UUID("01900000-0000-7000-8000-0000000e0e03") + declared_b = UUID("01900000-0000-7000-8000-0000000e0e04") + family_to_add = declared_a + + app = create_app() + with TestClient(app) as client: + asyncio.run( + _seed_model_with_declared_families( + app, + model_id=model_id, + declared_family_ids=frozenset({declared_a, declared_b}), + ) + ) + asyncio.run(_seed_asset_bound_to_model(app, asset_id=asset_id, model_id=model_id)) + + headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "add_asset_family", + "arguments": { + "asset_id": str(asset_id), + "family_id": str(family_to_add), + }, + }, + }, + headers=headers, + ) + + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + text = body["result"]["content"][0]["text"] + assert str(model_id) in text + assert str(asset_id) in text diff --git a/apps/api/tests/contract/test_register_asset_idempotency.py b/apps/api/tests/contract/test_register_asset_idempotency.py index 9480c1daf..ac08f45d7 100644 --- a/apps/api/tests/contract/test_register_asset_idempotency.py +++ b/apps/api/tests/contract/test_register_asset_idempotency.py @@ -3,14 +3,28 @@ Same cross-BC `with_idempotency` decorator as the other create-style slices. Test keys are short to stay below the gitleaks generic-API- key entropy threshold. + +The `model_id`-related cases monkeypatch the `load_model` symbol +imported into the `register_asset.handler` module: a stub that +returns a fully-formed Model snapshot pins the happy path (no need +to seed the upstream Model stream via `POST /models` first), and +a stub that always returns `None` pins the 404 path. """ +from collections.abc import Iterator from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelName, + PartNumber, +) def _body(name: str = "APS-2BM", level: str = "Unit") -> dict[str, object]: @@ -79,3 +93,156 @@ def test_post_assets_cached_response_returns_valid_uuid() -> None: UUID(r1.json()["asset_id"]) # parses UUID(r2.json()["asset_id"]) # parses assert r1.json()["asset_id"] == r2.json()["asset_id"] + + +# ---------- model_id body field (asset-model binding slice) ---------- + + +_KNOWN_MODEL_ID = UUID("01900000-0000-7000-8000-00000000ad01") +_KNOWN_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa11") + + +@pytest.fixture +def accept_model(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `load_model` so `_KNOWN_MODEL_ID` resolves to a real Model. + + The register_asset handler imports `load_model` by name at module + load, so we patch the binding in the handler's namespace (the one + it actually calls). Mirrors the `accept_family` pattern in + `test_define_model_contract.py`. + """ + + async def _stub(_event_store: object, requested_id: UUID) -> Model | None: + if requested_id == _KNOWN_MODEL_ID: + return Model( + id=requested_id, + name=ModelName("EigerX-9M"), + manufacturer=Manufacturer(name=ManufacturerName("Dectris")), + part_number=PartNumber("EX9M-001"), + declared_families=frozenset({_KNOWN_FAMILY_ID}), + ) + return None + + monkeypatch.setattr( + "cora.equipment.features.register_asset.handler.load_model", + _stub, + ) + yield _KNOWN_MODEL_ID + + +@pytest.mark.contract +def test_post_assets_with_known_model_id_returns_201(accept_model: UUID) -> None: + """Happy path: body carries model_id resolving to a real Model; + handler appends AssetRegistered and returns 201 + new asset id.""" + body: dict[str, object] = { + "name": "APS-2BM-Det", + "level": "Device", + "parent_id": str(uuid4()), + "model_id": str(accept_model), + } + with TestClient(create_app()) as client: + response = client.post("/assets", json=body) + + assert response.status_code == 201 + UUID(response.json()["asset_id"]) # parses + + +@pytest.mark.contract +def test_post_assets_with_unknown_model_id_returns_404(accept_model: UUID) -> None: + """Cross-BC 404: a model_id that does not resolve to a real Model + stream surfaces as ModelNotFoundError mapped to HTTP 404 by the + BC's `_handle_not_found` exception handler.""" + _ = accept_model # fixture's stub returns None for any other id + unknown_id = UUID("01900000-0000-7000-8000-00000000def0") + body: dict[str, object] = { + "name": "APS-2BM-Det", + "level": "Device", + "parent_id": str(uuid4()), + "model_id": str(unknown_id), + } + with TestClient(create_app()) as client: + response = client.post("/assets", json=body) + + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_assets_with_malformed_model_id_returns_422() -> None: + """Pydantic schema validation: a non-UUID string in `model_id` + fails request-body validation before the handler runs.""" + body: dict[str, object] = { + "name": "APS-2BM", + "level": "Unit", + "parent_id": str(uuid4()), + "model_id": "not-a-uuid", + } + with TestClient(create_app()) as client: + response = client.post("/assets", json=body) + + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assets_without_model_id_still_returns_201() -> None: + """Forward-compat: omitting model_id from the body continues to + work (the field is optional, default None). Legacy clients that + do not know about the binding are unaffected.""" + body: dict[str, object] = { + "name": "APS", + "level": "Site", + "parent_id": str(uuid4()), + } + with TestClient(create_app()) as client: + response = client.post("/assets", json=body) + + assert response.status_code == 201 + + +@pytest.mark.contract +def test_post_assets_same_key_different_model_id_returns_422( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Same Idempotency-Key + same body EXCEPT for model_id surfaces as 422. + + Gate-review P1-2: the cross-BC `hash_command` includes model_id + (RegisterAsset is a frozen dataclass; canonical hash covers every + field). Two distinct Model bindings under the same Idempotency-Key + must surface as a key/body conflict, NOT silently return a cached + asset_id pointing at the wrong Model. + + Patches `load_model` to accept either of two distinct Model ids so + both requests would otherwise reach the handler successfully; the + only differentiator on the cached-response check is the model_id + field in the body. + """ + model_a = UUID("01900000-0000-7000-8000-00000000a001") + model_b = UUID("01900000-0000-7000-8000-00000000b002") + family_id = UUID("01900000-0000-7000-8000-00000000fa12") + + async def _stub(_event_store: object, requested_id: UUID) -> Model | None: + if requested_id in {model_a, model_b}: + return Model( + id=requested_id, + name=ModelName("EigerX-9M"), + manufacturer=Manufacturer(name=ManufacturerName("Dectris")), + part_number=PartNumber("EX9M-001"), + declared_families=frozenset({family_id}), + ) + return None + + monkeypatch.setattr( + "cora.equipment.features.register_asset.handler.load_model", + _stub, + ) + + with TestClient(create_app()) as client: + headers = {"Idempotency-Key": "ak-model"} + body_a = {**_body(), "model_id": str(model_a)} + body_b = {**_body(), "model_id": str(model_b)} + r1 = client.post("/assets", json=body_a, headers=headers) + r2 = client.post("/assets", json=body_b, headers=headers) + + assert r1.status_code == 201 + assert r2.status_code == 422 + detail = r2.json().get("detail", "").lower() + assert "idempotency-key" in detail diff --git a/apps/api/tests/contract/test_register_asset_mcp_tool.py b/apps/api/tests/contract/test_register_asset_mcp_tool.py index b6e94dea9..8fcc76290 100644 --- a/apps/api/tests/contract/test_register_asset_mcp_tool.py +++ b/apps/api/tests/contract/test_register_asset_mcp_tool.py @@ -1,14 +1,26 @@ """Contract tests for the `register_asset` MCP tool. Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. + +The `model_id`-arg cases monkeypatch `load_model` in the handler's +namespace so the cross-BC Model existence check resolves without +seeding the upstream Model stream. """ +from collections.abc import Iterator from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelName, + PartNumber, +) from tests.contract._mcp_helpers import open_session, parse_sse_data @@ -187,3 +199,124 @@ def test_mcp_register_asset_tool_rejects_missing_arguments() -> None: ) body = parse_sse_data(response.text) assert body["result"]["isError"] is True + + +# ---------- model_id arg (asset-model binding slice) ---------- + + +_KNOWN_MODEL_ID = UUID("01900000-0000-7000-8000-00000000ad02") +_KNOWN_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa22") + + +@pytest.fixture +def accept_model_mcp(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `load_model` so `_KNOWN_MODEL_ID` resolves to a real Model.""" + + async def _stub(_event_store: object, requested_id: UUID) -> Model | None: + if requested_id == _KNOWN_MODEL_ID: + return Model( + id=requested_id, + name=ModelName("EigerX-9M"), + manufacturer=Manufacturer(name=ManufacturerName("Dectris")), + part_number=PartNumber("EX9M-002"), + declared_families=frozenset({_KNOWN_FAMILY_ID}), + ) + return None + + monkeypatch.setattr( + "cora.equipment.features.register_asset.handler.load_model", + _stub, + ) + yield _KNOWN_MODEL_ID + + +@pytest.mark.contract +def test_mcp_register_asset_tool_accepts_model_id_arg(accept_model_mcp: UUID) -> None: + """Happy path: model_id arg referencing a real Model returns + structured asset_id.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "register_asset", + "arguments": { + "name": "APS-2BM-Det", + "level": "Device", + "parent_id": str(uuid4()), + "model_id": str(accept_model_mcp), + }, + }, + }, + headers=session_headers, + ) + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is False + UUID(result["structuredContent"]["asset_id"]) # parses + + +@pytest.mark.contract +def test_mcp_register_asset_tool_returns_iserror_on_unknown_model_id( + accept_model_mcp: UUID, +) -> None: + """A model_id that does not resolve surfaces ModelNotFoundError; + FastMCP wraps it as isError: true.""" + _ = accept_model_mcp # fixture stub returns None for any other id + unknown_id = UUID("01900000-0000-7000-8000-00000000def2") + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "register_asset", + "arguments": { + "name": "APS-2BM-Det", + "level": "Device", + "parent_id": str(uuid4()), + "model_id": str(unknown_id), + }, + }, + }, + headers=session_headers, + ) + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + + +@pytest.mark.contract +def test_mcp_register_asset_tool_omits_model_id_arg_remains_201_path() -> None: + """Forward-compat: callers that omit model_id continue to work. + The arg defaults to None and the handler never invokes load_model.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 11, + "method": "tools/call", + "params": { + "name": "register_asset", + "arguments": { + "name": "APS", + "level": "Site", + "parent_id": str(uuid4()), + }, + }, + }, + headers=session_headers, + ) + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is False + UUID(result["structuredContent"]["asset_id"]) # parses diff --git a/apps/api/tests/integration/test_add_asset_family_handler_postgres.py b/apps/api/tests/integration/test_add_asset_family_handler_postgres.py index 12e4b2954..36e98d628 100644 --- a/apps/api/tests/integration/test_add_asset_family_handler_postgres.py +++ b/apps/api/tests/integration/test_add_asset_family_handler_postgres.py @@ -4,18 +4,40 @@ string; the evolver reconstructs into the frozenset on next load. Two scenarios — adding a single capability, then verifying that load+fold returns a state with the capability in the set. + +Plus the cross-BC subset gate (Asset.model_id binding): when the +Asset is bound to a Model carrying `declared_families`, the +`add_asset_family` handler loads the Model snapshot at decide time +and raises `AssetModelMismatchError` if the post-add Asset family set +is not a superset of the Model's declared families. The PG-backed +test seeds Model + Asset events directly via the event store +(register_asset does not yet accept a model_id) so the gate is +exercised end-to-end against real Postgres. """ from datetime import UTC, datetime -from uuid import UUID +from uuid import UUID, uuid4 import asyncpg import pytest -from cora.equipment.aggregates.asset import AssetLevel, load_asset +from cora.equipment.aggregates.asset import AssetLevel, AssetModelMismatchError, load_asset +from cora.equipment.aggregates.asset.events import AssetRegistered +from cora.equipment.aggregates.asset.events import ( + event_type_name as asset_event_type_name, +) +from cora.equipment.aggregates.asset.events import to_payload as asset_to_payload +from cora.equipment.aggregates.model.events import ModelDefined +from cora.equipment.aggregates.model.events import ( + event_type_name as model_event_type_name, +) +from cora.equipment.aggregates.model.events import to_payload as model_to_payload +from cora.equipment.aggregates.model.state import Manufacturer, ManufacturerName from cora.equipment.features import add_asset_family, register_asset from cora.equipment.features.add_asset_family import AddAssetFamily from cora.equipment.features.register_asset import RegisterAsset +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) @@ -24,6 +46,80 @@ _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +async def _seed_model( + deps: Kernel, + *, + model_id: UUID, + declared_family_ids: frozenset[UUID], +) -> None: + """Append a `ModelDefined` event directly via the event store. + + Bypasses `define_model` (which would require the Families to + exist in the projection too); this keeps the integration test + focused on the cross-BC subset gate at the Asset handler. + """ + event = ModelDefined( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=declared_family_ids, + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=model_event_type_name(event), + payload=model_to_payload(event), + occurred_at=_NOW, + event_id=uuid4(), + command_name="DefineModel", + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + ) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=0, + events=[new_event], + ) + + +async def _seed_asset_bound_to_model( + deps: Kernel, + *, + asset_id: UUID, + model_id: UUID, +) -> None: + """Append an `AssetRegistered` event with `model_id` set directly. + + Used by the subset-gate tests; the current `register_asset` slice + does not yet accept `model_id`. Round-trips through PG so the + handler folds back an Asset state with `model_id` populated. + """ + registered = AssetRegistered( + asset_id=asset_id, + name="APS-2BM", + level=AssetLevel.UNIT, + parent_id=_PARENT_ID, + occurred_at=_NOW, + model_id=model_id, + ) + new_event = to_new_event( + event_type=asset_event_type_name(registered), + payload=asset_to_payload(registered), + occurred_at=_NOW, + event_id=uuid4(), + command_name="RegisterAsset", + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + ) + await deps.event_store.append( + stream_type="Asset", + stream_id=asset_id, + expected_version=0, + events=[new_event], + ) + + @pytest.mark.integration async def test_add_asset_family_persists_event_and_round_trips_through_fold( db_pool: asyncpg.Pool, @@ -61,3 +157,86 @@ async def test_add_asset_family_persists_event_and_round_trips_through_fold( state = await load_asset(deps.event_store, asset_id) assert state is not None assert state.family_ids == frozenset({cap1}) + + +@pytest.mark.integration +async def test_add_asset_family_succeeds_when_bound_model_subset_is_satisfied( + db_pool: asyncpg.Pool, +) -> None: + """Asset bound to a Model whose `declared_families` is satisfied + by the post-add Asset family set: the cross-BC subset gate + passes, the `AssetFamilyAdded` event lands as usual.""" + asset_id = UUID("01900000-0000-7000-8000-00000057fa01") + model_id = UUID("01900000-0000-7000-8000-00000057fa02") + declared_family_id = UUID("01900000-0000-7000-8000-00000057fa03") + add_event_id = UUID("01900000-0000-7000-8000-00000057fa04") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[add_event_id]) + + await _seed_model( + deps, + model_id=model_id, + declared_family_ids=frozenset({declared_family_id}), + ) + await _seed_asset_bound_to_model(deps, asset_id=asset_id, model_id=model_id) + + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=declared_family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Asset", asset_id) + assert version == 2 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetFamilyAdded", + ] + assert events[1].payload["family_id"] == str(declared_family_id) + + state = await load_asset(deps.event_store, asset_id) + assert state is not None + assert state.model_id == model_id + assert state.family_ids == frozenset({declared_family_id}) + + +@pytest.mark.integration +async def test_add_asset_family_raises_asset_model_mismatch_when_subset_is_violated( + db_pool: asyncpg.Pool, +) -> None: + """Asset bound to a Model whose `declared_families` is NOT + satisfied by the post-add Asset family set: the cross-BC subset + gate raises `AssetModelMismatchError`, no event is appended, the + Asset stream stays at version 1.""" + asset_id = UUID("01900000-0000-7000-8000-00000058fa01") + model_id = UUID("01900000-0000-7000-8000-00000058fa02") + declared_a = UUID("01900000-0000-7000-8000-00000058fa03") + declared_b = UUID("01900000-0000-7000-8000-00000058fa04") + unused_add_event_id = UUID("01900000-0000-7000-8000-00000058fa05") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[unused_add_event_id]) + + # Model declares two Families; we add only one; the post-add + # Asset family set {declared_a} is NOT a superset of the Model's + # {declared_a, declared_b}, so the gate fails. + await _seed_model( + deps, + model_id=model_id, + declared_family_ids=frozenset({declared_a, declared_b}), + ) + await _seed_asset_bound_to_model(deps, asset_id=asset_id, model_id=model_id) + + with pytest.raises(AssetModelMismatchError) as exc_info: + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=declared_a), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert exc_info.value.asset_id == asset_id + assert exc_info.value.model_id == model_id + assert exc_info.value.declared_families == frozenset({declared_a, declared_b}) + assert exc_info.value.asset_family_ids == frozenset({declared_a}) + + _, version = await deps.event_store.load("Asset", asset_id) + assert version == 1 diff --git a/apps/api/tests/integration/test_postgres_asset_summary_projection.py b/apps/api/tests/integration/test_postgres_asset_summary_projection.py index c1efa2622..8b1aa9241 100644 --- a/apps/api/tests/integration/test_postgres_asset_summary_projection.py +++ b/apps/api/tests/integration/test_postgres_asset_summary_projection.py @@ -19,6 +19,8 @@ other two populated - CHECK constraint on drawing_system rejects unknown values when written directly via SQL (defense-in-depth pin) + - AssetRegistered with model_id in the payload lands in the new + model_id column; legacy events without the key fold to NULL """ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false @@ -33,7 +35,9 @@ from cora.equipment.aggregates.asset import AssetLevel from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_asset import bind as bind_register_asset +from cora.infrastructure.adapters.postgres_event_store import PostgresEventStore from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports.event_store import NewEvent from tests.integration._equipment_helpers import drain_equipment_projections from tests.integration._helpers import build_postgres_deps @@ -153,3 +157,90 @@ async def test_check_constraint_rejects_unknown_drawing_system( "'UnknownSystem', '123', NULL, now())", asset_id, ) + + +async def _append_asset_registered( + pool: asyncpg.Pool, + *, + asset_id: UUID, + payload_extra: dict[str, object], +) -> None: + """Append a synthetic AssetRegistered event directly to the event + store. Bypasses the register_asset handler so the model_id payload + key can be exercised at the projection layer ahead of the + register_asset slice landing model_id in the command + decider.""" + store = PostgresEventStore(pool) + payload: dict[str, object] = { + "asset_id": str(asset_id), + "name": "synthetic-asset", + "level": "Device", + "parent_id": str(uuid4()), + "occurred_at": _NOW.isoformat(), + } + payload.update(payload_extra) + await store.append( + "Asset", + asset_id, + 0, + [ + NewEvent( + event_id=uuid4(), + event_type="AssetRegistered", + schema_version=1, + payload=payload, + occurred_at=_NOW, + correlation_id=_CORRELATION_ID, + causation_id=None, + metadata={}, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + await drain_equipment_projections(pool) + + +@pytest.mark.integration +async def test_asset_registered_with_model_id_populates_model_column( + db_pool: asyncpg.Pool, +) -> None: + """An AssetRegistered event carrying the optional model_id key + lands in the proj_equipment_asset_summary.model_id column after + projection drain.""" + asset_id = uuid4() + model_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + payload_extra={"model_id": str(model_id)}, + ) + + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT model_id FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["model_id"] == model_id + + +@pytest.mark.integration +async def test_asset_registered_without_model_id_leaves_model_column_null( + db_pool: asyncpg.Pool, +) -> None: + """Legacy AssetRegistered events (and unbound genesis registrations) + omit the model_id payload key; the new column folds to NULL via the + additive-payload pattern.""" + asset_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + payload_extra={}, + ) + + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT model_id FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["model_id"] is None diff --git a/apps/api/tests/integration/test_register_asset_handler_postgres.py b/apps/api/tests/integration/test_register_asset_handler_postgres.py index d18545c01..cbc360009 100644 --- a/apps/api/tests/integration/test_register_asset_handler_postgres.py +++ b/apps/api/tests/integration/test_register_asset_handler_postgres.py @@ -4,6 +4,13 @@ Enterprise root (parent_id=None) and Site-with-parent. Pinned because the payload's nullable parent_id round-trip through Postgres jsonb is one of the structural guarantees Asset relies on. + +A third scenario seeds a Model via `define_model` then registers an +Asset bound to it via `command.model_id`, verifying the +AssetRegistered payload carries `model_id` and the folded Asset +state round-trips it. The Model load-and-confirm-exists step happens +inside the register_asset handler against the real Postgres event +store. """ from datetime import UTC, datetime @@ -12,14 +19,23 @@ import asyncpg import pytest +from cora.equipment._projections import register_equipment_projections from cora.equipment.aggregates.asset import ( AssetLevel, AssetLifecycle, AssetName, load_asset, ) -from cora.equipment.features import register_asset +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelNotFoundError, +) +from cora.equipment.features import define_family, define_model, register_asset +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel from cora.equipment.features.register_asset import RegisterAsset +from cora.infrastructure.projection import ProjectionRegistry, drain_projections from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) @@ -92,3 +108,120 @@ async def test_register_asset_persists_site_with_parent_to_postgres( assert state.parent_id == parent_id assert state.level is AssetLevel.SITE assert state.lifecycle is AssetLifecycle.COMMISSIONED + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Pump Equipment-owned projections so cross-BC reads see fresh writes. + + `define_model.handler` queries `proj_equipment_family_summary` via + `list_family_ids`; the upstream `define_family` event must be + drained into the projection before the Model definition runs. + """ + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_register_asset_persists_model_binding_to_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Seed a Family + Model, then register an Asset bound to that + Model via `command.model_id`. The handler's Model existence check + runs against the real Postgres event store; AssetRegistered payload + carries `model_id` as a string; folded Asset state round-trips it.""" + family_id = UUID("01900000-0000-7000-8000-000000054e01") + family_event_id = UUID("01900000-0000-7000-8000-000000054e0e") + model_id = UUID("01900000-0000-7000-8000-00000054ec01") + model_event_id = UUID("01900000-0000-7000-8000-00000054ec0e") + asset_id = UUID("01900000-0000-7000-8000-00000054ed01") + asset_event_id = UUID("01900000-0000-7000-8000-00000054ed0e") + parent_id = UUID("01900000-0000-7000-8000-00000054ed00") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + model_event_id, + asset_id, + asset_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + await define_model.bind(deps)( + DefineModel( + name="EigerX-9M", + manufacturer=Manufacturer(name=ManufacturerName("Dectris")), + part_number="EX9M-001", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + returned_asset_id = await register_asset.bind(deps)( + RegisterAsset( + name="APS-2BM-Det", + level=AssetLevel.DEVICE, + parent_id=parent_id, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_asset_id == asset_id + + events, version = await deps.event_store.load("Asset", asset_id) + assert version == 1 + stored = events[0] + assert stored.event_type == "AssetRegistered" + assert stored.payload["model_id"] == str(model_id) + assert stored.payload["asset_id"] == str(asset_id) + assert stored.payload["level"] == "Device" + assert stored.payload["parent_id"] == str(parent_id) + + state = await load_asset(deps.event_store, asset_id) + assert state is not None + assert state.id == asset_id + assert state.name == AssetName("APS-2BM-Det") + assert state.model_id == model_id + assert state.lifecycle is AssetLifecycle.COMMISSIONED + + +@pytest.mark.integration +async def test_register_asset_raises_model_not_found_on_unknown_model_id( + db_pool: asyncpg.Pool, +) -> None: + """When `command.model_id` references a Model stream that does not + exist, the handler raises ModelNotFoundError BEFORE appending any + Asset event.""" + asset_id = UUID("01900000-0000-7000-8000-00000054ef01") + asset_event_id = UUID("01900000-0000-7000-8000-00000054ef0e") + unknown_model_id = UUID("01900000-0000-7000-8000-00000bad7e57") + parent_id = UUID("01900000-0000-7000-8000-00000054ef00") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[asset_id, asset_event_id]) + + with pytest.raises(ModelNotFoundError) as exc_info: + await register_asset.bind(deps)( + RegisterAsset( + name="APS-2BM", + level=AssetLevel.UNIT, + parent_id=parent_id, + model_id=unknown_model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == unknown_model_id + + _, version = await deps.event_store.load("Asset", asset_id) + assert version == 0 diff --git a/apps/api/tests/unit/equipment/test_add_asset_family_handler.py b/apps/api/tests/unit/equipment/test_add_asset_family_handler.py index 5f836e791..a70b70842 100644 --- a/apps/api/tests/unit/equipment/test_add_asset_family_handler.py +++ b/apps/api/tests/unit/equipment/test_add_asset_family_handler.py @@ -4,6 +4,13 @@ two-id-arg slice). Covers the strict-not-idempotent re-add guard, the Decommissioned-asset guard, auth deny, causation_id propagation, and the wire-equipment smoke. + +Also covers the cross-BC subset gate (Model binding): when an Asset +carries a `model_id`, the handler loads the Model stream snapshot, +asserts `Model.declared_families` is a subset of the post-add Asset +families, and raises `AssetModelMismatchError` otherwise. The Model load +is monkeypatched per the model-binding design memo precedent (same +shape as `update_asset_settings` Family-stream loads). """ from datetime import UTC, datetime @@ -15,18 +22,35 @@ from cora.equipment.aggregates.asset import ( AssetCannotAddFamilyError, AssetLevel, + AssetModelMismatchError, AssetNotFoundError, ) +from cora.equipment.aggregates.asset.events import AssetRegistered +from cora.equipment.aggregates.asset.events import ( + event_type_name as asset_event_type_name, +) +from cora.equipment.aggregates.asset.events import to_payload as asset_to_payload +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelName, + ModelNotFoundError, + PartNumber, +) from cora.equipment.features import ( add_asset_family, decommission_asset, register_asset, ) from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.add_asset_family import handler as add_asset_family_handler from cora.equipment.features.decommission_asset import DecommissionAsset from cora.equipment.features.register_asset import RegisterAsset 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.event_store import EventStore from tests.unit._helpers import build_deps as _build_deps_shared _NOW = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) @@ -206,3 +230,206 @@ def test_wire_equipment_includes_add_asset_family() -> None: handlers = wire_equipment(deps) assert isinstance(handlers, EquipmentHandlers) assert callable(handlers.add_asset_family) + + +# --------------------------------------------------------------------------- +# Cross-BC subset gate (Asset.model_id binding). +# +# The handler loads the bound Model (when `state.model_id is not None`) +# and asserts `Model.declared_families` is a subset of the post-add +# `state.family_ids | {command.family_id}`. The four scenarios below +# cover: (a) bound+satisfied success, (b) bound+violated mismatch, +# (c) bound+Model-stream-missing raises ModelNotFoundError, +# (d) unbound (model_id=None) proceeds with no Model load. +# --------------------------------------------------------------------------- + + +_MODEL_ID = UUID("01900000-0000-7000-8000-0000000c0d01") +_CAP_DECLARED = UUID("01900000-0000-7000-8000-0000000c0d02") +_CAP_EXTRA = UUID("01900000-0000-7000-8000-0000000c0d03") +_PRIOR = datetime(2026, 5, 10, 11, 0, 0, tzinfo=UTC) + + +def _make_model( + *, + model_id: UUID = _MODEL_ID, + declared_families: frozenset[UUID] | None = None, +) -> Model: + """Build a Model state aggregate for monkeypatched load_model.""" + return Model( + id=model_id, + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=declared_families + if declared_families is not None + else frozenset({_CAP_DECLARED}), + ) + + +async def _seed_asset_with_model_id( + store: InMemoryEventStore, + asset_id: UUID, + *, + model_id: UUID | None, +) -> None: + """Append an `AssetRegistered` event carrying `model_id` directly. + + The current `register_asset` slice does not yet accept a + `model_id` argument, so handler tests that need to fold an Asset + state with `model_id` set seed the genesis event directly via the + event store. The payload-shape round-trip is exercised by the + PG integration test. + """ + registered = AssetRegistered( + asset_id=asset_id, + name="APS-2BM", + level=AssetLevel.UNIT, + parent_id=_PARENT_ID, + occurred_at=_PRIOR, + model_id=model_id, + ) + new_event = to_new_event( + event_type=asset_event_type_name(registered), + payload=asset_to_payload(registered), + occurred_at=_PRIOR, + event_id=uuid4(), + command_name="RegisterAsset", + correlation_id=_CORRELATION_ID, + principal_id=_PRINCIPAL_ID, + ) + await store.append( + stream_type="Asset", + stream_id=asset_id, + expected_version=0, + events=[new_event], + ) + + +@pytest.mark.unit +async def test_handler_succeeds_when_bound_model_subset_is_satisfied_post_add( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Asset bound to Model; post-add family set is a superset of + `Model.declared_families` (the family being added is the only + declared family). Subset gate passes; event is appended as usual.""" + asset_id = UUID("01900000-0000-7000-8000-0000000c0d10") + store = InMemoryEventStore() + await _seed_asset_with_model_id(store, asset_id, model_id=_MODEL_ID) + deps = _build_deps(event_store=store) + + captured: dict[str, UUID] = {} + + async def fake_load_model(event_store: EventStore, model_id: UUID) -> Model | None: + _ = event_store + captured["model_id"] = model_id + return _make_model(declared_families=frozenset({_CAP_DECLARED})) + + monkeypatch.setattr(add_asset_family_handler, "load_model", fake_load_model) + + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=_CAP_DECLARED), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert captured["model_id"] == _MODEL_ID + events, version = await store.load("Asset", asset_id) + assert version == 2 + assert [e.event_type for e in events] == ["AssetRegistered", "AssetFamilyAdded"] + + +@pytest.mark.unit +async def test_handler_raises_asset_model_mismatch_when_subset_is_violated_post_add( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Asset bound to Model; post-add family set still missing one of + `Model.declared_families`. Subset gate fails; AssetModelMismatchError + raised, no event appended, Asset stream stays at version 1.""" + asset_id = UUID("01900000-0000-7000-8000-0000000c0d11") + store = InMemoryEventStore() + await _seed_asset_with_model_id(store, asset_id, model_id=_MODEL_ID) + deps = _build_deps(event_store=store) + + # Model declares TWO families; the add provides only one of them + # and the Asset has no families yet, so the post-add set is + # {_CAP_EXTRA} which is not a superset of {_CAP_DECLARED, _CAP_EXTRA}. + async def fake_load_model(event_store: EventStore, model_id: UUID) -> Model | None: + _ = (event_store, model_id) + return _make_model(declared_families=frozenset({_CAP_DECLARED, _CAP_EXTRA})) + + monkeypatch.setattr(add_asset_family_handler, "load_model", fake_load_model) + + with pytest.raises(AssetModelMismatchError) as exc_info: + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=_CAP_EXTRA), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert exc_info.value.asset_id == asset_id + assert exc_info.value.model_id == _MODEL_ID + assert exc_info.value.declared_families == frozenset({_CAP_DECLARED, _CAP_EXTRA}) + assert exc_info.value.asset_family_ids == frozenset({_CAP_EXTRA}) + + _, version = await store.load("Asset", asset_id) + assert version == 1 + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_bound_model_stream_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Asset bound to Model but the Model stream cannot be loaded + (returns None). The handler raises ModelNotFoundError; no Asset + event is appended.""" + asset_id = UUID("01900000-0000-7000-8000-0000000c0d12") + store = InMemoryEventStore() + await _seed_asset_with_model_id(store, asset_id, model_id=_MODEL_ID) + deps = _build_deps(event_store=store) + + async def fake_load_model(event_store: EventStore, model_id: UUID) -> Model | None: + _ = (event_store, model_id) + return None + + monkeypatch.setattr(add_asset_family_handler, "load_model", fake_load_model) + + with pytest.raises(ModelNotFoundError) as exc_info: + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=_CAP_DECLARED), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert exc_info.value.model_id == _MODEL_ID + _, version = await store.load("Asset", asset_id) + assert version == 1 + + +@pytest.mark.unit +async def test_handler_skips_model_load_when_asset_is_unbound( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Asset has `model_id=None`. The handler does not call + load_model and proceeds straight to decide+append. Verified by + monkeypatching load_model to a sentinel that raises if called.""" + asset_id = UUID("01900000-0000-7000-8000-0000000c0d13") + store = InMemoryEventStore() + await _seed_asset_with_model_id(store, asset_id, model_id=None) + deps = _build_deps(event_store=store) + + async def sentinel_load_model(event_store: EventStore, model_id: UUID) -> Model | None: + _ = (event_store, model_id) + pytest.fail("load_model should not be called for an unbound Asset") + + monkeypatch.setattr(add_asset_family_handler, "load_model", sentinel_load_model) + + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=_CAP_DECLARED), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", asset_id) + assert version == 2 + assert [e.event_type for e in events] == ["AssetRegistered", "AssetFamilyAdded"] diff --git a/apps/api/tests/unit/equipment/test_asset.py b/apps/api/tests/unit/equipment/test_asset.py index 8011f8fdb..8106fee68 100644 --- a/apps/api/tests/unit/equipment/test_asset.py +++ b/apps/api/tests/unit/equipment/test_asset.py @@ -1,10 +1,14 @@ """AssetName VO + AssetLevel + AssetLifecycle enum tests.""" +from uuid import uuid4 + import pytest from cora.equipment.aggregates.asset import ( + Asset, AssetLevel, AssetLifecycle, + AssetModelMismatchError, AssetName, InvalidAssetNameError, ) @@ -130,3 +134,97 @@ def test_asset_lifecycle_values_are_pascal_case_strings() -> None: def test_asset_lifecycle_is_str_enum() -> None: assert isinstance(AssetLifecycle.COMMISSIONED, str) assert AssetLifecycle.COMMISSIONED == "Commissioned" + + +# ---------- Asset.model_id ---------- + + +@pytest.mark.unit +def test_asset_model_id_defaults_to_none() -> None: + """Additive-state pattern: legacy AssetRegistered streams without + model_id fold cleanly to None. Pin so adding a different default + is a deliberate change.""" + asset = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + ) + assert asset.model_id is None + + +@pytest.mark.unit +def test_asset_model_id_accepts_uuid() -> None: + """Construction with a Model binding lands the UUID on state.""" + model_id = uuid4() + asset = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + model_id=model_id, + ) + assert asset.model_id == model_id + + +# ---------- AssetModelMismatchError error class ---------- + + +@pytest.mark.unit +def test_asset_model_mismatch_carries_all_four_fields() -> None: + """Lock E: the error carries (asset_id, model_id, declared_families, + asset_family_ids) for diagnostics. Pin so any constructor signature + change is deliberate.""" + asset_id = uuid4() + model_id = uuid4() + fam_a = uuid4() + fam_b = uuid4() + declared = frozenset({fam_a, fam_b}) + on_asset = frozenset({fam_a}) + error = AssetModelMismatchError( + asset_id=asset_id, + model_id=model_id, + declared_families=declared, + asset_family_ids=on_asset, + ) + assert error.asset_id == asset_id + assert error.model_id == model_id + assert error.declared_families == declared + assert error.asset_family_ids == on_asset + + +@pytest.mark.unit +def test_asset_model_mismatch_message_lists_both_sets_verbatim() -> None: + """Lock E: message lists both sets verbatim so operators see exactly + which Families are missing on the Asset.""" + asset_id = uuid4() + model_id = uuid4() + fam_a = uuid4() + fam_b = uuid4() + declared = frozenset({fam_a, fam_b}) + on_asset = frozenset({fam_a}) + error = AssetModelMismatchError( + asset_id=asset_id, + model_id=model_id, + declared_families=declared, + asset_family_ids=on_asset, + ) + message = str(error) + assert str(asset_id) in message + assert str(model_id) in message + # Both UUIDs of declared_families must appear; same for asset_family_ids. + assert str(fam_a) in message + assert str(fam_b) in message + + +@pytest.mark.unit +def test_asset_model_mismatch_is_exception() -> None: + """Subclass of Exception so it can be raised / caught in the + cannot_transition_cls tuple in routes.py.""" + error = AssetModelMismatchError( + asset_id=uuid4(), + model_id=uuid4(), + declared_families=frozenset(), + asset_family_ids=frozenset(), + ) + assert isinstance(error, Exception) diff --git a/apps/api/tests/unit/equipment/test_asset_events.py b/apps/api/tests/unit/equipment/test_asset_events.py index 9b82025f4..f3fab4c6a 100644 --- a/apps/api/tests/unit/equipment/test_asset_events.py +++ b/apps/api/tests/unit/equipment/test_asset_events.py @@ -286,6 +286,142 @@ def test_to_payload_then_from_stored_round_trips_with_drawing() -> None: assert from_stored(stored) == original +# ---------- AssetRegistered.model_id ---------- + + +@pytest.mark.unit +def test_to_payload_omits_model_id_key_when_model_id_is_none() -> None: + """Omit-when-None convention (Lock G): legacy AssetRegistered shape + (no model_id) must serialize without the key so existing stream + readers can't accidentally observe a None value where they + previously saw the key missing. Mirrors the drawing precedent.""" + event = AssetRegistered( + asset_id=uuid4(), + name="X", + level="Site", + parent_id=uuid4(), + occurred_at=_NOW, + ) + payload = to_payload(event) + assert "model_id" not in payload + + +@pytest.mark.unit +def test_to_payload_includes_model_id_when_set() -> None: + asset_id = uuid4() + parent_id = uuid4() + model_id = uuid4() + event = AssetRegistered( + asset_id=asset_id, + name="Microscope-2BM-A", + level="Assembly", + parent_id=parent_id, + occurred_at=_NOW, + model_id=model_id, + ) + payload = to_payload(event) + assert payload["model_id"] == str(model_id) + + +@pytest.mark.unit +def test_from_stored_rebuilds_asset_registered_with_model_id() -> None: + asset_id = uuid4() + parent_id = uuid4() + model_id = uuid4() + stored = _stored( + "AssetRegistered", + { + "asset_id": str(asset_id), + "name": "Microscope-2BM-A", + "level": "Assembly", + "parent_id": str(parent_id), + "occurred_at": _NOW.isoformat(), + "model_id": str(model_id), + }, + ) + rebuilt = from_stored(stored) + assert rebuilt == AssetRegistered( + asset_id=asset_id, + name="Microscope-2BM-A", + level="Assembly", + parent_id=parent_id, + occurred_at=_NOW, + model_id=model_id, + ) + + +@pytest.mark.unit +def test_from_stored_folds_legacy_payload_without_model_id_to_none() -> None: + """Backward-compat pin: existing AssetRegistered events written before + the model_id widen had no model_id key; they MUST fold to + model_id=None without raising. Mirrors the drawing legacy-fold + precedent.""" + asset_id = uuid4() + stored = _stored( + "AssetRegistered", + { + "asset_id": str(asset_id), + "name": "Pre-widen Asset", + "level": "Unit", + "parent_id": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + ) + rebuilt = from_stored(stored) + assert isinstance(rebuilt, AssetRegistered) + assert rebuilt.model_id is None + + +@pytest.mark.unit +def test_to_payload_then_from_stored_round_trips_without_model_id_explicit() -> None: + """Pin the omit-then-rebuild path: model_id=None survives the + serialize+deserialize round-trip and emerges as model_id=None + (not as a missing attribute or something else).""" + original = AssetRegistered( + asset_id=uuid4(), + name="No-Model Asset", + level="Site", + parent_id=uuid4(), + occurred_at=_NOW, + ) + stored = _stored("AssetRegistered", to_payload(original)) + rebuilt = from_stored(stored) + assert rebuilt == original + assert isinstance(rebuilt, AssetRegistered) + assert rebuilt.model_id is None + + +@pytest.mark.unit +def test_to_payload_then_from_stored_round_trips_with_model_id() -> None: + original = AssetRegistered( + asset_id=uuid4(), + name="Microscope-2BM-A", + level="Assembly", + parent_id=uuid4(), + occurred_at=_NOW, + model_id=uuid4(), + ) + stored = _stored("AssetRegistered", to_payload(original)) + assert from_stored(stored) == original + + +@pytest.mark.unit +def test_to_payload_then_from_stored_round_trips_with_drawing_and_model_id() -> None: + """Both additive fields set: the two omit-when-None blocks must + compose cleanly and the round-trip preserves both.""" + original = AssetRegistered( + asset_id=uuid4(), + name="Microscope-2BM-A", + level="Assembly", + parent_id=uuid4(), + occurred_at=_NOW, + drawing=Drawing(system=DrawingSystem.ICMS, number="P4105", revision="A"), + model_id=uuid4(), + ) + stored = _stored("AssetRegistered", to_payload(original)) + assert from_stored(stored) == original + + @pytest.mark.unit def test_from_stored_raises_on_unknown_event_type() -> None: """Foreign event_types in a stream must fail loud, not be silently dropped.""" diff --git a/apps/api/tests/unit/equipment/test_asset_evolver.py b/apps/api/tests/unit/equipment/test_asset_evolver.py index e9d5dbb1c..39bf4d7fd 100644 --- a/apps/api/tests/unit/equipment/test_asset_evolver.py +++ b/apps/api/tests/unit/equipment/test_asset_evolver.py @@ -1603,3 +1603,209 @@ def test_evolve_port_removed_preserves_drawing() -> None: ) state = evolve(prior, AssetPortRemoved(asset_id=prior.id, port_name="x", occurred_at=_NOW)) assert state.drawing == _SAMPLE_DRAWING + + +# ---------- model_id genesis + preservation across transitions ---------- + + +@pytest.mark.unit +def test_evolve_register_with_model_id_carries_model_id_into_state() -> None: + """Genesis: AssetRegistered with model_id set lands the binding on + Asset.model_id. Lock A: model_id is set ONCE at register_asset time.""" + asset_id = uuid4() + model_id = uuid4() + state = evolve( + None, + AssetRegistered( + asset_id=asset_id, + name="X", + level="Unit", + parent_id=uuid4(), + occurred_at=_NOW, + model_id=model_id, + ), + ) + assert state.model_id == model_id + + +@pytest.mark.unit +def test_evolve_register_without_model_id_yields_none() -> None: + """Additive-state pattern: registration without model_id yields + Asset.model_id=None (permissive default).""" + state = evolve( + None, + AssetRegistered( + asset_id=uuid4(), + name="X", + level="Unit", + parent_id=uuid4(), + occurred_at=_NOW, + ), + ) + assert state.model_id is None + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("name", "transition"), + [ + ("activate", AssetActivated), + ("decommission", AssetDecommissioned), + ("enter_maintenance", AssetMaintenanceEntered), + ("exit_maintenance", AssetMaintenanceExited), + ], +) +def test_evolve_lifecycle_transition_preserves_model_id( + name: str, + transition: type, +) -> None: + """Critical pin: every lifecycle transition arm MUST carry model_id + through from prior state. model_id is set ONCE at registration per + Lock A and never changes post-genesis, but transition arms still + must carry it forward like any other Asset field.""" + _ = name + model_id = uuid4() + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + lifecycle=( + AssetLifecycle.COMMISSIONED + if transition is AssetActivated + else AssetLifecycle.ACTIVE + if transition is AssetMaintenanceEntered + else AssetLifecycle.MAINTENANCE + if transition is AssetMaintenanceExited + else AssetLifecycle.ACTIVE + ), + model_id=model_id, + ) + state = evolve(prior, transition(asset_id=prior.id, occurred_at=_NOW)) + assert state.model_id == model_id + + +@pytest.mark.unit +def test_evolve_relocate_preserves_model_id() -> None: + """Hierarchy mutation also must preserve model_id.""" + old_parent = uuid4() + new_parent = uuid4() + model_id = uuid4() + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=old_parent, + model_id=model_id, + ) + state = evolve( + prior, + AssetRelocated( + asset_id=prior.id, + from_parent_id=old_parent, + to_parent_id=new_parent, + reason="moved", + occurred_at=_NOW, + ), + ) + assert state.model_id == model_id + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("name", "transition", "kwargs"), + [ + ("family_added", AssetFamilyAdded, {"family_id": uuid4()}), + ("family_removed", AssetFamilyRemoved, {"family_id": uuid4()}), + ("degraded", AssetDegraded, {"reason": "x"}), + ("faulted", AssetFaulted, {"reason": "x"}), + ("restored", AssetRestored, {"reason": "x"}), + ("settings_updated", AssetSettingsUpdated, {"settings": {"a": 1}}), + ], +) +def test_evolve_mutation_preserves_model_id( + name: str, + transition: type, + kwargs: dict[str, object], +) -> None: + """Mirror of test_evolve_mutation_preserves_drawing: every mutation + arm carries model_id forward.""" + _ = name + model_id = uuid4() + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + model_id=model_id, + ) + state = evolve(prior, transition(asset_id=prior.id, occurred_at=_NOW, **kwargs)) + assert state.model_id == model_id + + +@pytest.mark.unit +def test_evolve_port_added_preserves_model_id() -> None: + model_id = uuid4() + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.DEVICE, + parent_id=uuid4(), + model_id=model_id, + ) + state = evolve( + prior, + AssetPortAdded( + asset_id=prior.id, + port_name="x", + direction="Input", + signal_type="TTL", + occurred_at=_NOW, + ), + ) + assert state.model_id == model_id + + +@pytest.mark.unit +def test_evolve_port_removed_preserves_model_id() -> None: + port = AssetPort(name="x", direction=PortDirection.INPUT, signal_type="TTL") + model_id = uuid4() + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.DEVICE, + parent_id=uuid4(), + ports=frozenset({port}), + model_id=model_id, + ) + state = evolve(prior, AssetPortRemoved(asset_id=prior.id, port_name="x", occurred_at=_NOW)) + assert state.model_id == model_id + + +@pytest.mark.unit +def test_fold_register_with_model_id_then_lifecycle_transitions_preserves_model_id() -> None: + """End-to-end fold: register with model_id, then activate + enter + maintenance + exit maintenance + decommission. The model_id binding + survives the entire lifecycle path.""" + asset_id = uuid4() + parent_id = uuid4() + model_id = uuid4() + state = fold( + [ + AssetRegistered( + asset_id=asset_id, + name="APS-2BM", + level="Unit", + parent_id=parent_id, + occurred_at=_NOW, + model_id=model_id, + ), + AssetActivated(asset_id=asset_id, occurred_at=_NOW), + AssetMaintenanceEntered(asset_id=asset_id, occurred_at=_NOW), + AssetMaintenanceExited(asset_id=asset_id, occurred_at=_NOW), + AssetDecommissioned(asset_id=asset_id, occurred_at=_NOW), + ] + ) + assert state is not None + assert state.model_id == model_id + assert state.lifecycle is AssetLifecycle.DECOMMISSIONED diff --git a/apps/api/tests/unit/equipment/test_asset_summary_projection.py b/apps/api/tests/unit/equipment/test_asset_summary_projection.py index ff5f94ab6..ef4d081bb 100644 --- a/apps/api/tests/unit/equipment/test_asset_summary_projection.py +++ b/apps/api/tests/unit/equipment/test_asset_summary_projection.py @@ -18,6 +18,7 @@ _ASSET_ID = uuid4() _PARENT_ID = uuid4() _OTHER_PARENT_ID = uuid4() +_MODEL_ID = uuid4() _EVENT_ID = uuid4() _CORRELATION_ID = uuid4() _NOW = datetime(2026, 5, 12, 14, 0, 0, tzinfo=UTC) @@ -101,7 +102,9 @@ async def test_asset_registered_inserts_with_commissioned_lifecycle_and_parent() assert args.args[5] is None assert args.args[6] is None assert args.args[7] is None - assert args.args[8] == _NOW + # model_id omitted from payload: column folds to NULL. + assert args.args[8] is None + assert args.args[9] == _NOW @pytest.mark.unit @@ -158,6 +161,56 @@ async def test_asset_registered_with_drawing_no_revision_keeps_revision_null() - assert args.args[7] is None +@pytest.mark.unit +async def test_asset_registered_with_model_id_populates_model_column() -> None: + """Bound Asset: AssetRegistered payload carries model_id; projection + parses to UUID and writes into the model_id column.""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _stored( + "AssetRegistered", + { + "asset_id": str(_ASSET_ID), + "name": "Microscope-2BM-A", + "level": "Assembly", + "parent_id": str(_PARENT_ID), + "model_id": str(_MODEL_ID), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[8] == _MODEL_ID + + +@pytest.mark.unit +async def test_asset_registered_without_model_id_leaves_model_column_null() -> None: + """Legacy AssetRegistered events (and genesis registrations with no + Model binding) omit the model_id payload key entirely; the column + folds to NULL via payload.get('model_id').""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _stored( + "AssetRegistered", + { + "asset_id": str(_ASSET_ID), + "name": "unbound-asset", + "level": "Device", + "parent_id": str(_PARENT_ID), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[8] is None + + @pytest.mark.unit async def test_asset_registered_with_null_parent_for_enterprise_root() -> None: """Enterprise-level Assets are the root; parent_id is None.""" diff --git a/apps/api/tests/unit/equipment/test_register_asset_decider.py b/apps/api/tests/unit/equipment/test_register_asset_decider.py index 0488f77cd..a2cefb91a 100644 --- a/apps/api/tests/unit/equipment/test_register_asset_decider.py +++ b/apps/api/tests/unit/equipment/test_register_asset_decider.py @@ -192,6 +192,42 @@ def test_decide_defaults_drawing_to_none_when_omitted() -> None: assert events[0].drawing is None +@pytest.mark.unit +def test_decide_propagates_model_id_to_emitted_event() -> None: + """Happy path: an optional model_id supplied on the command rides + the AssetRegistered event verbatim. The decider does NOT load the + Model snapshot per Lock B; the handler is the seam that enforces + existence.""" + model_id = uuid4() + events = register_asset.decide( + state=None, + command=RegisterAsset( + name="Microscope-2BM-A", + level=AssetLevel.ASSEMBLY, + parent_id=uuid4(), + model_id=model_id, + ), + now=_NOW, + new_id=uuid4(), + ) + assert events[0].model_id == model_id + + +@pytest.mark.unit +def test_decide_defaults_model_id_to_none_when_omitted() -> None: + events = register_asset.decide( + state=None, + command=RegisterAsset( + name="APS", + level=AssetLevel.SITE, + parent_id=uuid4(), + ), + now=_NOW, + new_id=uuid4(), + ) + assert events[0].model_id is None + + @pytest.mark.unit def test_decide_is_pure_same_inputs_same_outputs() -> None: new_id = uuid4() diff --git a/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py b/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py new file mode 100644 index 000000000..49546c682 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py @@ -0,0 +1,105 @@ +"""Property-based tests for `register_asset.decide` (Equipment BC). + +Universal claims across generated inputs, scoped to the model_id +propagation contract added by the asset-model-binding slice: + + - state=None + valid command + any `model_id` (UUID-or-None) + emits a single `AssetRegistered` whose `model_id` field equals + the command's `model_id` verbatim. The decider does NOT load + the Model snapshot per Lock B; the handler is the seam that + enforces existence. + - Pure: same (state, command, now, new_id) returns the same + events for any `model_id` choice. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.asset import AssetLevel, AssetRegistered +from cora.equipment.features import register_asset +from cora.equipment.features.register_asset import RegisterAsset +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) +_NON_ENTERPRISE_LEVELS = st.sampled_from( + [ + AssetLevel.SITE, + AssetLevel.AREA, + AssetLevel.UNIT, + AssetLevel.ASSEMBLY, + AssetLevel.DEVICE, + ] +) + + +@pytest.mark.unit +@given( + name=_NAME, + level=_NON_ENTERPRISE_LEVELS, + parent_id=st.uuids(), + model_id=st.one_of(st.none(), st.uuids()), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_register_asset_propagates_model_id_verbatim_into_event( + name: str, + level: AssetLevel, + parent_id: UUID, + model_id: UUID | None, + now: datetime, + new_id: UUID, +) -> None: + """Any (UUID-or-None) `model_id` on the command rides AssetRegistered + unchanged. The decider does not inspect or load the referenced Model + stream; that is the handler's responsibility per Lock B.""" + command = RegisterAsset( + name=name, + level=level, + parent_id=parent_id, + model_id=model_id, + ) + events = register_asset.decide(state=None, command=command, now=now, new_id=new_id) + assert len(events) == 1 + event = events[0] + assert isinstance(event, AssetRegistered) + assert event.model_id == model_id + + +@pytest.mark.unit +@given( + name=_NAME, + level=_NON_ENTERPRISE_LEVELS, + parent_id=st.uuids(), + model_id=st.one_of(st.none(), st.uuids()), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_register_asset_is_pure_across_model_id_inputs( + name: str, + level: AssetLevel, + parent_id: UUID, + model_id: UUID | None, + now: datetime, + new_id: UUID, +) -> None: + """Two calls with identical args (including model_id) return identical + events. Pins decider purity over the new model_id axis.""" + command = RegisterAsset( + name=name, + level=level, + parent_id=parent_id, + model_id=model_id, + ) + first = register_asset.decide(state=None, command=command, now=now, new_id=new_id) + second = register_asset.decide(state=None, command=command, now=now, new_id=new_id) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_register_asset_handler.py b/apps/api/tests/unit/equipment/test_register_asset_handler.py index d6fcb5634..10ef057b7 100644 --- a/apps/api/tests/unit/equipment/test_register_asset_handler.py +++ b/apps/api/tests/unit/equipment/test_register_asset_handler.py @@ -11,6 +11,14 @@ InvalidAssetNameError, InvalidAssetParentError, ) +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelName, + ModelNotFoundError, + PartNumber, +) from cora.equipment.features import register_asset from cora.equipment.features.register_asset import RegisterAsset from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore @@ -226,6 +234,107 @@ def test_wire_equipment_includes_register_asset() -> None: assert callable(handlers.get_family) +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_model_stream_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When command.model_id is supplied but the Model stream returns + None, the handler raises ModelNotFoundError BEFORE appending the + AssetRegistered event. The 404 mapping is wired at the BC route + layer (`_handle_not_found`).""" + unknown_model_id = UUID("01900000-0000-7000-8000-000000def001") + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + + async def _load_none(event_store: object, model_id: UUID) -> Model | None: + _ = (event_store, model_id) + return None + + monkeypatch.setattr("cora.equipment.features.register_asset.handler.load_model", _load_none) + + handler = register_asset.bind(deps) + with pytest.raises(ModelNotFoundError) as exc_info: + await handler( + RegisterAsset( + name="APS-2BM", + level=AssetLevel.UNIT, + parent_id=_PARENT_ID, + model_id=unknown_model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == unknown_model_id + + events, version = await store.load("Asset", _NEW_ID) + assert events == [] + assert version == 0 + + +@pytest.mark.unit +async def test_handler_proceeds_when_model_id_resolves( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Happy path: when load_model returns a Model snapshot, the + handler appends AssetRegistered carrying the model_id verbatim.""" + model_id = UUID("01900000-0000-7000-8000-000000ade001") + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + + async def _load_model(event_store: object, requested_id: UUID) -> Model | None: + _ = event_store + assert requested_id == model_id + return Model( + id=requested_id, + name=ModelName("EigerX-9M"), + manufacturer=Manufacturer(name=ManufacturerName("Dectris")), + part_number=PartNumber("EX9M-001"), + declared_families=frozenset({UUID("01900000-0000-7000-8000-000000fa1001")}), + ) + + monkeypatch.setattr("cora.equipment.features.register_asset.handler.load_model", _load_model) + + handler = register_asset.bind(deps) + await handler( + RegisterAsset( + name="APS-2BM-Det", + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", _NEW_ID) + assert version == 1 + assert events[0].event_type == "AssetRegistered" + assert events[0].payload["model_id"] == str(model_id) + + +@pytest.mark.unit +async def test_handler_omits_model_id_payload_key_when_command_has_none() -> None: + """When command.model_id is None the handler must NOT attempt a + Model load (no monkeypatch needed) and the emitted payload must + omit the `model_id` key entirely (mirrors the `drawing` omit-when- + None convention locked in events.py).""" + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + + handler = register_asset.bind(deps) + await handler( + RegisterAsset( + name="APS-2BM", + level=AssetLevel.UNIT, + parent_id=_PARENT_ID, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + events, _ = await store.load("Asset", _NEW_ID) + assert "model_id" not in events[0].payload + + @pytest.mark.unit async def test_wired_handler_propagates_causation_id_through_full_composition() -> None: """End-to-end check that causation_id survives the diff --git a/infra/atlas/migrations/20260602110000_add_asset_summary_model.sql b/infra/atlas/migrations/20260602110000_add_asset_summary_model.sql new file mode 100644 index 000000000..1e5736428 --- /dev/null +++ b/infra/atlas/migrations/20260602110000_add_asset_summary_model.sql @@ -0,0 +1,29 @@ +-- Widen proj_equipment_asset_summary with the Asset.model_id facet: +-- the optional Model binding captured at registration that connects +-- a deployed Asset back to its catalog entry in the Model BC. +-- +-- Additive nullable per Lock F of project_asset_model_binding_design. +-- No NOT NULL, no DEFAULT, no CHECK: legacy AssetRegistered events +-- written before the binding slice ships fold to model_id=None and +-- the projection writes NULL into the new column for those rows on +-- rebuild. Greenfield-friendly; no backfill needed. +-- +-- ## Partial index +-- +-- Mirrors the parent_id partial-index precedent at +-- 20260512280000_init_proj_equipment_asset_summary.sql:54-56. The +-- future "list Assets bound to Model X" lookup hits WHERE model_id +-- = $1; rows with model_id IS NULL never match that predicate and +-- are excluded from the index to keep it tight. +-- +-- ## Forward-only +-- +-- Pure ADD COLUMN; greenfield-friendly; no backfill needed. Rollback +-- via a NEW compensating migration per project_forward_only_migrations. + +ALTER TABLE proj_equipment_asset_summary + ADD COLUMN model_id UUID; + +CREATE INDEX proj_equipment_asset_summary_model_idx + ON proj_equipment_asset_summary (model_id) + WHERE model_id IS NOT NULL; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index ccb8c29f8..fc021a2a1 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:HcN5lpQUMg8RaMTT5EDI9eVoUwMU70UX3pNkpf2oNKU= +h1:sLm0yu7jixQpmCLHCYI8oZI9PNP+sfCTHQb3ibvfDbA= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -91,4 +91,5 @@ h1:HcN5lpQUMg8RaMTT5EDI9eVoUwMU70UX3pNkpf2oNKU= 20260601100200_add_proj_federation_seal_summary_stream_id.sql h1:/sgFuocyP63WPyKSk8wrZq1r8AZ9wfQV6iqt5eyhfPI= 20260601110000_init_proj_equipment_model_summary.sql h1:QJCanmiewUXP1knkN62ajcTon0dskClItX8pNOUwCzw= 20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:3zElIH2cC7y2mOyOmRgU+Asf3bsvfLtekrVH9mYnBqM= -20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:P7lqDSEdsMkNVn7r7IwVtoTsAGX/6FltJD7HEOR9qNs= +20260602110000_add_asset_summary_model.sql h1:6JNrSeL/whEo/ZQ6IlZs21RJ79BGcl0bR35nH0IhTGc= +20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:AGBAc0XP1TXK04PIFKavYe0buGgonSFMbL9MxYyr7bk=