Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cora/equipment/aggregates/assembly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
AssemblyStatus,
FamilyNotFoundForAssemblyError,
FixtureAssetFamilyMismatchError,
FixtureAssetNotAttachableError,
FixtureAssetNotFoundError,
FixtureMappingIncompleteError,
FixtureParameterOverridesInvalidError,
Expand Down Expand Up @@ -81,6 +82,7 @@
"AssemblyVersioned",
"FamilyNotFoundForAssemblyError",
"FixtureAssetFamilyMismatchError",
"FixtureAssetNotAttachableError",
"FixtureAssetNotFoundError",
"FixtureMappingIncompleteError",
"FixtureParameterOverridesInvalidError",
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/cora/equipment/aggregates/assembly/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 16 additions & 2 deletions apps/api/src/cora/equipment/features/register_fixture/context.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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]
)
30 changes: 30 additions & 0 deletions apps/api/src/cora/equipment/features/register_fixture/decider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cora/equipment/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
AssemblyNotFoundError,
FamilyNotFoundForAssemblyError,
FixtureAssetFamilyMismatchError,
FixtureAssetNotAttachableError,
FixtureAssetNotFoundError,
FixtureMappingIncompleteError,
FixtureParameterOverridesInvalidError,
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading