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
23 changes: 23 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7714,6 +7714,19 @@
"$ref": "#/components/schemas/AssetLevel",
"description": "Hierarchical level. One of: Enterprise (root, requires null parent_id), Site, Area, Unit, Assembly, Device."
},
"model_id": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"description": "Optional reference to the Model catalog entry this Asset is an instance of (Family -> Model -> Assembly -> Asset ladder). Set ONCE at registration; rebind path is decommission + re-register. The handler verifies the Model stream exists before invoking the decider (404 if missing); no subset check at register time because the genesis Asset families set is empty.",
"title": "Model Id"
},
"name": {
"description": "Display name for the new asset.",
"maxLength": 200,
Expand Down Expand Up @@ -13434,6 +13447,16 @@
},
"description": "Authorize port denied the command."
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "model_id was supplied but the referenced Model stream does not exist (ModelNotFoundError)."
},
"422": {
"description": "Request body failed schema validation (unknown level, missing fields, malformed UUID) OR Idempotency-Key was reused with a different request body."
}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cora/equipment/aggregates/asset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
AssetCondition,
AssetLevel,
AssetLifecycle,
AssetModelMismatchError,
AssetName,
AssetNotFoundError,
AssetPort,
Expand Down Expand Up @@ -83,6 +84,7 @@
"AssetLifecycle",
"AssetMaintenanceEntered",
"AssetMaintenanceExited",
"AssetModelMismatchError",
"AssetName",
"AssetNotFoundError",
"AssetPort",
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/cora/equipment/aggregates/asset/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ class AssetRegistered:
the engineering build-to spec for the physical specimen. Defaults
to None so legacy AssetRegistered streams (no drawing in the
payload) fold cleanly via the additive-payload pattern.

`model_id` is an optional reference to the Model catalog entry
this Asset is an instance of (Family -> Model -> Assembly ->
Asset ladder). Set at registration per the model-binding design
memo (Lock A); rebind path is decommission + re-register.
Defaults to None so legacy AssetRegistered streams (no model_id
in the payload) fold cleanly via the additive-payload pattern;
`to_payload` uses the omit-when-None convention (key absent
rather than serialized as JSON null) to mirror the `drawing`
precedent.
"""

asset_id: UUID
Expand All @@ -86,6 +96,7 @@ class AssetRegistered:
parent_id: UUID | None
occurred_at: datetime
drawing: Drawing | None = None
model_id: UUID | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -355,6 +366,7 @@ def to_payload(event: AssetEvent) -> dict[str, Any]:
parent_id=parent_id,
occurred_at=occurred_at,
drawing=drawing,
model_id=model_id,
):
payload: dict[str, Any] = {
"asset_id": str(asset_id),
Expand All @@ -369,6 +381,8 @@ def to_payload(event: AssetEvent) -> dict[str, Any]:
"number": drawing.number,
"revision": drawing.revision,
}
if model_id is not None:
payload["model_id"] = str(model_id)
return payload
case AssetActivated(asset_id=asset_id, occurred_at=occurred_at):
return {
Expand Down Expand Up @@ -495,13 +509,16 @@ def _build_registered() -> AssetRegistered:
if raw_drawing is not None
else None
)
raw_model_id = payload.get("model_id")
model_id = UUID(raw_model_id) if raw_model_id is not None else None
return AssetRegistered(
asset_id=UUID(payload["asset_id"]),
name=payload["name"],
level=payload["level"],
parent_id=UUID(raw_parent) if raw_parent is not None else None,
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
drawing=drawing,
model_id=model_id,
)

return deserialize_or_raise("AssetRegistered", _build_registered)
Expand Down
42 changes: 31 additions & 11 deletions apps/api/src/cora/equipment/aggregates/asset/evolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,24 @@

**Critical invariant**: every transition arm MUST carry
`family_ids` AND `condition` AND `settings` AND `ports` AND
`drawing` through from prior state. Constructing `Asset(id=...,
name=..., level=..., parent_id=..., lifecycle=...)` without
explicitly passing them would silently WIPE the fields to their
defaults (empty frozenset / NOMINAL / empty dict / empty frozenset
/ None). `family_ids` was added with a default solely for additive-
state forward compatibility on genesis events; `condition`,
`settings`, `ports`, and `drawing` followed the same additive
pattern. Transition arms must explicitly carry all five. Pinned
by `test_evolve_<transition>_preserves_capabilities`,
`drawing` AND `model_id` through from prior state. Constructing
`Asset(id=..., name=..., level=..., parent_id=..., lifecycle=...)`
without explicitly passing them would silently WIPE the fields to
their defaults (empty frozenset / NOMINAL / empty dict / empty
frozenset / None / None). `family_ids` was added with a default
solely for additive-state forward compatibility on genesis events;
`condition`, `settings`, `ports`, `drawing`, and `model_id`
followed the same additive pattern. Transition arms must
explicitly carry all six. `model_id` is set ONCE at registration
per the model-binding design memo (Lock A) and never changes
post-genesis, but transition arms still must carry it forward
like any other Asset field. Pinned by
`test_evolve_<transition>_preserves_capabilities`,
`test_evolve_<transition>_preserves_condition`,
`test_evolve_<transition>_preserves_settings`,
`test_evolve_<transition>_preserves_ports`, and
`test_evolve_<transition>_preserves_drawing` for each transition.
`test_evolve_<transition>_preserves_ports`,
`test_evolve_<transition>_preserves_drawing`, and
`test_evolve_<transition>_preserves_model_id` for each transition.

Transition events applied to empty state raise ValueError: they
can never appear before `AssetRegistered` in a well-formed stream.
Expand Down Expand Up @@ -99,6 +104,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
level=level,
parent_id=parent_id,
drawing=drawing,
model_id=model_id,
):
_ = state # AssetRegistered is the genesis event; prior state ignored
return Asset(
Expand All @@ -108,6 +114,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
parent_id=parent_id,
lifecycle=AssetLifecycle.COMMISSIONED,
drawing=drawing,
model_id=model_id,
# family_ids defaults to empty frozenset; condition
# defaults to NOMINAL. Additive-state pattern: both
# default-via-state so legacy streams without these
Expand All @@ -126,6 +133,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetDecommissioned():
prior = require_state(state, "AssetDecommissioned")
Expand All @@ -140,6 +148,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetRelocated(to_parent_id=to_parent_id):
# Hierarchy mutation: only parent_id changes; lifecycle / level
Expand All @@ -159,6 +168,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetMaintenanceEntered():
prior = require_state(state, "AssetMaintenanceEntered")
Expand All @@ -173,6 +183,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetMaintenanceExited():
prior = require_state(state, "AssetMaintenanceExited")
Expand All @@ -187,6 +198,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetFamilyAdded(family_id=family_id):
# Family mutation: only `family_ids` changes; everything
Expand All @@ -206,6 +218,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetFamilyRemoved(family_id=family_id):
# Mirror of AssetFamilyAdded. Frozenset difference is a
Expand All @@ -226,6 +239,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetDegraded():
# Condition mutation: only `condition` changes; everything
Expand All @@ -245,6 +259,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetFaulted():
prior = require_state(state, "AssetFaulted")
Expand All @@ -259,6 +274,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetRestored():
prior = require_state(state, "AssetRestored")
Expand All @@ -273,6 +289,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetSettingsUpdated(settings=settings):
# Settings mutation: only `settings` changes. Event payload
Expand All @@ -294,6 +311,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=dict(settings),
ports=prior.ports,
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetPortAdded(
port_name=port_name,
Expand Down Expand Up @@ -323,6 +341,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=prior.ports | {new_port},
drawing=prior.drawing,
model_id=prior.model_id,
)
case AssetPortRemoved(port_name=port_name):
# Mirror of AssetPortAdded. Removes the port whose `name`
Expand All @@ -345,6 +364,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset:
settings=prior.settings,
ports=frozenset(p for p in prior.ports if p.name != port_name),
drawing=prior.drawing,
model_id=prior.model_id,
)
case _: # pragma: no cover # exhaustiveness guard
assert_never(event)
Expand Down
50 changes: 50 additions & 0 deletions apps/api/src/cora/equipment/aggregates/asset/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,45 @@ def __init__(self, asset_id: UUID, port_name: str, reason: str) -> None:
self.reason = reason


class AssetModelMismatchError(Exception):
"""The Asset's families set does not satisfy the bound Model's declared families.

Cross-BC subset invariant: when an Asset is bound to a Model via
`model_id`, the Asset's `family_ids` must be a superset of the
Model's `declared_families`. The check fires at `add_asset_family`
against a freshly loaded Model snapshot; if the post-add families
set is not a superset of `declared_families`, this error is raised
and no event is emitted.

The message lists both sets verbatim so operators reading the API
error response see immediately which Families are missing on the
Asset (or, in the cascade case, which Families the Model has added
since the binding). Mapped to HTTP 409 via the
`cannot_transition_cls` tuple in `routes.py`.

Per the model-binding design memo (Lock E), this class lives in
the Asset BC per the per-BC error-class convention; the Model-side
equivalent does not exist because the binding is one-directional.
"""

def __init__(
self,
asset_id: UUID,
model_id: UUID,
declared_families: frozenset[UUID],
asset_family_ids: frozenset[UUID],
) -> None:
super().__init__(
f"Asset {asset_id} bound to Model {model_id} which declares families "
f"{sorted(declared_families)}, but Asset families would be "
f"{sorted(asset_family_ids)} after this transition"
)
self.asset_id = asset_id
self.model_id = model_id
self.declared_families = declared_families
self.asset_family_ids = asset_family_ids


class AssetCannotRelocateError(Exception):
"""Attempted to relocate an asset under disqualifying conditions.

Expand Down Expand Up @@ -553,6 +592,16 @@ class Asset:
AssetRegistered streams without the drawing field fold cleanly
via the additive-state pattern.

`model_id`: optional reference to the Model catalog entry this
Asset is an instance of (Family -> Model -> Assembly -> Asset
ladder). Set ONCE at `register_asset` time per the model-binding
design memo (Lock A); rebind path is decommission + re-register.
Carries the cross-BC subset invariant
`Model.declared_families ⊆ Asset.family_ids`, enforced at
`add_asset_family` against a freshly loaded Model snapshot.
Defaults to None; legacy AssetRegistered streams without the
model_id field fold cleanly via the additive-state pattern.

Future additive facets: `owner`, `persistent_id`. The state-
level fields land with defaults for the same forward-
compatibility reason.
Expand All @@ -579,3 +628,4 @@ class Asset:
# Same parametrized-callable trick as family_ids.
ports: frozenset[AssetPort] = field(default_factory=frozenset[AssetPort])
drawing: Drawing | None = None
model_id: UUID | None = None
24 changes: 24 additions & 0 deletions apps/api/src/cora/equipment/features/add_asset_family/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@

from cora.equipment.aggregates.asset import (
AssetEvent,
AssetModelMismatchError,
event_type_name,
fold,
from_stored,
to_payload,
)
from cora.equipment.aggregates.model import ModelNotFoundError, load_model
from cora.equipment.errors import UnauthorizedError
from cora.equipment.features.add_asset_family.command import AddAssetFamily
from cora.equipment.features.add_asset_family.decider import decide
Expand Down Expand Up @@ -101,6 +103,28 @@ async def handler(
history: list[AssetEvent] = [from_stored(s) for s in stored]
state = fold(history)

# Cross-BC subset gate: when the Asset is bound to a Model
# via model_id, the post-add family set must be a superset
# of the Model's declared families. Lives in the handler
# (not the decider) because the Model snapshot is loaded
# at decide time from a stream the Asset aggregate does not
# own. Same precedent as `update_asset_settings` loading
# Family streams to validate against schemas. Single-stream
# write discipline preserved: load Model read-only, append
# only to the Asset stream.
if state is not None and state.model_id is not None:
model = await load_model(deps.event_store, state.model_id)
if model is None:
raise ModelNotFoundError(state.model_id)
post_add_family_ids = state.family_ids | {command.family_id}
if not model.declared_families.issubset(post_add_family_ids):
raise AssetModelMismatchError(
asset_id=state.id,
model_id=state.model_id,
declared_families=model.declared_families,
asset_family_ids=post_add_family_ids,
)

domain_events = decide(state=state, command=command, now=now)

new_events = [
Expand Down
Loading
Loading