From 0ef41febc33749926669b6c4a31856d457b4fd7d Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 09:39:09 +0300 Subject: [PATCH 1/5] 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() From 525b3daaba92367458c8837fa19f2c2365e26491 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 12:05:55 +0300 Subject: [PATCH 2/5] feat(equipment): register_fixture rejects orphan bound assets (INV-4) Adds the second cross-aggregate guard from Slice 3 (3a shipped the Decommissioned-lifecycle half). Every Asset referenced by `slot_asset_bindings` must currently be installed in some Mount; operators that try to register a Fixture for un-racked assets are rejected with `FixtureAssetNotInstalledError` instead of silently materializing an unbacked composition. Closes INV-4 from the Fixture+Mount+Asset alignment plan: the contract becomes install_asset -> register_fixture, never the reverse. - New FixtureAssetNotInstalledError carries the sorted-first orphan asset_id (matches sibling FixtureAssetNotFoundError / FixtureAssetNotAttachableError deterministic-error precedent). - RegisterFixtureContext gains a `mount_id_by_asset_id` field shaped as `dict[UUID, UUID | None] | None`. Whole-None disables the guard for the pool-less test path (matches install_asset / decommission_asset short-circuit convention); per-entry None signals an orphan and fires the guard. - Handler kicks off three concurrent I/O streams via asyncio: the existing `assembly_state_task` + per-Asset `load_asset` gather, plus a new `mount_ids_future` returned by `asyncio.gather` over `load_asset_location` calls. `asyncio.gather` already schedules its children concurrently, so storing the future (without await) is the canonical way to start a gather alongside other work. - Decider guard ordering: existence -> NEW Decommissioned (3a) -> NEW orphan (3b) -> unknown-slot -> cardinality -> family-mismatch -> param-overrides. Decommissioned check fires before orphan when both apply (lifecycle is the more fundamental constraint). - Route + OpenAPI 409 description list the new cause; routes.py wires the new error into _handle_cannot_transition. Test ripple (5 integration files updated to install-then-register): factored a shared `seed_installed_asset` helper into `tests/integration/_equipment_helpers.py` that registers Frame + Mount + Asset, activates and installs the Asset, drains projections, and returns the ids. Each rippled test calls the helper before register_fixture, so the outer FixedIdGenerator id pool only budgets for the post-seed work. Asset version assertions bumped +1 to account for the AssetActivated event the helper emits. Three new decider unit tests (orphan fires with sorted-first determinism, pool-None short-circuit, ordering vs Decommissioned) plus one new integration test that exercises the full handler stack end-to-end against Postgres without installing the Asset first and asserts FixtureAssetNotInstalledError fires. Slice 3b in the Option A sequence; gate-reviewed 4/5/5 (DDD-fit should-fix on concurrency folded in here). Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 2 +- .../equipment/aggregates/assembly/__init__.py | 2 + .../equipment/aggregates/assembly/state.py | 22 ++++ .../features/register_fixture/context.py | 16 ++- .../features/register_fixture/decider.py | 29 +++++ .../features/register_fixture/handler.py | 32 +++++- .../features/register_fixture/route.py | 8 +- apps/api/src/cora/equipment/routes.py | 2 + .../tests/integration/_equipment_helpers.py | 104 +++++++++++++++++- ...ttach_asset_to_fixture_handler_postgres.py | 27 ++--- ...est_decommission_asset_handler_postgres.py | 27 +++-- ...ach_asset_from_fixture_handler_postgres.py | 26 ++--- .../test_list_fixtures_handler_postgres.py | 37 ++++--- .../test_register_fixture_handler_postgres.py | 87 +++++++++++++-- .../test_register_fixture_decider.py | 104 ++++++++++++++++++ 15 files changed, 451 insertions(+), 74 deletions(-) diff --git a/apps/api/openapi.json b/apps/api/openapi.json index b124fdbda..1f5f62dfd 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -15154,7 +15154,7 @@ } } }, - "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)." + "description": "Assembly is Deprecated (cannot instantiate), OR a referenced Asset is Decommissioned (lifecycle disallows attachment), OR a referenced Asset is not currently installed in any Mount (install_asset first), 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 066c2c216..0527042e4 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/__init__.py @@ -49,6 +49,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, InvalidAssemblyNameError, @@ -84,6 +85,7 @@ "FixtureAssetFamilyMismatchError", "FixtureAssetNotAttachableError", "FixtureAssetNotFoundError", + "FixtureAssetNotInstalledError", "FixtureMappingIncompleteError", "FixtureParameterOverridesInvalidError", "InvalidAssemblyNameError", diff --git a/apps/api/src/cora/equipment/aggregates/assembly/state.py b/apps/api/src/cora/equipment/aggregates/assembly/state.py index a31d315c3..baa238531 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/state.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/state.py @@ -315,6 +315,28 @@ def __init__(self, asset_id: UUID, current_lifecycle: str) -> None: self.current_lifecycle = current_lifecycle +class FixtureAssetNotInstalledError(Exception): + """A referenced Asset is not currently installed in any Mount. + + Fires when `proj_equipment_asset_location` carries no row for the + Asset at register_fixture time, i.e., the Asset exists but has + not been physically racked. Closes INV-4 from the + Fixture+Mount+Asset alignment plan: a Fixture should materialize + only equipment that is already on the floor, so the + install-then-register-fixture choreography becomes the contract. + + Carries the sorted-first offending `asset_id` for deterministic + error responses. + """ + + def __init__(self, asset_id: UUID) -> None: + super().__init__( + f"Asset {asset_id} cannot be bound into a Fixture: not currently " + f"installed in any Mount; install_asset first" + ) + self.asset_id = asset_id + + 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 1a2325c53..1902fe505 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/context.py +++ b/apps/api/src/cora/equipment/features/register_fixture/context.py @@ -23,6 +23,19 @@ 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). + +`mount_id_by_asset_id` maps each referenced asset_id to the Mount +currently holding it (sourced from `proj_equipment_asset_location`), +or `None` when the Asset is not currently installed. The whole field +is `None` when the handler ran without a pool (test path) and the +orphan guard is disabled entirely; this matches the +install_asset / decommission_asset projection-precondition +short-circuit convention. When non-None and an entry maps to +`None`, the decider raises `FixtureAssetNotInstalledError` carrying +the sorted-first orphan id, closing INV-4 from the +Fixture+Mount+Asset alignment plan: a Fixture should snapshot only +equipment already on the floor, so install-then-register becomes +the contract. """ from dataclasses import dataclass, field @@ -34,7 +47,7 @@ @dataclass(frozen=True) class RegisterFixtureContext: - """Snapshot of Assembly + Asset existence + lifecycle checks.""" + """Snapshot of Assembly + Asset existence + lifecycle + install checks.""" assembly_state: Assembly | None family_ids_by_asset_id: dict[UUID, frozenset[UUID] | None] = field( @@ -43,3 +56,4 @@ class RegisterFixtureContext: lifecycle_by_asset_id: dict[UUID, AssetLifecycle | None] = field( default_factory=dict[UUID, AssetLifecycle | None] ) + mount_id_by_asset_id: dict[UUID, UUID | None] | None = 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 7cbc0a730..f0fc950cf 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/decider.py +++ b/apps/api/src/cora/equipment/features/register_fixture/decider.py @@ -23,6 +23,11 @@ -> FixtureAssetNotAttachableError carrying the sorted-first offending asset_id (mirrors AssetCannotAttachToFixtureError at attach-time). + - Every referenced Asset must currently be installed in some Mount + (when the handler loaded asset_location info) + -> FixtureAssetNotInstalledError carrying the sorted-first + orphan id. Closes INV-4 from the Fixture+Mount+Asset alignment + plan. - Each TemplateSlot's cardinality is satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -55,6 +60,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, SlotCardinality, @@ -130,6 +136,11 @@ def decide( -> FixtureAssetNotAttachableError carrying the sorted-first offending asset_id (mirrors AssetCannotAttachToFixtureError at attach-time). + - Every referenced Asset must currently be installed in some Mount + (when the handler loaded asset_location info) + -> FixtureAssetNotInstalledError carrying the sorted-first + orphan id. Closes INV-4 from the Fixture+Mount+Asset + alignment plan. - Each TemplateSlot's cardinality must be satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -189,6 +200,24 @@ def decide( AssetLifecycle.DECOMMISSIONED.value, ) + # Cross-aggregate guard: every referenced Asset must currently be + # installed in some Mount. Closes INV-4: a Fixture should snapshot + # only equipment already racked on the floor, so the + # install-then-register-fixture choreography becomes the contract. + # `mount_id_by_asset_id is None` means the handler ran without a + # pool (test path) and the guard is disabled entirely. + if context.mount_id_by_asset_id is not None: + orphan_asset_ids = sorted( + ( + asset_id + for asset_id, mount_id in context.mount_id_by_asset_id.items() + if mount_id is None + ), + key=str, + ) + if orphan_asset_ids: + raise FixtureAssetNotInstalledError(orphan_asset_ids[0]) + 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 3cb95dd87..16835e8d5 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/handler.py +++ b/apps/api/src/cora/equipment/features/register_fixture/handler.py @@ -38,6 +38,7 @@ from cora.equipment.features.register_fixture.command import RegisterFixture from cora.equipment.features.register_fixture.context import RegisterFixtureContext from cora.equipment.features.register_fixture.decider import decide +from cora.equipment.projections.asset_location import load_asset_location from cora.infrastructure.event_envelope import to_new_event from cora.infrastructure.kernel import Kernel from cora.infrastructure.logging import get_logger @@ -130,15 +131,38 @@ async def handler( now = deps.clock.now() asset_ids = _referenced_asset_ids(command) - # Split gather across the two aggregate types so pyright keeps + # Split gather across the aggregate types so pyright keeps # `assembly_state` narrowed to `Assembly | None` and each item # in `assets` narrowed to `Asset | None`; a single gather across - # both would widen everything to the union. + # all three would widen everything to the union. + # + # All three I/O streams run concurrently: assembly_state_task + # and mount_ids_future are scheduled before we await the + # per-Asset gather. The mount_ids_future is only created when + # deps.pool is set (pool=None short-circuit preserves the + # pre-tightening permissive default for the pool-less test + # path; matches install_asset / decommission_asset shape). In + # production deps.pool is always set, so every referenced + # asset_id gets an entry in the dict (mount_id when installed, + # None when orphan). assembly_state_task = asyncio.create_task( load_assembly(deps.event_store, command.assembly_id) ) + # asyncio.gather(...) returns a Future that has already + # scheduled its children; storing it (without await) is the + # canonical way to start a gather concurrently with other work. + if deps.pool is not None: + pool = deps.pool + mount_ids_future: asyncio.Future[list[UUID | None]] | None = asyncio.gather( + *(load_asset_location(pool, aid) for aid in asset_ids) + ) + else: + mount_ids_future = None + assets = await asyncio.gather(*(load_asset(deps.event_store, aid) for aid in asset_ids)) assembly_state = await assembly_state_task + mount_ids = await mount_ids_future if mount_ids_future is not None else None + family_ids_by_asset_id: dict[UUID, frozenset[UUID] | None] = { aid: (asset.family_ids if asset is not None else None) for aid, asset in zip(asset_ids, assets, strict=True) @@ -147,10 +171,14 @@ async def handler( aid: (asset.lifecycle if asset is not None else None) for aid, asset in zip(asset_ids, assets, strict=True) } + mount_id_by_asset_id: dict[UUID, UUID | None] | None = ( + dict(zip(asset_ids, mount_ids, strict=True)) if mount_ids is not None else None + ) context = RegisterFixtureContext( assembly_state=assembly_state, family_ids_by_asset_id=family_ids_by_asset_id, lifecycle_by_asset_id=lifecycle_by_asset_id, + mount_id_by_asset_id=mount_id_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 c241049b6..7e5135e86 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/route.py +++ b/apps/api/src/cora/equipment/features/register_fixture/route.py @@ -119,9 +119,11 @@ def _get_handler(request: Request) -> IdempotentHandler: "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)." + "attachment), OR a referenced Asset is not currently " + "installed in any Mount (install_asset first), 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 3b362ad1c..c0ef53b06 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -47,6 +47,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, InvalidAssemblyNameError, @@ -522,6 +523,7 @@ def register_equipment_routes(app: FastAPI) -> None: AssetNotAttachedToFixtureError, AssetAttachedToDifferentFixtureError, FixtureAssetNotAttachableError, + FixtureAssetNotInstalledError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) for pidinst_state_cls in ( diff --git a/apps/api/tests/integration/_equipment_helpers.py b/apps/api/tests/integration/_equipment_helpers.py index 401d9ca4a..6784ca4c3 100644 --- a/apps/api/tests/integration/_equipment_helpers.py +++ b/apps/api/tests/integration/_equipment_helpers.py @@ -2,7 +2,9 @@ The Mount/Frame PG integration test files share an identical `placement(parent_frame_id)` constructor and projection-drain wrapper; -hoisted here so per-file boilerplate stays short. +hoisted here so per-file boilerplate stays short. `seed_installed_asset` +is the install-then-register-fixture choreography helper used by every +Fixture-touching integration test after slice 3b locked INV-4. Per-file helpers that vary (the `_seed_*` family, scenario-specific fixtures) stay local to each test file. Only the genuinely-identical @@ -14,6 +16,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from uuid import UUID, uuid4 from cora.equipment._projections import register_equipment_projections from cora.equipment.aggregates._placement import ( @@ -21,14 +24,35 @@ ReferenceSurface, UnitSystem, ) +from cora.equipment.aggregates.asset import AssetLevel +from cora.equipment.features.activate_asset import ActivateAsset +from cora.equipment.features.activate_asset import bind as bind_activate_asset +from cora.equipment.features.install_asset import InstallAsset +from cora.equipment.features.install_asset import bind as bind_install_asset +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.register_asset import bind as bind_register_asset +from cora.equipment.features.register_frame import RegisterFrame +from cora.equipment.features.register_frame import bind as bind_register_frame +from cora.equipment.features.register_mount import RegisterMount +from cora.equipment.features.register_mount import bind as bind_register_mount from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps if TYPE_CHECKING: - from uuid import UUID + from datetime import datetime import asyncpg +# Helper-internal principal / correlation ids. They intentionally +# differ from per-test `_PRINCIPAL_ID` / `_CORRELATION_ID` constants: +# tests that need the seeded events to share a principal / correlation +# id with their own commands should run their own setup inline rather +# than call this helper. +_SEED_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_SEED_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + def placement(parent_frame_id: UUID) -> Placement: """A minimal Placement adequate for any slot in PG integration tests. @@ -68,4 +92,78 @@ async def drain_equipment_projections( await drain_projections(pool, registry, deadline_seconds=deadline_seconds) -__all__ = ["drain_equipment_projections", "placement"] +async def seed_installed_asset( + pool: asyncpg.Pool, + *, + now: datetime, + slot_code: str, + asset_name: str | None = None, + asset_level: AssetLevel = AssetLevel.DEVICE, +) -> tuple[UUID, UUID, UUID]: + """Register Frame + Mount + Asset, activate Asset, install Asset; drain. + + Returns (frame_id, mount_id, asset_id). Drains projections after + activate (so install_asset's preconditions pass) and after install + (so the asset_location projection row is visible to downstream + register_fixture calls that need to pass the orphan guard). + + Shared by every register_fixture / attach_asset_to_fixture / + detach_asset_from_fixture integration test that needs a real + Fixture-able Asset after slice 3b locked INV-4. Tests that need + fine-grained control over the individual ids (specific + FixedIdGenerator values) keep their own inline setup. + + The helper uses its own per-call `build_postgres_deps` so it + consumes ids from a fresh FixedIdGenerator each time; the caller's + outer deps's id pool only needs to budget for the work after + seeding (define_family, add_asset_family, define_assembly, + register_fixture, etc.). + """ + frame_id, mount_id, asset_id = uuid4(), uuid4(), uuid4() + asset_name = asset_name if asset_name is not None else f"specimen-{slot_code}" + + deps = build_postgres_deps(pool, now=now, ids=[frame_id, uuid4()]) + await bind_register_frame(deps)( + RegisterFrame(name=f"frame-{slot_code}", parent_frame_id=None, placement=None), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[mount_id, uuid4()]) + await bind_register_mount(deps)( + RegisterMount( + slot_code=slot_code, + parent_mount_id=None, + placement=placement(frame_id), + drawing=None, + ), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[asset_id, uuid4()]) + await bind_register_asset(deps)( + RegisterAsset(name=asset_name, level=asset_level, parent_id=uuid4()), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_activate_asset(deps)( + ActivateAsset(asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_install_asset(deps)( + InstallAsset(mount_id=mount_id, asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + return frame_id, mount_id, asset_id + + +__all__ = ["drain_equipment_projections", "placement", "seed_installed_asset"] diff --git a/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py b/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py index 4bc3ad49a..a8fe1be1e 100644 --- a/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py @@ -12,29 +12,26 @@ import pytest from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot -from cora.equipment.aggregates.asset import AssetLevel, load_asset +from cora.equipment.aggregates.asset import load_asset from cora.equipment.aggregates.fixture import SlotAssetBinding from cora.equipment.features import ( add_asset_family, attach_asset_to_fixture, define_assembly, define_family, - register_asset, register_fixture, ) from cora.equipment.features.add_asset_family import AddAssetFamily from cora.equipment.features.attach_asset_to_fixture import AttachAssetToFixture from cora.equipment.features.define_assembly import DefineAssembly from cora.equipment.features.define_family import DefineFamily -from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_fixture import RegisterFixture +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 3, 14, 0, 0, tzinfo=UTC) _FAMILY_ID = UUID("01900000-0000-7000-8000-00000054cb01") _FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb0e") -_ASSET_ID = UUID("01900000-0000-7000-8000-00000054cb02") -_ASSET_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb0f") _ADD_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb10") _ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054cb03") _ASSEMBLY_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb1e") @@ -43,21 +40,24 @@ _ATTACH_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb3e") _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000dd") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") @pytest.mark.integration async def test_attach_asset_to_fixture_sets_back_reference_in_postgres( db_pool: asyncpg.Pool, ) -> None: + # Pre-seed: Frame + Mount + Asset, with the Asset installed so it + # passes register_fixture's INV-4 install-required guard. + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-attach", asset_name="Cam-1" + ) + deps = build_postgres_deps( db_pool, now=_NOW, ids=[ _FAMILY_ID, _FAMILY_EVENT_ID, - _ASSET_ID, - _ASSET_EVENT_ID, _ADD_FAMILY_EVENT_ID, _ASSEMBLY_ID, _ASSEMBLY_DEFINED_EVENT_ID, @@ -72,11 +72,6 @@ async def test_attach_asset_to_fixture_sets_back_reference_in_postgres( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await register_asset.bind(deps)( - RegisterAsset(name="Cam-1", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), - principal_id=_PRINCIPAL_ID, - correlation_id=_CORRELATION_ID, - ) await add_asset_family.bind(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, @@ -118,8 +113,10 @@ async def test_attach_asset_to_fixture_sets_back_reference_in_postgres( # Asset stream now has Registered + AddFamily + AttachedToFixture. asset_events, asset_version = await deps.event_store.load("Asset", asset_id) - assert asset_version == 3 - attach_event = asset_events[2] + # Asset stream: Registered + Activated + AddFamily + AttachedToFixture + # (the Activated event comes from seed_installed_asset's setup). + assert asset_version == 4 + attach_event = asset_events[3] assert attach_event.event_type == "AssetAttachedToFixture" assert attach_event.event_id == _ATTACH_EVENT_ID assert attach_event.payload == { diff --git a/apps/api/tests/integration/test_decommission_asset_handler_postgres.py b/apps/api/tests/integration/test_decommission_asset_handler_postgres.py index 29f18d5fe..f6dad92b1 100644 --- a/apps/api/tests/integration/test_decommission_asset_handler_postgres.py +++ b/apps/api/tests/integration/test_decommission_asset_handler_postgres.py @@ -53,7 +53,11 @@ from cora.equipment.features.register_fixture import RegisterFixture from cora.equipment.features.register_frame import RegisterFrame from cora.equipment.features.register_mount import RegisterMount -from tests.integration._equipment_helpers import drain_equipment_projections, placement +from tests.integration._equipment_helpers import ( + drain_equipment_projections, + placement, + seed_installed_asset, +) from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) @@ -208,18 +212,23 @@ async def test_decommission_asset_rejects_when_still_bound_to_fixture( carries `fixture_id` cannot be decommissioned; operator must `detach_asset_from_fixture` first. Verifies the guard fires end-to-end against the real Asset stream fold. + + Setup chain after slice 3b's INV-4 lock: seed_installed_asset + (Frame + Mount + Asset + activate + install) -> define_family + + add_asset_family -> define_assembly + register_fixture + -> attach_asset_to_fixture -> decommission_asset (rejected by + AssetHasFixtureBindingError). """ - deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(10)]) + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-decom-fix", asset_name="Cam-1" + ) + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(8)]) family_id = await define_family.bind(deps)( DefineFamily(name="Camera", affordances=frozenset()), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await register_asset.bind(deps)( - RegisterAsset(name="Cam-1", level=AssetLevel.DEVICE, parent_id=uuid4()), - principal_id=_PRINCIPAL_ID, - correlation_id=_CORRELATION_ID, - ) await add_asset_family.bind(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, @@ -267,10 +276,12 @@ async def test_decommission_asset_rejects_when_still_bound_to_fixture( assert exc_info.value.asset_id == asset_id assert exc_info.value.fixture_id == fixture_id - # The Asset stream is unchanged after the rejection. + # The Asset stream is unchanged after the rejection (AssetActivated + # comes from seed_installed_asset). events, _ = await deps.event_store.load("Asset", asset_id) assert [e.event_type for e in events] == [ "AssetRegistered", + "AssetActivated", "AssetFamilyAdded", "AssetAttachedToFixture", ] diff --git a/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py b/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py index d01fcf578..7ebdf045c 100644 --- a/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py @@ -12,7 +12,7 @@ import pytest from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot -from cora.equipment.aggregates.asset import AssetLevel, load_asset +from cora.equipment.aggregates.asset import load_asset from cora.equipment.aggregates.fixture import SlotAssetBinding from cora.equipment.features import ( add_asset_family, @@ -20,7 +20,6 @@ define_assembly, define_family, detach_asset_from_fixture, - register_asset, register_fixture, ) from cora.equipment.features.add_asset_family import AddAssetFamily @@ -28,15 +27,13 @@ from cora.equipment.features.define_assembly import DefineAssembly from cora.equipment.features.define_family import DefineFamily from cora.equipment.features.detach_asset_from_fixture import DetachAssetFromFixture -from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_fixture import RegisterFixture +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 4, 14, 0, 0, tzinfo=UTC) _FAMILY_ID = UUID("01900000-0000-7000-8000-00000054cc01") _FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc0e") -_ASSET_ID = UUID("01900000-0000-7000-8000-00000054cc02") -_ASSET_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc0f") _ADD_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc10") _ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054cc03") _ASSEMBLY_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc1e") @@ -46,21 +43,23 @@ _DETACH_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc4e") _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000dd") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") @pytest.mark.integration async def test_detach_asset_from_fixture_clears_back_reference_in_postgres( db_pool: asyncpg.Pool, ) -> None: + # Pre-seed: Frame + Mount + Asset, installed (passes INV-4 at register_fixture). + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-detach", asset_name="Cam-1" + ) + deps = build_postgres_deps( db_pool, now=_NOW, ids=[ _FAMILY_ID, _FAMILY_EVENT_ID, - _ASSET_ID, - _ASSET_EVENT_ID, _ADD_FAMILY_EVENT_ID, _ASSEMBLY_ID, _ASSEMBLY_DEFINED_EVENT_ID, @@ -76,11 +75,6 @@ async def test_detach_asset_from_fixture_clears_back_reference_in_postgres( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await register_asset.bind(deps)( - RegisterAsset(name="Cam-1", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), - principal_id=_PRINCIPAL_ID, - correlation_id=_CORRELATION_ID, - ) await add_asset_family.bind(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, @@ -127,8 +121,10 @@ async def test_detach_asset_from_fixture_clears_back_reference_in_postgres( # Asset stream: Registered + AddFamily + AttachedToFixture + DetachedFromFixture. asset_events, asset_version = await deps.event_store.load("Asset", asset_id) - assert asset_version == 4 - detach_event = asset_events[3] + # Asset stream: Registered + Activated + AddFamily + AttachedToFixture + + # DetachedFromFixture (Activated comes from seed_installed_asset). + assert asset_version == 5 + detach_event = asset_events[4] assert detach_event.event_type == "AssetDetachedFromFixture" assert detach_event.event_id == _DETACH_EVENT_ID assert detach_event.payload == { diff --git a/apps/api/tests/integration/test_list_fixtures_handler_postgres.py b/apps/api/tests/integration/test_list_fixtures_handler_postgres.py index a674c76b3..ab2e34da8 100644 --- a/apps/api/tests/integration/test_list_fixtures_handler_postgres.py +++ b/apps/api/tests/integration/test_list_fixtures_handler_postgres.py @@ -15,7 +15,6 @@ from cora.equipment._projections import register_equipment_projections from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot -from cora.equipment.aggregates.asset import AssetLevel from cora.equipment.aggregates.fixture import SlotAssetBinding from cora.equipment.features.add_asset_family import AddAssetFamily from cora.equipment.features.add_asset_family import bind as bind_add_family @@ -25,18 +24,16 @@ from cora.equipment.features.define_family import bind as bind_define_family from cora.equipment.features.list_fixtures import ListFixtures from cora.equipment.features.list_fixtures import bind as bind_list_fixtures -from cora.equipment.features.register_asset import RegisterAsset -from cora.equipment.features.register_asset import bind as bind_register_asset from cora.equipment.features.register_fixture import RegisterFixture from cora.equipment.features.register_fixture import bind as bind_register_fixture from cora.infrastructure.kernel import Kernel from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 4, 14, 0, 0, tzinfo=UTC) _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000bb") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") def _build_deps(db_pool: asyncpg.Pool, ids: list[UUID]) -> Kernel: @@ -51,21 +48,29 @@ async def _drain(db_pool: asyncpg.Pool) -> None: async def _seed_fixture( deps: Kernel, + db_pool: asyncpg.Pool, *, asset_name: str = "Cam", assembly_name: str = "MCTOptics", ) -> tuple[UUID, UUID, UUID]: - """Returns (family_id, assembly_id, fixture_id).""" + """Returns (family_id, assembly_id, fixture_id). + + Pre-seeds Frame + Mount + Asset via the shared seed_installed_asset + helper (uuid4 ids; bypasses the outer FixedIdGenerator) so the + bound Asset is mount-installed before register_fixture (INV-4 lock + from slice 3b). The outer deps's id pool only needs to budget for + the four post-seed commands: define_family, add_asset_family, + define_assembly, register_fixture. + """ + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code=f"02-BM-{asset_name}", asset_name=asset_name + ) + family_id = await bind_define_family(deps)( DefineFamily(name=f"Camera-{asset_name}", affordances=frozenset()), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await bind_register_asset(deps)( - RegisterAsset(name=asset_name, level=AssetLevel.DEVICE, parent_id=_PARENT_ID), - principal_id=_PRINCIPAL_ID, - correlation_id=_CORRELATION_ID, - ) await bind_add_family(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, @@ -109,7 +114,7 @@ async def test_list_fixtures_returns_registered_fixture( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000054ee{i:02x}") for i in range(20)], ) - _, assembly_id, fixture_id = await _seed_fixture(deps) + _, assembly_id, fixture_id = await _seed_fixture(deps, db_pool) await _drain(db_pool) page = await bind_list_fixtures(deps)( ListFixtures(limit=20), @@ -132,8 +137,10 @@ async def test_list_fixtures_filter_by_assembly_id( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000054ef{i:02x}") for i in range(40)], ) - _, assembly_a, fixture_a = await _seed_fixture(deps, asset_name="CamA", assembly_name="A") - _, _, fixture_b = await _seed_fixture(deps, asset_name="CamB", assembly_name="B") + _, assembly_a, fixture_a = await _seed_fixture( + deps, db_pool, asset_name="CamA", assembly_name="A" + ) + _, _, fixture_b = await _seed_fixture(deps, db_pool, asset_name="CamB", assembly_name="B") await _drain(db_pool) page = await bind_list_fixtures(deps)( ListFixtures(limit=50, assembly_id=assembly_a), @@ -155,7 +162,7 @@ async def test_list_fixtures_filter_by_surface_id( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000055f1{i:02x}") for i in range(40)], ) - _, _, fixture_id = await _seed_fixture(deps) + _, _, fixture_id = await _seed_fixture(deps, db_pool) await _drain(db_pool) # Fetch all to discover the surface_id our test fixture landed on. all_page = await bind_list_fixtures(deps)( @@ -184,7 +191,7 @@ async def test_list_fixtures_filter_by_content_hash( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000054f0{i:02x}") for i in range(40)], ) - _, _, fixture_id = await _seed_fixture(deps) + _, _, fixture_id = await _seed_fixture(deps, db_pool) await _drain(db_pool) # Fetch all fixtures and pull the content_hash for our fixture. all_page = await bind_list_fixtures(deps)( 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 dd9048ff1..124375e57 100644 --- a/apps/api/tests/integration/test_register_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_register_fixture_handler_postgres.py @@ -15,6 +15,7 @@ from cora.equipment.aggregates.assembly import ( FixtureAssetNotAttachableError, + FixtureAssetNotInstalledError, SlotCardinality, SlotName, TemplateSlot, @@ -35,13 +36,12 @@ from cora.equipment.features.define_family import DefineFamily from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_fixture import RegisterFixture +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 3, 14, 0, 0, tzinfo=UTC) _FAMILY_ID = UUID("01900000-0000-7000-8000-00000054ca01") _FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca0e") -_ASSET_ID = UUID("01900000-0000-7000-8000-00000054ca02") -_ASSET_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca0f") _ADD_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca10") _ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054ca03") _ASSEMBLY_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca1e") @@ -49,21 +49,27 @@ _FIXTURE_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca2e") _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000dd") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") @pytest.mark.integration async def test_register_fixture_appends_genesis_event_to_postgres( db_pool: asyncpg.Pool, ) -> None: + # INV-4 (slice 3b): a Fixture's bindings must be installed in a + # Mount. Seed Frame + Mount + Asset and install the Asset BEFORE + # register_fixture; the helper bypasses this test's outer + # FixedIdGenerator so the pre-allocated id pool only needs to + # budget for the work after seeding. + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-fix-genesis", asset_name="Camera-1" + ) + deps = build_postgres_deps( db_pool, now=_NOW, ids=[ _FAMILY_ID, _FAMILY_EVENT_ID, - _ASSET_ID, - _ASSET_EVENT_ID, _ADD_FAMILY_EVENT_ID, _ASSEMBLY_ID, _ASSEMBLY_DEFINED_EVENT_ID, @@ -77,11 +83,6 @@ async def test_register_fixture_appends_genesis_event_to_postgres( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await register_asset.bind(deps)( - RegisterAsset(name="Camera-1", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), - principal_id=_PRINCIPAL_ID, - correlation_id=_CORRELATION_ID, - ) await add_asset_family.bind(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, @@ -135,8 +136,10 @@ async def test_register_fixture_appends_genesis_event_to_postgres( assert registered.metadata == {"command": "RegisterFixture"} assert registered.occurred_at == _NOW + # Asset stream: registered + activated + add_family (UNCHANGED by Fixture). + # The activated event comes from seed_installed_asset. asset_events, asset_version = await deps.event_store.load("Asset", asset_id) - assert asset_version == 2 # registered + add_family; UNCHANGED by Fixture + assert asset_version == 3 assembly_events, assembly_version = await deps.event_store.load("Assembly", assembly_id) assert assembly_version == 1 # defined only; UNCHANGED _ = asset_events @@ -209,3 +212,65 @@ async def test_register_fixture_rejects_decommissioned_asset_with_not_attachable ) assert exc_info.value.asset_id == asset_id assert exc_info.value.current_lifecycle == AssetLifecycle.DECOMMISSIONED.value + + +@pytest.mark.integration +async def test_register_fixture_rejects_orphan_asset_with_not_installed_error( + db_pool: asyncpg.Pool, +) -> None: + """INV-4 guard end-to-end: a registered Asset that is NOT installed + in any Mount cannot be bound into a Fixture. Operator must + install_asset first so the choreography becomes + install -> register_fixture, never register_fixture -> install. + + The asset_location projection is the back-lookup; the handler + loads it before the pure decider via a concurrent gather started + alongside the per-Asset load_asset gather and the assembly_state + task (three concurrent I/O streams). + """ + deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(8)]) + family_id = await define_family.bind(deps)( + DefineFamily(name="OrphanCamera", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + asset_id = await register_asset.bind(deps)( + RegisterAsset(name="OrphanCam-1", level=AssetLevel.DEVICE, parent_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assembly_id = await define_assembly.bind(deps)( + DefineAssembly( + name="OrphanRig", + 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(FixtureAssetNotInstalledError) 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 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 841e1bb8a..d7c2aab3e 100644 --- a/apps/api/tests/unit/equipment/test_register_fixture_decider.py +++ b/apps/api/tests/unit/equipment/test_register_fixture_decider.py @@ -14,6 +14,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, SlotCardinality, @@ -360,6 +361,109 @@ def test_decide_skips_lifecycle_guard_when_dict_is_empty() -> None: assert isinstance(events[0], FixtureRegistered) +@pytest.mark.unit +def test_decide_rejects_orphan_bound_asset_with_not_installed_error() -> None: + """Cross-aggregate guard (INV-4): every bound Asset must currently + be installed in some Mount. mount_id_by_asset_id with a None entry + says 'the projection has no row for this asset_id' + -> FixtureAssetNotInstalledError. Fires AFTER the lifecycle check + (Decommissioned is a more fundamental constraint) and BEFORE + cardinality / family-mismatch / parameter-overrides. + """ + 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.ACTIVE}, + mount_id_by_asset_id={asset_id: None}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)}, + ), + ) + with pytest.raises(FixtureAssetNotInstalledError) 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 + + +@pytest.mark.unit +def test_decide_skips_orphan_guard_when_mount_id_dict_is_none() -> None: + """Pool-None test path: handler ran without a database pool, so + mount_id_by_asset_id is None and the orphan guard is disabled. + Mirrors install_asset / decommission_asset projection-precondition + short-circuit pattern. + """ + 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.ACTIVE}, + mount_id_by_asset_id=None, + ) + 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_decommissioned_guard_fires_before_orphan_guard() -> None: + """Deterministic ordering when both guards would apply: the + Decommissioned-lifecycle check fires first because lifecycle is + the more fundamental constraint (an installed Decommissioned + Asset is rarer in practice but still wrong). + """ + 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}, + mount_id_by_asset_id={asset_id: None}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)}, + ), + ) + with pytest.raises(FixtureAssetNotAttachableError): + register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + + @pytest.mark.unit def test_decide_is_pure_same_inputs_yield_same_events() -> None: assembly_id = uuid4() From 71f82b8a31d84a5e59b10f81b5c28d07b57c8761 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 16:41:25 +0300 Subject: [PATCH 3/5] test(architecture): make_inmemory_kernel may not be called from production code Adds a parametrized AST fitness test that scans every git-tracked .py file for direct calls to `make_inmemory_kernel(...)` and asserts each call site is either the definition module `src/cora/infrastructure/deps.py` or under `tests/`. `make_inmemory_kernel` builds a Kernel with `pool=None`. Three cross-aggregate guards short-circuit on `pool=None` for the pool-less test path: install_asset (since 5g), decommission_asset (slice 1), register_fixture (slice 3a). If a future production module accidentally wires the in-memory primitive (alternate entrypoint, MCP-only deployment, lazy-pool refactor), every one of those guards silently disables with zero test failure. This is the "load-bearing assumption with no fitness" risk the slice 3a / 3b gate-review adversarial flagged, and the rule-of-three trigger the `feedback_pool_none_short_circuit_rule_of_three.md` memo captured. Test shape mirrors `test_kernel_construction_single_site.py`: textual `make_inmemory_kernel(` filter + AST `ast.Call` walk + a `_PRODUCTION_ALLOWLIST` with one entry (deps.py) + a bulk allowance for any path under `tests/` + a drift-catcher that the allowlist file exists. Mutation-checked: dropping a one-liner `make_inmemory_kernel(...)` call site at `src/cora/_pool_none_regression.py` makes the test fail at the exact line; removing it returns the suite to green. Co-Authored-By: Claude Opus 4.7 --- ...e_inmemory_kernel_production_call_sites.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 apps/api/tests/architecture/test_make_inmemory_kernel_production_call_sites.py diff --git a/apps/api/tests/architecture/test_make_inmemory_kernel_production_call_sites.py b/apps/api/tests/architecture/test_make_inmemory_kernel_production_call_sites.py new file mode 100644 index 000000000..57df32739 --- /dev/null +++ b/apps/api/tests/architecture/test_make_inmemory_kernel_production_call_sites.py @@ -0,0 +1,147 @@ +"""`make_inmemory_kernel(...)` may not be called from production code +outside its own definition module. + +`make_inmemory_kernel` builds a `Kernel` with `pool=None` (the +in-memory test-shaped variant). Every cross-aggregate guard that +short-circuits on `pool=None` (install_asset, decommission_asset, +register_fixture as of slice 3a) silently disables when the running +Kernel has no pool. The intent is: only production-test-environment +plumbing in `cora.infrastructure.deps` reaches for this primitive, +and only tests call it directly. + +If a future production module accidentally wires the in-memory +primitive (alternate entrypoint, MCP-only deployment, lazy-pool +refactor, etc.), every cross-aggregate guard that relies on the +pool=None short-circuit convention silently disables with zero test +failure. This fitness catches the regression at CI before such a +module can land. + +See the project-memory feedback note +`feedback_pool_none_short_circuit_rule_of_three.md` for the +rule-of-three trigger that motivated this test. + +## Allowed call sites + + - `src/cora/infrastructure/deps.py`: the definition module. The + `build_kernel` test-environment branch inside this file is the + single production caller; tach + this fitness keep the allowance + scoped. + - Any path under `tests/`: test wrappers (`tests/unit/_helpers.py`), + `test_deps.py`'s exercise of the override seams, agent seed + tests that want full programmatic control over the in-memory + kernel they hand to `Subscriber.attach`. + +Adding a new production callsite requires a deliberate allowlist +edit + a justification comment; the test fails loud otherwise. +""" + +import ast +from pathlib import Path + +import pytest + +from tests.architecture.conftest import tracked_python_files, tracked_test_files + +# tests/architecture/.py -> apps/api/ +_API_ROOT = Path(__file__).resolve().parents[2] + +# The one production file allowed to call `make_inmemory_kernel(...)`. +# Test-side files are allowed in bulk via the `_under_tests` check +# below; only explicit production allowances live in the set. +_PRODUCTION_ALLOWLIST: frozenset[str] = frozenset( + { + "src/cora/infrastructure/deps.py", + } +) + + +def _python_files() -> list[Path]: + """All git-tracked Python files under src/ and tests/. + + Enumerates from git's tracked-file set rather than `rglob` so a + half-staged refactor (an existing make_inmemory_kernel(...) call + edited-then-stashed, a new call site untracked) does not false-fail + under pre-commit. + """ + return sorted(tracked_python_files() | tracked_test_files()) + + +def _candidate_files() -> list[Path]: + """Files that mention `make_inmemory_kernel(` (cheap textual heuristic). + + The trailing `(` skips bare imports and docstring mentions; the AST + scan inside the parametrized test confirms each candidate has a + real call expression. + """ + return [p for p in _python_files() if "make_inmemory_kernel(" in p.read_text()] + + +def _qualified(p: Path) -> str: + return str(p.relative_to(_API_ROOT)) + + +def _under_tests(qualified: str) -> bool: + return qualified.startswith("tests/") + + +def _make_inmemory_kernel_call_lines(tree: ast.AST) -> list[int]: + """Find every Call node whose func is a bare `Name("make_inmemory_kernel")`. + + Catches `make_inmemory_kernel(...)` and any keyword-argument form. + Does not catch a namespaced `cora.infrastructure.deps.make_inmemory_kernel(...)` + (Attribute form); that form is unused today and would still fail + tach's import contract if added in a forbidden module. + """ + lines: list[int] = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "make_inmemory_kernel" + ): + lines.append(node.lineno) + return lines + + +@pytest.mark.architecture +@pytest.mark.parametrize("py_file", _candidate_files(), ids=_qualified) +def test_make_inmemory_kernel_called_only_in_allowed_sites(py_file: Path) -> None: + qualified = _qualified(py_file) + tree = ast.parse(py_file.read_text(), filename=str(py_file)) + lines = _make_inmemory_kernel_call_lines(tree) + + if qualified in _PRODUCTION_ALLOWLIST: + # Allowlisted production file MUST still call make_inmemory_kernel; + # otherwise the allowlist is stale and should be pruned. + assert lines, ( + f"{qualified} is allowlisted but no longer calls " + f"make_inmemory_kernel(). Prune the allowlist in " + f"{Path(__file__).name}." + ) + return + + if _under_tests(qualified): + # Test-side callers are allowed in bulk. Nothing to assert. + return + + assert not lines, ( + f"{qualified}: production code calls make_inmemory_kernel(...) " + f"at line(s) {sorted(lines)}. make_inmemory_kernel builds a " + f"Kernel with pool=None, which silently disables every " + f"cross-aggregate guard that short-circuits on pool=None " + f"(install_asset, decommission_asset, register_fixture, ...). " + f"Use make_postgres_kernel from cora.infrastructure.deps (or, " + f"in tests, the build_postgres_deps / build_deps wrappers). " + f"If this site genuinely needs the in-memory primitive, add it " + f"to _PRODUCTION_ALLOWLIST with a justification comment." + ) + + +@pytest.mark.architecture +def test_production_allowlist_files_exist() -> None: + """Drift catcher: if an allowlisted file moves or is renamed, fail loudly.""" + missing = [rel for rel in _PRODUCTION_ALLOWLIST if not (_API_ROOT / rel).is_file()] + assert not missing, ( + f"Allowlist references files that no longer exist: {missing}. " + f"Update the allowlist in {Path(__file__).name}." + ) From ec28a4a3f7d8348b33862baeb573185aae7cf17a Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 17:49:08 +0300 Subject: [PATCH 4/5] review(equipment): address PR #44 second-pass findings (concurrency + determinism + audit-tag strip + docs) Independent review of the INV-4 (orphan-bound-asset) guard surfaced five should-fix findings across correctness, test coverage, naming conventions, and docs. Four addressed here; the fifth (contract-tier pool=None bypass) is closed by the sibling commit adding a `make_inmemory_kernel` production-call-sites architecture fitness. Concurrency (handler.py): the three I/O streams that load the assembly, the per-Asset state, and per-Asset mount location used a hand-rolled `asyncio.create_task` + bare `asyncio.gather` future with sequential awaits. If any one of them raised, the others were never awaited and continued running in the background, surfacing as 'task exception never retrieved' warnings and holding pool connections after the handler had already returned an error. Rewrapped in `asyncio.TaskGroup` so a failure in any child cancels the siblings deterministically before the handler unwinds. Sorted-first determinism (test_register_fixture_decider*.py): the existing test for the orphan guard used a single asset_id, which made sorting trivially identity-preserving. A regression from `sorted(..., key=str)[0]` to `next(iter(...))` would have passed silently. Added a deterministic two-line test with three concrete UUIDs whose dict insertion order disagrees with their stringified sort order, plus a Hypothesis property test parameterized over frozenset[UUID] of 2-5 orphans that asserts the raised id equals `sorted(orphan_ids, key=str)[0]` regardless of iteration order. Naming hygiene (CLAUDE.md hard rule): stripped 14 occurrences of the audit-plan tags 'INV-4' and 'slice 3b' from source and test files. The durable WHY (cross-aggregate guard requiring referenced Assets to be currently installed in some Mount) was already stated alongside each tag, so the strip is mechanical and the prose survives. Module docs (docs/architecture/modules/equipment/index.md): the RegisterFixture error taxonomy listed only the pre-existing causes; both FixtureAssetNotAttachable (lifecycle guard, pre-existing miss from slice 3a) and FixtureAssetNotInstalled (install-required guard added by this slice) are now first-class entries. The register_fixture invariants prose at the top of the doc also gained the lifecycle + install preconditions that the decider now enforces. Tests after fix: 21/21 register_fixture decider tests pass (incl. 1 new example + 1 new PBT); 1726 equipment unit + integration tests pass; 16911 architecture fitnesses pass. Co-Authored-By: Claude Opus 4.7 --- .../equipment/aggregates/assembly/state.py | 7 ++- .../features/register_fixture/context.py | 5 +- .../features/register_fixture/decider.py | 14 ++--- .../features/register_fixture/handler.py | 54 +++++++++---------- .../tests/integration/_equipment_helpers.py | 6 ++- ...ttach_asset_to_fixture_handler_postgres.py | 2 +- ...est_decommission_asset_handler_postgres.py | 10 ++-- ...ach_asset_from_fixture_handler_postgres.py | 2 +- .../test_list_fixtures_handler_postgres.py | 8 +-- .../test_register_fixture_handler_postgres.py | 10 ++-- .../test_register_fixture_decider.py | 46 +++++++++++++++- ...est_register_fixture_decider_properties.py | 47 ++++++++++++++++ docs/architecture/modules/equipment/index.md | 4 +- 13 files changed, 152 insertions(+), 63 deletions(-) diff --git a/apps/api/src/cora/equipment/aggregates/assembly/state.py b/apps/api/src/cora/equipment/aggregates/assembly/state.py index baa238531..e9b48ecfc 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/state.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/state.py @@ -320,10 +320,9 @@ class FixtureAssetNotInstalledError(Exception): Fires when `proj_equipment_asset_location` carries no row for the Asset at register_fixture time, i.e., the Asset exists but has - not been physically racked. Closes INV-4 from the - Fixture+Mount+Asset alignment plan: a Fixture should materialize - only equipment that is already on the floor, so the - install-then-register-fixture choreography becomes the contract. + not been physically racked. A Fixture should materialize only + equipment that is already on the floor, so the + install-then-register-fixture choreography is the contract. Carries the sorted-first offending `asset_id` for deterministic error responses. 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 1902fe505..f10d8b085 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/context.py +++ b/apps/api/src/cora/equipment/features/register_fixture/context.py @@ -32,9 +32,8 @@ install_asset / decommission_asset projection-precondition short-circuit convention. When non-None and an entry maps to `None`, the decider raises `FixtureAssetNotInstalledError` carrying -the sorted-first orphan id, closing INV-4 from the -Fixture+Mount+Asset alignment plan: a Fixture should snapshot only -equipment already on the floor, so install-then-register becomes +the sorted-first orphan id: a Fixture should snapshot only +equipment already on the floor, so install-then-register is the contract. """ 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 f0fc950cf..8d7b44278 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/decider.py +++ b/apps/api/src/cora/equipment/features/register_fixture/decider.py @@ -26,8 +26,8 @@ - Every referenced Asset must currently be installed in some Mount (when the handler loaded asset_location info) -> FixtureAssetNotInstalledError carrying the sorted-first - orphan id. Closes INV-4 from the Fixture+Mount+Asset alignment - plan. + orphan id. A Fixture should materialize only equipment that is + already on the floor; install_asset is a hard precondition. - Each TemplateSlot's cardinality is satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -139,8 +139,8 @@ def decide( - Every referenced Asset must currently be installed in some Mount (when the handler loaded asset_location info) -> FixtureAssetNotInstalledError carrying the sorted-first - orphan id. Closes INV-4 from the Fixture+Mount+Asset - alignment plan. + orphan id. A Fixture should materialize only equipment that is + already on the floor; install_asset is a hard precondition. - Each TemplateSlot's cardinality must be satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -201,9 +201,9 @@ def decide( ) # Cross-aggregate guard: every referenced Asset must currently be - # installed in some Mount. Closes INV-4: a Fixture should snapshot - # only equipment already racked on the floor, so the - # install-then-register-fixture choreography becomes the contract. + # installed in some Mount. A Fixture should snapshot only equipment + # already racked on the floor, so the install-then-register-fixture + # choreography is the contract. # `mount_id_by_asset_id is None` means the handler ran without a # pool (test path) and the guard is disabled entirely. if context.mount_id_by_asset_id is not None: 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 16835e8d5..70ade56d8 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/handler.py +++ b/apps/api/src/cora/equipment/features/register_fixture/handler.py @@ -131,37 +131,35 @@ async def handler( now = deps.clock.now() asset_ids = _referenced_asset_ids(command) - # Split gather across the aggregate types so pyright keeps - # `assembly_state` narrowed to `Assembly | None` and each item - # in `assets` narrowed to `Asset | None`; a single gather across - # all three would widen everything to the union. - # - # All three I/O streams run concurrently: assembly_state_task - # and mount_ids_future are scheduled before we await the - # per-Asset gather. The mount_ids_future is only created when - # deps.pool is set (pool=None short-circuit preserves the - # pre-tightening permissive default for the pool-less test - # path; matches install_asset / decommission_asset shape). In + # All three I/O streams run concurrently inside a TaskGroup so + # that a failure in any one of them cancels the siblings before + # the handler returns. Without this discipline, a load_asset + # raise would leak the assembly_state and mount_ids work as + # "task exception never retrieved" warnings and tie up pool + # connections after the request had already errored out. + # Per-asset tasks keep each result narrowed (Asset | None, + # Assembly | None) for pyright; the asset_location stream is + # only scheduled when deps.pool is set (pool=None short-circuit + # preserves the permissive default for the pool-less test path; + # matches install_asset / decommission_asset shape). In # production deps.pool is always set, so every referenced - # asset_id gets an entry in the dict (mount_id when installed, - # None when orphan). - assembly_state_task = asyncio.create_task( - load_assembly(deps.event_store, command.assembly_id) - ) - # asyncio.gather(...) returns a Future that has already - # scheduled its children; storing it (without await) is the - # canonical way to start a gather concurrently with other work. - if deps.pool is not None: - pool = deps.pool - mount_ids_future: asyncio.Future[list[UUID | None]] | None = asyncio.gather( - *(load_asset_location(pool, aid) for aid in asset_ids) + # asset_id gets an entry in mount_id_by_asset_id (mount_id when + # installed, None when orphan). + pool = deps.pool + async with asyncio.TaskGroup() as tg: + assembly_task = tg.create_task(load_assembly(deps.event_store, command.assembly_id)) + asset_tasks = [tg.create_task(load_asset(deps.event_store, aid)) for aid in asset_ids] + mount_id_tasks: list[asyncio.Task[UUID | None]] | None = ( + [tg.create_task(load_asset_location(pool, aid)) for aid in asset_ids] + if pool is not None + else None ) - else: - mount_ids_future = None - assets = await asyncio.gather(*(load_asset(deps.event_store, aid) for aid in asset_ids)) - assembly_state = await assembly_state_task - mount_ids = await mount_ids_future if mount_ids_future is not None else None + assembly_state = assembly_task.result() + assets = [t.result() for t in asset_tasks] + mount_ids: list[UUID | None] | None = ( + [t.result() for t in mount_id_tasks] if mount_id_tasks is not None else None + ) family_ids_by_asset_id: dict[UUID, frozenset[UUID] | None] = { aid: (asset.family_ids if asset is not None else None) diff --git a/apps/api/tests/integration/_equipment_helpers.py b/apps/api/tests/integration/_equipment_helpers.py index 6784ca4c3..d6a9c3595 100644 --- a/apps/api/tests/integration/_equipment_helpers.py +++ b/apps/api/tests/integration/_equipment_helpers.py @@ -4,7 +4,8 @@ `placement(parent_frame_id)` constructor and projection-drain wrapper; hoisted here so per-file boilerplate stays short. `seed_installed_asset` is the install-then-register-fixture choreography helper used by every -Fixture-touching integration test after slice 3b locked INV-4. +Fixture-touching integration test (register_fixture requires every +bound Asset to be currently installed in some Mount). Per-file helpers that vary (the `_seed_*` family, scenario-specific fixtures) stay local to each test file. Only the genuinely-identical @@ -109,7 +110,8 @@ async def seed_installed_asset( Shared by every register_fixture / attach_asset_to_fixture / detach_asset_from_fixture integration test that needs a real - Fixture-able Asset after slice 3b locked INV-4. Tests that need + Fixture-able Asset (register_fixture rejects bindings whose Asset + is not currently installed in some Mount). Tests that need fine-grained control over the individual ids (specific FixedIdGenerator values) keep their own inline setup. diff --git a/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py b/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py index a8fe1be1e..1c6d4c695 100644 --- a/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py @@ -47,7 +47,7 @@ async def test_attach_asset_to_fixture_sets_back_reference_in_postgres( db_pool: asyncpg.Pool, ) -> None: # Pre-seed: Frame + Mount + Asset, with the Asset installed so it - # passes register_fixture's INV-4 install-required guard. + # passes register_fixture's install-required guard. _, _, asset_id = await seed_installed_asset( db_pool, now=_NOW, slot_code="02-BM-attach", asset_name="Cam-1" ) diff --git a/apps/api/tests/integration/test_decommission_asset_handler_postgres.py b/apps/api/tests/integration/test_decommission_asset_handler_postgres.py index f6dad92b1..e3d88add5 100644 --- a/apps/api/tests/integration/test_decommission_asset_handler_postgres.py +++ b/apps/api/tests/integration/test_decommission_asset_handler_postgres.py @@ -213,11 +213,11 @@ async def test_decommission_asset_rejects_when_still_bound_to_fixture( `detach_asset_from_fixture` first. Verifies the guard fires end-to-end against the real Asset stream fold. - Setup chain after slice 3b's INV-4 lock: seed_installed_asset - (Frame + Mount + Asset + activate + install) -> define_family - + add_asset_family -> define_assembly + register_fixture - -> attach_asset_to_fixture -> decommission_asset (rejected by - AssetHasFixtureBindingError). + Setup chain (register_fixture requires every bound Asset to be + currently installed): seed_installed_asset (Frame + Mount + Asset + + activate + install) -> define_family + add_asset_family + -> define_assembly + register_fixture -> attach_asset_to_fixture + -> decommission_asset (rejected by AssetHasFixtureBindingError). """ _, _, asset_id = await seed_installed_asset( db_pool, now=_NOW, slot_code="02-BM-decom-fix", asset_name="Cam-1" diff --git a/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py b/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py index 7ebdf045c..d34656310 100644 --- a/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py @@ -49,7 +49,7 @@ async def test_detach_asset_from_fixture_clears_back_reference_in_postgres( db_pool: asyncpg.Pool, ) -> None: - # Pre-seed: Frame + Mount + Asset, installed (passes INV-4 at register_fixture). + # Pre-seed: Frame + Mount + Asset, installed (passes register_fixture's install-required guard). _, _, asset_id = await seed_installed_asset( db_pool, now=_NOW, slot_code="02-BM-detach", asset_name="Cam-1" ) diff --git a/apps/api/tests/integration/test_list_fixtures_handler_postgres.py b/apps/api/tests/integration/test_list_fixtures_handler_postgres.py index ab2e34da8..2d657547e 100644 --- a/apps/api/tests/integration/test_list_fixtures_handler_postgres.py +++ b/apps/api/tests/integration/test_list_fixtures_handler_postgres.py @@ -57,10 +57,10 @@ async def _seed_fixture( Pre-seeds Frame + Mount + Asset via the shared seed_installed_asset helper (uuid4 ids; bypasses the outer FixedIdGenerator) so the - bound Asset is mount-installed before register_fixture (INV-4 lock - from slice 3b). The outer deps's id pool only needs to budget for - the four post-seed commands: define_family, add_asset_family, - define_assembly, register_fixture. + bound Asset is mount-installed before register_fixture (the + install-required guard). The outer deps's id pool only needs to + budget for the four post-seed commands: define_family, + add_asset_family, define_assembly, register_fixture. """ _, _, asset_id = await seed_installed_asset( db_pool, now=_NOW, slot_code=f"02-BM-{asset_name}", asset_name=asset_name 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 124375e57..664499d6e 100644 --- a/apps/api/tests/integration/test_register_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_register_fixture_handler_postgres.py @@ -55,8 +55,8 @@ async def test_register_fixture_appends_genesis_event_to_postgres( db_pool: asyncpg.Pool, ) -> None: - # INV-4 (slice 3b): a Fixture's bindings must be installed in a - # Mount. Seed Frame + Mount + Asset and install the Asset BEFORE + # A Fixture's bindings must be currently installed in some Mount. + # Seed Frame + Mount + Asset and install the Asset BEFORE # register_fixture; the helper bypasses this test's outer # FixedIdGenerator so the pre-allocated id pool only needs to # budget for the work after seeding. @@ -218,9 +218,9 @@ async def test_register_fixture_rejects_decommissioned_asset_with_not_attachable async def test_register_fixture_rejects_orphan_asset_with_not_installed_error( db_pool: asyncpg.Pool, ) -> None: - """INV-4 guard end-to-end: a registered Asset that is NOT installed - in any Mount cannot be bound into a Fixture. Operator must - install_asset first so the choreography becomes + """Install-required guard end-to-end: a registered Asset that is + NOT installed in any Mount cannot be bound into a Fixture. Operator + must install_asset first so the choreography becomes install -> register_fixture, never register_fixture -> install. The asset_location projection is the back-lookup; the handler 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 d7c2aab3e..830826094 100644 --- a/apps/api/tests/unit/equipment/test_register_fixture_decider.py +++ b/apps/api/tests/unit/equipment/test_register_fixture_decider.py @@ -363,7 +363,7 @@ def test_decide_skips_lifecycle_guard_when_dict_is_empty() -> None: @pytest.mark.unit def test_decide_rejects_orphan_bound_asset_with_not_installed_error() -> None: - """Cross-aggregate guard (INV-4): every bound Asset must currently + """Cross-aggregate guard: every bound Asset must currently be installed in some Mount. mount_id_by_asset_id with a None entry says 'the projection has no row for this asset_id' -> FixtureAssetNotInstalledError. Fires AFTER the lifecycle check @@ -397,6 +397,50 @@ def test_decide_rejects_orphan_bound_asset_with_not_installed_error() -> None: assert exc_info.value.asset_id == asset_id +@pytest.mark.unit +def test_decide_orphan_error_carries_sorted_first_when_multiple_orphans() -> None: + """With multiple orphan bindings, FixtureAssetNotInstalledError must + carry the sorted-by-str-of-UUID first id (the deterministic + invariant). Uses concrete UUIDs whose dict insertion order does NOT + match their stringified sort order, so a regression to e.g. + `next(iter(...))` would catch the wrong id and fail. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.ZERO_OR_MORE, + ) + # Concrete ids chosen so the dict's first-inserted entry sorts LAST + # by str(UUID). Sorted-by-str order: 11..., 22..., 33.... + asset_late = UUID("33333333-3333-7333-9333-333333333333") + asset_mid = UUID("22222222-2222-7222-9222-222222222222") + asset_early = UUID("11111111-1111-7111-9111-111111111111") + asset_ids = (asset_late, asset_mid, asset_early) + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={aid: frozenset({family_id}) for aid in asset_ids}, + lifecycle_by_asset_id={aid: AssetLifecycle.ACTIVE for aid in asset_ids}, + mount_id_by_asset_id={aid: None for aid in asset_ids}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name="camera", asset_id=aid) for aid in asset_ids + ), + ) + with pytest.raises(FixtureAssetNotInstalledError) as exc_info: + register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.asset_id == asset_early + + @pytest.mark.unit def test_decide_skips_orphan_guard_when_mount_id_dict_is_none() -> None: """Pool-None test path: handler ran without a database pool, so diff --git a/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py b/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py index 32bb7e913..da310e520 100644 --- a/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py @@ -13,10 +13,12 @@ AssemblyName, AssemblyNotFoundError, AssemblyStatus, + FixtureAssetNotInstalledError, SlotCardinality, SlotName, TemplateSlot, ) +from cora.equipment.aggregates.asset import AssetLifecycle from cora.equipment.aggregates.fixture import ( FixtureRegistered, SlotAssetBinding, @@ -125,6 +127,51 @@ def test_decide_zero_or_more_slot_accepts_any_binding_count( assert isinstance(events[0], FixtureRegistered) +@pytest.mark.unit +@given( + orphan_ids=st.lists(st.uuids(), min_size=2, max_size=5, unique=True).map(frozenset), + now=aware_datetimes(), +) +def test_decide_orphan_error_always_carries_sorted_first_id( + orphan_ids: frozenset[UUID], + now: datetime, +) -> None: + """When several bindings are orphans, the raised + `FixtureAssetNotInstalledError.asset_id` is the smallest by + `str(UUID)` regardless of dict/frozenset iteration order. A + regression to `next(iter(orphans))` would fail this property + because frozenset iteration is not stringified-order. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.ZERO_OR_MORE, + ) + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={aid: frozenset({family_id}) for aid in orphan_ids}, + lifecycle_by_asset_id={aid: AssetLifecycle.ACTIVE for aid in orphan_ids}, + mount_id_by_asset_id={aid: None for aid in orphan_ids}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name="camera", asset_id=aid) for aid in orphan_ids + ), + ) + with pytest.raises(FixtureAssetNotInstalledError) as exc_info: + register_fixture.decide( + state=None, + command=command, + context=context, + now=now, + new_id=uuid4(), + ) + assert exc_info.value.asset_id == sorted(orphan_ids, key=str)[0] + + @pytest.mark.unit @given(now=aware_datetimes(), new_id=st.uuids()) def test_decide_is_pure_same_inputs_yield_same_events( diff --git a/docs/architecture/modules/equipment/index.md b/docs/architecture/modules/equipment/index.md index 37c886edd..a109b4506 100644 --- a/docs/architecture/modules/equipment/index.md +++ b/docs/architecture/modules/equipment/index.md @@ -226,7 +226,7 @@ stateDiagram-v2 : `presents_as_family_id` references a Family that exists in the Equipment module (verified at decide time via a cross-aggregate load). Every `TemplateWire.source_slot` and `TemplateWire.target_slot` matches a `TemplateSlot.slot_name` declared on the same Assembly. The decider captures the canonical `content_hash` from the new structural content; re-versioning with identical content yields the same hash by design. `register_fixture` -: Every required slot in the Assembly is covered by exactly one `SlotAssetBinding` whose `asset_id` references an Asset whose `family_ids` includes the slot's required Family. The Assembly is in `Defined` or `Versioned` (not `Deprecated`). The Fixture's `surface_id` is read from the caller's authenticated Trust Surface; the Fixture is bound to that Surface for authorization scoping. +: Every required slot in the Assembly is covered by exactly one `SlotAssetBinding` whose `asset_id` references an Asset whose `family_ids` includes the slot's required Family. The Assembly is in `Defined` or `Versioned` (not `Deprecated`). Every bound Asset must not be `Decommissioned` (a terminal lifecycle disallows attachment) and must currently be installed in some Mount (so the choreography is `install_asset` -> `register_fixture`, never the reverse). The Fixture's `surface_id` is read from the caller's authenticated Trust Surface; the Fixture is bound to that Surface for authorization scoping. `add_model_family` / `remove_model_family` : The Model is not Deprecated. The Family id is not already present (or is present, for remove). The referenced Family must exist in the Family event stream (verified at decide time via load). @@ -459,7 +459,7 @@ The seven aggregates expose fifty slices end to end. : `AssemblyAlreadyExists` (define only), `AssemblyNotFound`, `AssemblyCannotVersion` / `AssemblyCannotDeprecate`, `FamilyNotFoundForAssembly` (define / version, when `presents_as_family_id` or a slot's `required_family_ids` references a missing Family), `WireReferencesUnknownSlot` (define / version, when a TemplateWire endpoint cites a slot the same Assembly does not declare), `Unauthorized` `RegisterFixture` -: `AssemblyNotFound`, `AssemblyCannotInstantiate` (Assembly is Deprecated), `FixtureAlreadyExists`, `FixtureAssetNotFound` (a binding references a missing Asset), `FixtureAssetFamilyMismatch` (a binding's Asset does not include the slot's required Family in its `family_ids`), `FixtureMappingIncomplete` (a required slot has no covering binding), `Unauthorized` +: `AssemblyNotFound`, `AssemblyCannotInstantiate` (Assembly is Deprecated), `FixtureAlreadyExists`, `FixtureAssetNotFound` (a binding references a missing Asset), `FixtureAssetNotAttachable` (a binding's Asset is Decommissioned), `FixtureAssetNotInstalled` (a binding's Asset is not currently installed in any Mount), `FixtureAssetFamilyMismatch` (a binding's Asset does not include the slot's required Family in its `family_ids`), `FixtureMappingIncomplete` (a required slot has no covering binding), `Unauthorized` `GetFamily` / `GetModel` / `GetAsset` / `GetAssetIntegrationView` / `GetFixture` : `NotFound` From 71bb9407508c385002233e9c20edf8af4dcbf18c Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 20:03:32 +0300 Subject: [PATCH 5/5] test(equipment): install bound Assets in PIDINST fixture tests for INV-4 The three Fixture-PIDINST integration test files landed via PRs #43 + #45 after this branch was created, so the install-then-register choreography this branch enforces was not applied to their fixture setups. Their tests register an Asset and then immediately call register_fixture binding that Asset, which trips the new INV-4 orphan guard (FixtureAssetNotInstalledError) added in this branch. Adds a sibling helper `install_existing_asset_into_fresh_mount` in `tests/integration/_equipment_helpers.py` that activates an already-registered Asset, registers a fresh Frame + Mount under uuid4 ids, and installs the Asset. Use this when the test already needed to register the Asset itself (to bind a model_id or seed an owner) and now needs to satisfy INV-4 before calling register_fixture. Companion to the existing seed_installed_asset helper that handles the no-model / no-owner case from scratch. Each of the three PIDINST test files calls the new helper right after its register_asset step. Slot codes use the asset's uuid as a suffix to keep them unique across helper calls within the same test run. All 10 PIDINST tests now pass locally. Pre-this-fix the CI run on this branch was failing 6 of them with FixtureAssetNotInstalledError. Co-Authored-By: Claude Opus 4.7 --- .../tests/integration/_equipment_helpers.py | 68 ++++++++++++++++++- ...st_get_fixture_pidinst_handler_postgres.py | 13 +++- .../test_get_fixture_pidinst_route.py | 10 ++- .../test_get_fixture_pidinst_tool.py | 10 ++- 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/apps/api/tests/integration/_equipment_helpers.py b/apps/api/tests/integration/_equipment_helpers.py index d6a9c3595..5e6df977a 100644 --- a/apps/api/tests/integration/_equipment_helpers.py +++ b/apps/api/tests/integration/_equipment_helpers.py @@ -168,4 +168,70 @@ async def seed_installed_asset( return frame_id, mount_id, asset_id -__all__ = ["drain_equipment_projections", "placement", "seed_installed_asset"] +async def install_existing_asset_into_fresh_mount( + pool: asyncpg.Pool, + *, + now: datetime, + asset_id: UUID, + slot_code: str, +) -> tuple[UUID, UUID]: + """Activate the given pre-registered Asset, then register a fresh + Frame + Mount and install the Asset; drain. Returns (frame_id, + mount_id). + + Companion to `seed_installed_asset`: use this when the test + already needed to register the Asset itself (e.g., to bind a + model_id or seed an owner) and now needs to satisfy the INV-4 + install-required guard before calling `register_fixture`. + + Activates the Asset because `install_asset` rejects non-Active + Assets (`AssetNotInstallableError`). Tests that have already + activated the Asset should not call this helper; activate is a + strict-not-idempotent transition. + """ + frame_id, mount_id = uuid4(), uuid4() + + deps = build_postgres_deps(pool, now=now, ids=[frame_id, uuid4()]) + await bind_register_frame(deps)( + RegisterFrame(name=f"frame-{slot_code}", parent_frame_id=None, placement=None), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[mount_id, uuid4()]) + await bind_register_mount(deps)( + RegisterMount( + slot_code=slot_code, + parent_mount_id=None, + placement=placement(frame_id), + drawing=None, + ), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_activate_asset(deps)( + ActivateAsset(asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_install_asset(deps)( + InstallAsset(mount_id=mount_id, asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + return frame_id, mount_id + + +__all__ = [ + "drain_equipment_projections", + "install_existing_asset_into_fresh_mount", + "placement", + "seed_installed_asset", +] diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py index 5c2cd10a2..ba12501f5 100644 --- a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py @@ -67,7 +67,10 @@ from cora.equipment.features.register_fixture import RegisterFixture from cora.infrastructure.config import Settings from cora.infrastructure.kernel import Kernel -from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps pytestmark = pytest.mark.timeout(60, method="thread") @@ -198,6 +201,14 @@ async def _seed_asset_with_owner_and_model( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call so the + # orphan guard does not fire. slot_code suffix uses the asset's + # own uuid to keep slot_code unique across helper calls within a + # single test run. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_id}" + ) await _add_family_to_asset(db_pool, asset_id=asset_id, family_id=family_id) owner_event_id = uuid4() owner_deps = _build_deps(db_pool, ids=[owner_event_id]) diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py index 6edf66d9e..da86e8acc 100644 --- a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py @@ -75,7 +75,10 @@ from cora.equipment.wire import wire_equipment from cora.infrastructure.config import Settings from cora.infrastructure.kernel import Kernel -from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps pytestmark = pytest.mark.timeout(60, method="thread") @@ -183,6 +186,11 @@ async def _seed_asset( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_id}" + ) await _add_family_to_asset(db_pool, asset_id=asset_id, family_id=family_id) if owner is not None: owner_event_id = uuid4() diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py index 82ebc5e34..3963a68c0 100644 --- a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py @@ -63,7 +63,10 @@ from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_fixture import RegisterFixture from cora.infrastructure.kernel import Kernel -from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps pytestmark = pytest.mark.timeout(60, method="thread") @@ -161,6 +164,11 @@ async def _seed_minted_fixture(db_pool: asyncpg.Pool) -> UUID: principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_id}" + ) await add_asset_family.bind(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID,