From 0ef41febc33749926669b6c4a31856d457b4fd7d Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 09:39:09 +0300 Subject: [PATCH] feat(equipment): register_fixture rejects Decommissioned bound assets Adds a single cross-aggregate guard: every Asset referenced by `slot_asset_bindings` must NOT be Decommissioned. Mirrors the sibling AssetCannotAttachToFixtureError precondition at attach-time and rejects at register-time so the operator does not register a Fixture that would inevitably fail later at attach_asset_to_fixture (Fixture is single-event-genesis and cannot be amended). - New FixtureAssetNotAttachableError carries the sorted-first offending asset_id and current lifecycle string, mirroring the FixtureAssetNotFoundError deterministic-error precedent in the same decider. - RegisterFixtureContext gains a `lifecycle_by_asset_id` dict populated from the existing per-Asset load_asset gather (no extra round-trip, no new projection). Default empty dict means decider-only unit tests that exercise other invariants leave the guard inactive (mirrors family_ids_by_asset_id's relaxed default). - Decider guard ordering: existence (FixtureAssetNotFoundError) -> NEW lifecycle (FixtureAssetNotAttachableError) -> unknown-slot -> cardinality -> family-mismatch -> param-overrides. Operator sees the most actionable error first. - Route + OpenAPI 409 description list the new cause; routes.py wires the new error into the existing _handle_cannot_transition family alongside its Asset-BC mirror. Zero existing tests break because every existing happy-path test uses Active assets. Two new decider unit tests (guard fires with sorted-first determinism + empty-dict short-circuit), plus one integration test exercising the full handler stack end-to-end against Postgres (register_asset -> decommission_asset -> register_fixture -> FixtureAssetNotAttachableError). Closes INV-5 from the Fixture+Mount+Asset alignment plan; first half of Slice 3 (INV-4 orphan-binding guard deferred to Slice 3b to keep this slice ripple-free). Slice 3a in the Option A sequence. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 2 +- .../equipment/aggregates/assembly/__init__.py | 2 + .../equipment/aggregates/assembly/state.py | 25 ++++++ .../features/register_fixture/context.py | 18 ++++- .../features/register_fixture/decider.py | 30 +++++++ .../features/register_fixture/handler.py | 5 ++ .../features/register_fixture/route.py | 7 +- apps/api/src/cora/equipment/routes.py | 2 + .../test_register_fixture_handler_postgres.py | 81 ++++++++++++++++++- .../test_register_fixture_decider.py | 70 ++++++++++++++++ 10 files changed, 233 insertions(+), 9 deletions(-) diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ef4b5face..b124fdbda 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -15154,7 +15154,7 @@ } } }, - "description": "Assembly is Deprecated (cannot instantiate), OR a concurrent write to the new Fixture stream conflicted (optimistic concurrency; essentially impossible with UUIDv7 ids)." + "description": "Assembly is Deprecated (cannot instantiate), OR a referenced Asset is Decommissioned (lifecycle disallows attachment), OR a concurrent write to the new Fixture stream conflicted (optimistic concurrency; essentially impossible with UUIDv7 ids)." }, "422": { "description": "Request body failed schema validation OR Idempotency-Key was reused with a different request body." diff --git a/apps/api/src/cora/equipment/aggregates/assembly/__init__.py b/apps/api/src/cora/equipment/aggregates/assembly/__init__.py index 8b5abfcc6..066c2c216 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/__init__.py @@ -47,6 +47,7 @@ AssemblyStatus, FamilyNotFoundForAssemblyError, FixtureAssetFamilyMismatchError, + FixtureAssetNotAttachableError, FixtureAssetNotFoundError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, @@ -81,6 +82,7 @@ "AssemblyVersioned", "FamilyNotFoundForAssemblyError", "FixtureAssetFamilyMismatchError", + "FixtureAssetNotAttachableError", "FixtureAssetNotFoundError", "FixtureMappingIncompleteError", "FixtureParameterOverridesInvalidError", diff --git a/apps/api/src/cora/equipment/aggregates/assembly/state.py b/apps/api/src/cora/equipment/aggregates/assembly/state.py index 5a28ac567..a31d315c3 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/state.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/state.py @@ -290,6 +290,31 @@ def __init__(self, asset_id: UUID) -> None: self.asset_id = asset_id +class FixtureAssetNotAttachableError(Exception): + """A referenced Asset's lifecycle disallows attachment to a Fixture. + + Currently fires for `Decommissioned` Assets only (terminal state; + no further wiring). Mirrors the Asset BC's + `AssetCannotAttachToFixtureError` precondition at register time: + rejecting a Decommissioned binding here prevents the operator + from registering a Fixture that would inevitably fail later at + `attach_asset_to_fixture` (the Fixture is single-event-genesis + and cannot be amended). + + Carries the sorted-first offending `asset_id` for deterministic + error responses. + """ + + def __init__(self, asset_id: UUID, current_lifecycle: str) -> None: + super().__init__( + f"Asset {asset_id} cannot be bound into a Fixture: currently in " + f"lifecycle {current_lifecycle}; expected Commissioned, Active, " + f"or Maintenance" + ) + self.asset_id = asset_id + self.current_lifecycle = current_lifecycle + + class FixtureMappingIncompleteError(Exception): """`register_fixture`'s slot_asset_bindings does not satisfy the required cardinality of one or more slots. diff --git a/apps/api/src/cora/equipment/features/register_fixture/context.py b/apps/api/src/cora/equipment/features/register_fixture/context.py index cf747c1fa..1a2325c53 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/context.py +++ b/apps/api/src/cora/equipment/features/register_fixture/context.py @@ -1,6 +1,6 @@ """Context snapshot loaded by the register_fixture handler. -Single-stream-write + projection-precondition pattern: the handler +Single-stream-write + cross-aggregate-read pattern: the handler loads the target Assembly state plus every referenced Asset state BEFORE calling the decider, packs the results into this VO, and hands it to the pure decider for invariant enforcement. @@ -13,19 +13,33 @@ A `None` value tells the decider to raise `FixtureAssetNotFoundError` carrying the missing id (sorted-first for deterministic responses). + +`lifecycle_by_asset_id` maps each referenced asset_id to its current +`AssetLifecycle`, or `None` when the asset_id did not resolve. Used +by the decider to raise `FixtureAssetNotAttachableError` for +Decommissioned bindings (rejecting at register-time prevents the +operator from registering a Fixture that would inevitably fail +later at `attach_asset_to_fixture`, since Fixture is single-event- +genesis and cannot be amended). Empty dict (default) means no +lifecycle info was loaded; the decider skips the guard entirely +(useful for decider unit tests that exercise other invariants). """ from dataclasses import dataclass, field from uuid import UUID from cora.equipment.aggregates.assembly import Assembly +from cora.equipment.aggregates.asset import AssetLifecycle @dataclass(frozen=True) class RegisterFixtureContext: - """Snapshot of Assembly + Asset existence checks for register_fixture.""" + """Snapshot of Assembly + Asset existence + lifecycle checks.""" assembly_state: Assembly | None family_ids_by_asset_id: dict[UUID, frozenset[UUID] | None] = field( default_factory=dict[UUID, frozenset[UUID] | None] ) + lifecycle_by_asset_id: dict[UUID, AssetLifecycle | None] = field( + default_factory=dict[UUID, AssetLifecycle | None] + ) diff --git a/apps/api/src/cora/equipment/features/register_fixture/decider.py b/apps/api/src/cora/equipment/features/register_fixture/decider.py index f619343c5..7cbc0a730 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/decider.py +++ b/apps/api/src/cora/equipment/features/register_fixture/decider.py @@ -19,6 +19,10 @@ - Every referenced asset_id in the bindings must resolve -> FixtureAssetNotFoundError carrying the sorted-first missing id for deterministic error responses. + - Every referenced Asset must NOT be Decommissioned + -> FixtureAssetNotAttachableError carrying the sorted-first + offending asset_id (mirrors AssetCannotAttachToFixtureError + at attach-time). - Each TemplateSlot's cardinality is satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -49,12 +53,14 @@ AssemblyNotFoundError, AssemblyStatus, FixtureAssetFamilyMismatchError, + FixtureAssetNotAttachableError, FixtureAssetNotFoundError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, SlotCardinality, TemplateSlot, ) +from cora.equipment.aggregates.asset import AssetLifecycle from cora.equipment.aggregates.fixture import ( Fixture, FixtureAlreadyExistsError, @@ -120,6 +126,10 @@ def decide( - Every referenced asset_id must resolve to a registered Asset -> FixtureAssetNotFoundError carrying the sorted-first missing id for deterministic error responses. + - Every referenced Asset must NOT be Decommissioned + -> FixtureAssetNotAttachableError carrying the sorted-first + offending asset_id (mirrors AssetCannotAttachToFixtureError + at attach-time). - Each TemplateSlot's cardinality must be satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -159,6 +169,26 @@ def decide( if missing_asset_ids: raise FixtureAssetNotFoundError(missing_asset_ids[0]) + # Cross-aggregate guard: every referenced Asset must NOT be + # Decommissioned (mirrors AssetCannotAttachToFixtureError at + # attach-time; rejecting here prevents registering a Fixture that + # would inevitably fail at the per-Asset attach step since the + # Fixture is single-event-genesis and cannot be amended). + # Empty dict means no lifecycle info loaded -> guard skipped. + decommissioned_asset_ids = sorted( + ( + asset_id + for asset_id, lifecycle in context.lifecycle_by_asset_id.items() + if lifecycle is AssetLifecycle.DECOMMISSIONED + ), + key=str, + ) + if decommissioned_asset_ids: + raise FixtureAssetNotAttachableError( + decommissioned_asset_ids[0], + AssetLifecycle.DECOMMISSIONED.value, + ) + slots_by_name = {slot.slot_name.value: slot for slot in assembly.required_slots} binding_counts: Counter[str] = Counter( binding.slot_name for binding in command.slot_asset_bindings diff --git a/apps/api/src/cora/equipment/features/register_fixture/handler.py b/apps/api/src/cora/equipment/features/register_fixture/handler.py index ab8c5a0f2..3cb95dd87 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/handler.py +++ b/apps/api/src/cora/equipment/features/register_fixture/handler.py @@ -143,9 +143,14 @@ async def handler( aid: (asset.family_ids if asset is not None else None) for aid, asset in zip(asset_ids, assets, strict=True) } + lifecycle_by_asset_id = { + aid: (asset.lifecycle if asset is not None else None) + for aid, asset in zip(asset_ids, assets, strict=True) + } context = RegisterFixtureContext( assembly_state=assembly_state, family_ids_by_asset_id=family_ids_by_asset_id, + lifecycle_by_asset_id=lifecycle_by_asset_id, ) # Decider raises FixtureAlreadyExistsError defensively when diff --git a/apps/api/src/cora/equipment/features/register_fixture/route.py b/apps/api/src/cora/equipment/features/register_fixture/route.py index 27c3d6362..c241049b6 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/route.py +++ b/apps/api/src/cora/equipment/features/register_fixture/route.py @@ -118,9 +118,10 @@ def _get_handler(request: Request) -> IdempotentHandler: "model": ErrorResponse, "description": ( "Assembly is Deprecated (cannot instantiate), OR a " - "concurrent write to the new Fixture stream conflicted " - "(optimistic concurrency; essentially impossible with " - "UUIDv7 ids)." + "referenced Asset is Decommissioned (lifecycle disallows " + "attachment), OR a concurrent write to the new Fixture " + "stream conflicted (optimistic concurrency; essentially " + "impossible with UUIDv7 ids)." ), }, status.HTTP_422_UNPROCESSABLE_CONTENT: { diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 16ca8ff36..3b362ad1c 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -45,6 +45,7 @@ AssemblyNotFoundError, FamilyNotFoundForAssemblyError, FixtureAssetFamilyMismatchError, + FixtureAssetNotAttachableError, FixtureAssetNotFoundError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, @@ -520,6 +521,7 @@ def register_equipment_routes(app: FastAPI) -> None: AssetCannotAttachToFixtureError, AssetNotAttachedToFixtureError, AssetAttachedToDifferentFixtureError, + FixtureAssetNotAttachableError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) for pidinst_state_cls in ( diff --git a/apps/api/tests/integration/test_register_fixture_handler_postgres.py b/apps/api/tests/integration/test_register_fixture_handler_postgres.py index f6a1520a5..dd9048ff1 100644 --- a/apps/api/tests/integration/test_register_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_register_fixture_handler_postgres.py @@ -8,22 +8,29 @@ """ from datetime import UTC, datetime -from uuid import UUID +from uuid import UUID, uuid4 import asyncpg import pytest -from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot -from cora.equipment.aggregates.asset import AssetLevel +from cora.equipment.aggregates.assembly import ( + FixtureAssetNotAttachableError, + SlotCardinality, + SlotName, + TemplateSlot, +) +from cora.equipment.aggregates.asset import AssetLevel, AssetLifecycle from cora.equipment.aggregates.fixture import SlotAssetBinding from cora.equipment.features import ( add_asset_family, + decommission_asset, define_assembly, define_family, register_asset, register_fixture, ) from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.decommission_asset import DecommissionAsset from cora.equipment.features.define_assembly import DefineAssembly from cora.equipment.features.define_family import DefineFamily from cora.equipment.features.register_asset import RegisterAsset @@ -134,3 +141,71 @@ async def test_register_fixture_appends_genesis_event_to_postgres( assert assembly_version == 1 # defined only; UNCHANGED _ = asset_events _ = assembly_events + + +@pytest.mark.integration +async def test_register_fixture_rejects_decommissioned_asset_with_not_attachable_error( + db_pool: asyncpg.Pool, +) -> None: + """Cross-aggregate guard end-to-end: a Decommissioned Asset + cannot be bound into a Fixture. The lifecycle guard fires in the + pure decider after the handler folds the Asset's stream via the + standard load_asset gather (no extra round-trip, no new + projection). Rejecting at register-time prevents registering a + Fixture that would inevitably fail later at + `attach_asset_to_fixture`, since Fixture is single-event-genesis + and cannot be amended. + """ + deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(8)]) + + # Register a fresh Asset and decommission it directly from + # Commissioned (no install / activate needed; Slice 1's + # decommission guards do not fire because the Asset is not + # bound to a Fixture and not installed in any Mount). + asset_id = await register_asset.bind(deps)( + RegisterAsset(name="RetiredCam", level=AssetLevel.DEVICE, parent_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await decommission_asset.bind(deps)( + DecommissionAsset(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + family_id = await define_family.bind(deps)( + DefineFamily(name="RetiredCamera", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assembly_id = await define_assembly.bind(deps)( + DefineAssembly( + name="RetiredRig", + presents_as_family_id=family_id, + required_slots=frozenset( + { + TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(FixtureAssetNotAttachableError) as exc_info: + await register_fixture.bind(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)} + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.asset_id == asset_id + assert exc_info.value.current_lifecycle == AssetLifecycle.DECOMMISSIONED.value diff --git a/apps/api/tests/unit/equipment/test_register_fixture_decider.py b/apps/api/tests/unit/equipment/test_register_fixture_decider.py index 48903a9a2..841e1bb8a 100644 --- a/apps/api/tests/unit/equipment/test_register_fixture_decider.py +++ b/apps/api/tests/unit/equipment/test_register_fixture_decider.py @@ -12,6 +12,7 @@ AssemblyNotFoundError, AssemblyStatus, FixtureAssetFamilyMismatchError, + FixtureAssetNotAttachableError, FixtureAssetNotFoundError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, @@ -19,6 +20,7 @@ SlotName, TemplateSlot, ) +from cora.equipment.aggregates.asset import AssetLifecycle from cora.equipment.aggregates.fixture import ( FixtureRegistered, SlotAssetBinding, @@ -290,6 +292,74 @@ def test_decide_rejects_overrides_failing_schema_validation() -> None: ) +@pytest.mark.unit +def test_decide_rejects_decommissioned_bound_asset_with_not_attachable_error() -> None: + """Cross-aggregate guard: a Decommissioned Asset cannot be bound + into a Fixture; mirrors AssetCannotAttachToFixtureError at the + attach-time precondition. Fires AFTER the existence check + (FixtureAssetNotFoundError) but BEFORE cardinality / family + match so the operator sees the most actionable error first. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = _slot("camera", required_family_ids=frozenset({family_id})) + asset_id = uuid4() + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={asset_id: frozenset({family_id})}, + lifecycle_by_asset_id={asset_id: AssetLifecycle.DECOMMISSIONED}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)}, + ), + ) + with pytest.raises(FixtureAssetNotAttachableError) as exc_info: + register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.asset_id == asset_id + assert exc_info.value.current_lifecycle == AssetLifecycle.DECOMMISSIONED.value + + +@pytest.mark.unit +def test_decide_skips_lifecycle_guard_when_dict_is_empty() -> None: + """Default-empty lifecycle_by_asset_id means the handler did not + load lifecycle info (decider-only unit tests that exercise other + invariants leave it empty); the guard short-circuits without + firing. Mirrors family_ids_by_asset_id's relaxed default. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = _slot("camera", required_family_ids=frozenset({family_id})) + asset_id = uuid4() + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={asset_id: frozenset({family_id})}, + # lifecycle_by_asset_id intentionally omitted -> default empty + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)}, + ), + ) + events = register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + assert len(events) == 1 + assert isinstance(events[0], FixtureRegistered) + + @pytest.mark.unit def test_decide_is_pure_same_inputs_yield_same_events() -> None: assembly_id = uuid4()