From 77c26af6f2302f1ef8f26cf6210dc3c258bab5a1 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 13:52:15 +0300 Subject: [PATCH 01/11] feat(equipment): add Model aggregate core (state + events + evolver) Model is the Equipment BC's vendor-catalog tier between Family (device-class kind) and Asset (deployed instance). A Model pins a Manufacturer (name plus optional ROR/GRID/ISNI identifier) to a part_number (vendor SKU, case-sensitive) and a declared_families frozenset (at least one Family id the catalog entry satisfies). This commit lands the aggregate core only: state.py (VOs, ModelStatus enum, error classes, Model dataclass), events.py (5 events with payload round-trip), evolver.py (template-shaped FSM mirroring Family), and routes.py exception-handler registration for all 14 Model error classes. No slices, no projection, no API surface yet, those land per slice in follow-up commits per the design memo project_model_aggregate_design.md. FSM mirrors Family verbatim: - Defined to Versioned (multi-source from Defined or Versioned) - (Defined | Versioned) to Deprecated Five events: - ModelDefined / ModelVersioned / ModelDeprecated - ModelFamilyAdded / ModelFamilyRemoved (targeted-mutation; matches the operational pattern "vendor shipped firmware update, one extra Family declared" rather than wholesale re-author) Manufacturer is a small VO with a pairing invariant (identifier and identifier_type both set or both None). ManufacturerIdentifierType is a closed StrEnum (ROR | GRID | ISNI) per the Affordance closed-vocabulary precedent. declared_families is REQUIRED with cardinality at least one; empty rejected at the API boundary in the define_model slice. The cross-BC subset invariant (Model.declared_families subset-of Asset.families) is enforced by the Asset BC at register_asset and add_asset_family time, not inside the Model aggregate. The PIDINST property 6 (Manufacturer, 1-n Mandatory) mandate is an instance-tier obligation per PIDINST v1.0 spec p.1 ("instrument instances ... as opposed to instrument types or models") and transfers to Asset.alternate_identifiers in a future slice, NOT to Model.manufacturer at the catalog tier. Model.manufacturer is required because catalog-tier traditions (CMMS Equipment Type per ISO 14224, AAS Type-AAS DigitalNameplate IDTA 02006, OPC UA vendor profile, ECLASS-augmented Property) all treat manufacturer as required at the catalog tier. Tests: - 22 VO + dataclass + enum tests (test_model.py) - 9 event payload round-trip tests (test_model_events.py) - 10 FSM evolution tests (test_model_evolver.py) - All 41 pass; 923 equipment unit tests + 14112 architecture tests pass overall; ruff clean; pyright 0/0/0. Note on HTTP status mapping: validation errors follow the existing equipment BC convention (Invalid* to 400) rather than the design memo's 422 spec. The memo will be reconciled if a pilot needs 422 specifically; the existing convention takes precedence for the first ship per established Equipment BC handlers. Co-Authored-By: Claude Opus 4.7 --- .../equipment/aggregates/model/__init__.py | 95 ++++ .../cora/equipment/aggregates/model/events.py | 297 +++++++++++++ .../equipment/aggregates/model/evolver.py | 127 ++++++ .../cora/equipment/aggregates/model/state.py | 420 ++++++++++++++++++ apps/api/src/cora/equipment/routes.py | 30 ++ apps/api/tests/unit/equipment/test_model.py | 195 ++++++++ .../tests/unit/equipment/test_model_events.py | 169 +++++++ .../unit/equipment/test_model_evolver.py | 199 +++++++++ 8 files changed, 1532 insertions(+) create mode 100644 apps/api/src/cora/equipment/aggregates/model/__init__.py create mode 100644 apps/api/src/cora/equipment/aggregates/model/events.py create mode 100644 apps/api/src/cora/equipment/aggregates/model/evolver.py create mode 100644 apps/api/src/cora/equipment/aggregates/model/state.py create mode 100644 apps/api/tests/unit/equipment/test_model.py create mode 100644 apps/api/tests/unit/equipment/test_model_events.py create mode 100644 apps/api/tests/unit/equipment/test_model_evolver.py diff --git a/apps/api/src/cora/equipment/aggregates/model/__init__.py b/apps/api/src/cora/equipment/aggregates/model/__init__.py new file mode 100644 index 000000000..6e9c52cf3 --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/__init__.py @@ -0,0 +1,95 @@ +"""Model aggregate: state, status enum, errors, events, evolver. + +Vertical slices that operate on this aggregate live under +`cora.equipment.features._model/` and import from here for +state and event types. +""" + +from cora.equipment.aggregates.model.events import ( + ModelDefined, + ModelDeprecated, + ModelEvent, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelVersioned, + event_type_name, + from_stored, + to_payload, +) +from cora.equipment.aggregates.model.evolver import evolve, fold +from cora.equipment.aggregates.model.state import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_DEPRECATION_REASON_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidDeclaredFamiliesError, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidManufacturerNameError, + InvalidModelDeprecationReasonError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelAlreadyExistsError, + ModelCannotDeprecateError, + ModelCannotVersionError, + ModelDeprecationReason, + ModelFamilyAlreadyPresentError, + ModelFamilyNotPresentError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersionTag, + PartNumber, +) + +__all__ = [ + "MANUFACTURER_IDENTIFIER_MAX_LENGTH", + "MANUFACTURER_NAME_MAX_LENGTH", + "MODEL_DEPRECATION_REASON_MAX_LENGTH", + "MODEL_NAME_MAX_LENGTH", + "MODEL_PART_NUMBER_MAX_LENGTH", + "MODEL_VERSION_TAG_MAX_LENGTH", + "InvalidDeclaredFamiliesError", + "InvalidManufacturerIdentifierError", + "InvalidManufacturerIdentifierPairingError", + "InvalidManufacturerNameError", + "InvalidModelDeprecationReasonError", + "InvalidModelNameError", + "InvalidModelVersionTagError", + "InvalidPartNumberError", + "Manufacturer", + "ManufacturerIdentifier", + "ManufacturerIdentifierType", + "ManufacturerName", + "Model", + "ModelAlreadyExistsError", + "ModelCannotDeprecateError", + "ModelCannotVersionError", + "ModelDefined", + "ModelDeprecated", + "ModelDeprecationReason", + "ModelEvent", + "ModelFamilyAdded", + "ModelFamilyAlreadyPresentError", + "ModelFamilyNotPresentError", + "ModelFamilyRemoved", + "ModelName", + "ModelNotFoundError", + "ModelStatus", + "ModelVersionTag", + "ModelVersioned", + "PartNumber", + "event_type_name", + "evolve", + "fold", + "from_stored", + "to_payload", +] diff --git a/apps/api/src/cora/equipment/aggregates/model/events.py b/apps/api/src/cora/equipment/aggregates/model/events.py new file mode 100644 index 000000000..99ccf61f6 --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/events.py @@ -0,0 +1,297 @@ +"""Domain events emitted by the Model aggregate, plus the discriminated union. + +Event types: `ModelDefined`, `ModelVersioned`, `ModelDeprecated`, +`ModelFamilyAdded`, `ModelFamilyRemoved`. Status is NOT carried in +event payloads; the event type itself encodes the state change. The +evolver hardcodes the mapping per match arm. + +Targeted-mutation pattern: `ModelFamilyAdded` and `ModelFamilyRemoved` +carry a single `family_id` change rather than the whole +`declared_families` set. The operational pattern at a beamline is +"vendor shipped firmware update, one extra Family declared" rather +than wholesale re-author; targeted mutation preserves the operator +intent signal. `ModelVersioned` accepts the wholesale replacement +when a revision genuinely re-authors the catalog entry. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, assert_never +from uuid import UUID + +from cora.equipment.aggregates.model.state import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.infrastructure.event_payload import deserialize_or_raise +from cora.infrastructure.ports.event_store import StoredEvent + + +@dataclass(frozen=True) +class ModelDefined: + """A new vendor-catalog entry was defined. + + Status is implicit (`Defined`); the evolver sets it. `version_tag` + is the optional initial revision label (e.g., `rev-A`); `None` + means no initial label and `Model.version` stays `None` until the + first `version_model` call. + """ + + model_id: UUID + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + occurred_at: datetime + version_tag: str | None = None + + +@dataclass(frozen=True) +class ModelVersioned: + """A model's catalog entry was revised; a new version label was issued. + + Multi-source transition: `Defined | Versioned -> Versioned`. + REPLACES `name`, `manufacturer`, `part_number`, `declared_families`, + and `version_tag` wholesale (a new version IS a new declaration). + Matches Family/Method/Plan/Practice replace-on-version precedent. + """ + + model_id: UUID + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + version_tag: str + occurred_at: datetime + + +@dataclass(frozen=True) +class ModelDeprecated: + """A model was marked as no longer recommended for new Assets. + + Multi-source transition: `Defined | Versioned -> Deprecated`. + Existing Assets with `model_id` pointing at this Model continue + to function; deprecation is an authoring signal, not a runtime + gate. + """ + + model_id: UUID + reason: str + occurred_at: datetime + + +@dataclass(frozen=True) +class ModelFamilyAdded: + """A family was added to the model's `declared_families` set. + + Targeted-mutation event. Strict-not-idempotent: re-adding a + present family raises `ModelFamilyAlreadyPresentError`. Allowed + from `Defined | Versioned`; rejected from `Deprecated`. + """ + + model_id: UUID + family_id: UUID + occurred_at: datetime + + +@dataclass(frozen=True) +class ModelFamilyRemoved: + """A family was removed from the model's `declared_families` set. + + Targeted-mutation event. Strict-not-idempotent: removing an + absent family raises `ModelFamilyNotPresentError`. Allowed from + `Defined | Versioned`; rejected from `Deprecated`. Does NOT + cascade through existing Assets bound to this Model. + """ + + model_id: UUID + family_id: UUID + occurred_at: datetime + + +# Discriminated union of every event the Model aggregate emits. +ModelEvent = ModelDefined | ModelVersioned | ModelDeprecated | ModelFamilyAdded | ModelFamilyRemoved + + +def event_type_name(event: ModelEvent) -> str: + """Discriminator string written into StoredEvent.event_type.""" + return type(event).__name__ + + +def _manufacturer_to_payload(manufacturer: Manufacturer) -> dict[str, Any]: + """Serialize a Manufacturer VO to a JSON-friendly dict. + + `identifier` and `identifier_type` are omitted when both are None + (the optional pair drops together per the VO's pairing invariant). + """ + payload: dict[str, Any] = {"name": manufacturer.name.value} + if manufacturer.identifier is not None and manufacturer.identifier_type is not None: + payload["identifier"] = manufacturer.identifier.value + payload["identifier_type"] = manufacturer.identifier_type.value + return payload + + +def to_payload(event: ModelEvent) -> dict[str, Any]: + """Serialize a Model event to a JSON-friendly dict for jsonb storage.""" + match event: + case ModelDefined( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + occurred_at=occurred_at, + version_tag=version_tag, + ): + payload: dict[str, Any] = { + "model_id": str(model_id), + "name": name, + "manufacturer": _manufacturer_to_payload(manufacturer), + "part_number": part_number, + # Sorted for deterministic payload serialization. + "declared_families": sorted(str(family_id) for family_id in declared_families), + "occurred_at": occurred_at.isoformat(), + } + if version_tag is not None: + payload["version_tag"] = version_tag + return payload + case ModelVersioned( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + occurred_at=occurred_at, + ): + return { + "model_id": str(model_id), + "name": name, + "manufacturer": _manufacturer_to_payload(manufacturer), + "part_number": part_number, + "declared_families": sorted(str(family_id) for family_id in declared_families), + "version_tag": version_tag, + "occurred_at": occurred_at.isoformat(), + } + case ModelDeprecated(model_id=model_id, reason=reason, occurred_at=occurred_at): + return { + "model_id": str(model_id), + "reason": reason, + "occurred_at": occurred_at.isoformat(), + } + case ModelFamilyAdded(model_id=model_id, family_id=family_id, occurred_at=occurred_at): + return { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + case ModelFamilyRemoved(model_id=model_id, family_id=family_id, occurred_at=occurred_at): + return { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def _manufacturer_from_payload(payload: dict[str, Any]) -> Manufacturer: + """Load a Manufacturer VO from a payload's `manufacturer` sub-dict. + + Tolerates: missing `identifier` and `identifier_type` keys (both + must be absent together or present together per the VO's pairing + invariant). Unknown `identifier_type` values raise via the + StrEnum constructor, same fail-loud stance as the top-level + `from_stored` dispatch on unknown event types. + """ + name = ManufacturerName(payload["name"]) + raw_identifier = payload.get("identifier") + raw_identifier_type = payload.get("identifier_type") + identifier = ManufacturerIdentifier(raw_identifier) if raw_identifier is not None else None + identifier_type = ( + ManufacturerIdentifierType(raw_identifier_type) if raw_identifier_type is not None else None + ) + return Manufacturer(name=name, identifier=identifier, identifier_type=identifier_type) + + +def _declared_families_from_payload(payload: dict[str, Any]) -> frozenset[UUID]: + """Load the declared_families frozenset from a payload list field.""" + raw = payload.get("declared_families", []) + return frozenset(UUID(family_id) for family_id in raw) + + +def from_stored(stored: StoredEvent) -> ModelEvent: + """Rebuild a Model event from a StoredEvent loaded from the event store.""" + payload = stored.payload + match stored.event_type: + case "ModelDefined": + return deserialize_or_raise( + "ModelDefined", + lambda: ModelDefined( + model_id=UUID(payload["model_id"]), + name=payload["name"], + manufacturer=_manufacturer_from_payload(payload["manufacturer"]), + part_number=payload["part_number"], + declared_families=_declared_families_from_payload(payload), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + version_tag=payload.get("version_tag"), + ), + ) + case "ModelVersioned": + return deserialize_or_raise( + "ModelVersioned", + lambda: ModelVersioned( + model_id=UUID(payload["model_id"]), + name=payload["name"], + manufacturer=_manufacturer_from_payload(payload["manufacturer"]), + part_number=payload["part_number"], + declared_families=_declared_families_from_payload(payload), + version_tag=payload["version_tag"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "ModelDeprecated": + return deserialize_or_raise( + "ModelDeprecated", + lambda: ModelDeprecated( + model_id=UUID(payload["model_id"]), + reason=payload["reason"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "ModelFamilyAdded": + return deserialize_or_raise( + "ModelFamilyAdded", + lambda: ModelFamilyAdded( + model_id=UUID(payload["model_id"]), + family_id=UUID(payload["family_id"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "ModelFamilyRemoved": + return deserialize_or_raise( + "ModelFamilyRemoved", + lambda: ModelFamilyRemoved( + model_id=UUID(payload["model_id"]), + family_id=UUID(payload["family_id"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case _: + msg = f"Unknown ModelEvent event_type: {stored.event_type!r}" + raise ValueError(msg) + + +__all__ = [ + "ModelDefined", + "ModelDeprecated", + "ModelEvent", + "ModelFamilyAdded", + "ModelFamilyRemoved", + "ModelVersioned", + "event_type_name", + "from_stored", + "to_payload", +] diff --git a/apps/api/src/cora/equipment/aggregates/model/evolver.py b/apps/api/src/cora/equipment/aggregates/model/evolver.py new file mode 100644 index 000000000..b8b942b88 --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/evolver.py @@ -0,0 +1,127 @@ +"""Evolver: replay events to reconstruct Model state. + +Status mapping per event type: + - `ModelDefined` -> DEFINED (genesis; version=None unless + ModelDefined.version_tag was set) + - `ModelVersioned` -> VERSIONED (version=event.version_tag; + multi-source: Defined | Versioned; + replaces name, manufacturer, + part_number, declared_families) + - `ModelDeprecated` -> DEPRECATED (everything else preserved; + multi-source: Defined | Versioned) + - `ModelFamilyAdded` -> status preserved; declared_families + gains family_id (targeted mutation) + - `ModelFamilyRemoved` -> status preserved; declared_families + loses family_id + +The mapping is hardcoded per match arm; the event type IS the +state-change indicator (no status field in event payloads). + +Transition events applied to empty state raise via `require_state`. +""" + +from collections.abc import Sequence +from typing import assert_never + +from cora.equipment.aggregates.model.events import ( + ModelDefined, + ModelDeprecated, + ModelEvent, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelVersioned, +) +from cora.equipment.aggregates.model.state import ( + Model, + ModelName, + ModelStatus, + PartNumber, +) +from cora.infrastructure.evolver import require_state + + +def evolve(state: Model | None, event: ModelEvent) -> Model: + """Apply one event to the current state.""" + match event: + case ModelDefined( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ): + _ = state # ModelDefined is the genesis event; prior state ignored + return Model( + id=model_id, + name=ModelName(name), + manufacturer=manufacturer, + part_number=PartNumber(part_number), + declared_families=declared_families, + status=ModelStatus.DEFINED, + version=version_tag, + ) + case ModelVersioned( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ): + prior = require_state(state, "ModelVersioned") + return Model( + id=prior.id, + # Wholesale replacement (a new version IS a new declaration). + name=ModelName(name), + manufacturer=manufacturer, + part_number=PartNumber(part_number), + declared_families=declared_families, + status=ModelStatus.VERSIONED, + version=version_tag, + ) + case ModelDeprecated(): + prior = require_state(state, "ModelDeprecated") + return Model( + id=prior.id, + name=prior.name, + manufacturer=prior.manufacturer, + part_number=prior.part_number, + # declared_families PRESERVED across deprecation; the + # historical declaration stays visible for audit. + declared_families=prior.declared_families, + status=ModelStatus.DEPRECATED, + version=prior.version, + ) + case ModelFamilyAdded(family_id=family_id): + prior = require_state(state, "ModelFamilyAdded") + return Model( + id=prior.id, + name=prior.name, + manufacturer=prior.manufacturer, + part_number=prior.part_number, + declared_families=prior.declared_families | {family_id}, + # Status preserved across targeted mutation. + status=prior.status, + version=prior.version, + ) + case ModelFamilyRemoved(family_id=family_id): + prior = require_state(state, "ModelFamilyRemoved") + return Model( + id=prior.id, + name=prior.name, + manufacturer=prior.manufacturer, + part_number=prior.part_number, + declared_families=prior.declared_families - {family_id}, + status=prior.status, + version=prior.version, + ) + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def fold(events: Sequence[ModelEvent]) -> Model | None: + """Replay a stream of events from the empty initial state.""" + state: Model | None = None + for event in events: + state = evolve(state, event) + return state diff --git a/apps/api/src/cora/equipment/aggregates/model/state.py b/apps/api/src/cora/equipment/aggregates/model/state.py new file mode 100644 index 000000000..74c34d4cb --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/state.py @@ -0,0 +1,420 @@ +"""Model aggregate state, status enum, errors, and value objects. + +`Model` is the Equipment BC's vendor-catalog entry: HOW a deployed +`Asset` is identified as an instance of "Vendor X part number Y". +A `Model` pins together a `Manufacturer` (name plus optional +identifier in a closed-StrEnum scheme), a `part_number` (vendor SKU), +and a `declared_families: frozenset[UUID]` pointing at one or more +registered `Family` aggregates that the catalog entry satisfies. + +## Aggregate scope + +Model sits between `Family` (the device-class kind) and `Asset` (the +deployed instance) in the Equipment ladder. Examples: an Aerotech +ANT130-L rotary stage is one Model; the two PCO Edge 5.5 cameras +mounted at 2-BM share a single Model. Asset gains an optional +`model_id` pointer; if set, `Model.declared_families` must be a +subset of `Asset.families` at `register_asset` and `add_asset_family` +time (cross-BC subset invariant). + +`declared_families: frozenset[UUID]` is REQUIRED at `define_model` +time with cardinality at least one (empty rejected at the API +boundary). The set mutates incrementally through `add_model_family` +and `remove_model_family` (targeted-mutation events), or wholesale +through `version_model` (a new version IS a new declaration; matches +Family/Method/Plan/Practice replace-on-version precedent). + +## Catalog-tier required-manufacturer rationale + +`Manufacturer` is required: a catalog entry without a manufacturer is +incoherent across the four catalog-tier traditions (CMMS Equipment +Type per ISO 14224, AAS Type-AAS DigitalNameplate IDTA 02006, OPC UA +vendor profile, ECLASS-augmented Property). The PIDINST property 6 +`1-n Mandatory` cardinality is an INSTANCE-tier obligation (PIDINST +v1.0 spec page 1: "The group considers instrument instances, e.g. +the individual physical objects, as opposed to instrument types or +models") and transfers to `Asset.alternate_identifiers` in a future +slice, NOT to `Model.manufacturer` at the catalog tier. + +## Status as enum-in-state, derived-from-event-type-in-evolver + +`ModelStatus` is a `StrEnum` so the values would serialize naturally +as JSON-friendly strings IF carried in an event payload. Today they +aren't: state holds the enum (typed) and the evolver derives the new +status from the event TYPE, mirroring `FamilyStatus`. + +## Closed `ManufacturerIdentifierType` enum + +`ManufacturerIdentifierType` is a closed StrEnum (`ROR | GRID | ISNI`) +per the [[project-family-affordance-design]] closed-vocabulary +precedent. Adding a fourth scheme (e.g., `WIKIDATA`) is an additive +enum change at a future migration boundary. + +## Bounded-name VOs + +`ModelName`, `PartNumber`, `ManufacturerName`, `ManufacturerIdentifier`, +`ModelVersionTag`, and `ModelDeprecationReason` follow the +trimmed-bounded-text VO pattern via the shared +`validate_bounded_text` helper. Part numbers are NOT case-folded +because vendor SKUs are case-sensitive (`RV120CCHL` and `rv120cchl` +are different Newport entries). +""" + +from dataclasses import dataclass +from enum import StrEnum +from uuid import UUID + +from cora.infrastructure.bounded_text import validate_bounded_text + +MODEL_NAME_MAX_LENGTH = 200 +MODEL_PART_NUMBER_MAX_LENGTH = 100 +MODEL_VERSION_TAG_MAX_LENGTH = 50 +MODEL_DEPRECATION_REASON_MAX_LENGTH = 500 +MANUFACTURER_NAME_MAX_LENGTH = 200 +MANUFACTURER_IDENTIFIER_MAX_LENGTH = 200 + + +class ModelStatus(StrEnum): + """The Model's lifecycle state. + + Transitions: + - Defined -> Versioned (version_model) + - (Defined | Versioned) -> Deprecated (deprecate_model) + + `Defined` is the genesis state set by `define_model`. Multi-source + `(Defined | Versioned) -> Versioned` matches the Family precedent + at `family/state.py` (`FamilyCannotVersionError`). + """ + + DEFINED = "Defined" + VERSIONED = "Versioned" + DEPRECATED = "Deprecated" + + +class ManufacturerIdentifierType(StrEnum): + """Closed scheme for the optional manufacturer identifier. + + Three members ship in v1: ROR (Research Organization Registry), + GRID (Global Research Identifier Database; subsumed by ROR but + still in active use at many facilities), ISNI (International + Standard Name Identifier). Adding a fourth scheme is an additive + enum change. + """ + + ROR = "ROR" + GRID = "GRID" + ISNI = "ISNI" + + +class InvalidModelNameError(ValueError): + """The supplied model name is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Model name must be 1-{MODEL_NAME_MAX_LENGTH} chars after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidPartNumberError(ValueError): + """The supplied part number is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Part number must be 1-{MODEL_PART_NUMBER_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidManufacturerNameError(ValueError): + """The supplied manufacturer name is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Manufacturer name must be 1-{MANUFACTURER_NAME_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidManufacturerIdentifierError(ValueError): + """The supplied manufacturer identifier is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Manufacturer identifier must be 1-{MANUFACTURER_IDENTIFIER_MAX_LENGTH} chars " + f"after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidManufacturerIdentifierPairingError(ValueError): + """`identifier` and `identifier_type` must be both set or both None. + + Cross-field invariant: setting only one half of the optional pair + is ambiguous (a bare identifier with no scheme cannot be resolved; + a scheme with no identifier is meaningless). Both together, or + both None. + """ + + def __init__(self, *, identifier: str | None, identifier_type: object) -> None: + super().__init__( + "Manufacturer.identifier and Manufacturer.identifier_type must be both set " + f"or both None (got identifier={identifier!r}, identifier_type={identifier_type!r})" + ) + self.identifier = identifier + self.identifier_type = identifier_type + + +class InvalidModelVersionTagError(ValueError): + """The supplied version tag is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Model version tag must be 1-{MODEL_VERSION_TAG_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidModelDeprecationReasonError(ValueError): + """The supplied deprecation reason is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Model deprecation reason must be 1-{MODEL_DEPRECATION_REASON_MAX_LENGTH} chars " + f"after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidDeclaredFamiliesError(ValueError): + """`declared_families` is empty (cardinality at least one required).""" + + def __init__(self) -> None: + super().__init__( + "Model.declared_families must contain at least one Family id " + "(empty set rejected at the catalog tier)" + ) + + +class ModelAlreadyExistsError(Exception): + """Attempted to define a model whose stream already has events.""" + + def __init__(self, model_id: UUID) -> None: + super().__init__(f"Model {model_id} already exists") + self.model_id = model_id + + +class ModelNotFoundError(Exception): + """Attempted an operation on a model whose stream has no events.""" + + def __init__(self, model_id: UUID) -> None: + super().__init__(f"Model {model_id} not found") + self.model_id = model_id + + +class ModelCannotVersionError(Exception): + """Attempted to version a model not in `Defined` or `Versioned`. + + Multi-source guard: `version_model` accepts both `Defined` and + `Versioned`. Only `Deprecated` is rejected. + """ + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot be versioned: currently in status " + f"{current_status.value}, version requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + +class ModelCannotDeprecateError(Exception): + """Attempted to deprecate a model not in `Defined` or `Versioned`.""" + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot be deprecated: currently in status " + f"{current_status.value}, deprecate requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + +class ModelFamilyAlreadyPresentError(Exception): + """Attempted to add a family already present in `declared_families`.""" + + def __init__(self, model_id: UUID, family_id: UUID) -> None: + super().__init__( + f"Model {model_id} already declares family {family_id}; " + "add_model_family is strict-not-idempotent" + ) + self.model_id = model_id + self.family_id = family_id + + +class ModelFamilyNotPresentError(Exception): + """Attempted to remove a family not present in `declared_families`.""" + + def __init__(self, model_id: UUID, family_id: UUID) -> None: + super().__init__(f"Model {model_id} does not declare family {family_id}; nothing to remove") + self.model_id = model_id + self.family_id = family_id + + +@dataclass(frozen=True) +class ModelName: + """Display name for a model. Trimmed; 1-200 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_NAME_MAX_LENGTH, + error_class=InvalidModelNameError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class PartNumber: + """Vendor SKU. Trimmed; 1-100 chars; case-sensitive (no folding). + + Vendor part numbers like Newport's `RV120CCHL` and `rv120cchl` + are distinct entries in vendor catalogs; case-folding would + collide them. + """ + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + error_class=InvalidPartNumberError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class ManufacturerName: + """Manufacturer display name. Trimmed; 1-200 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + error_class=InvalidManufacturerNameError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class ManufacturerIdentifier: + """Optional manufacturer identifier value. Trimmed; 1-200 chars. + + Opaque string; the scheme lives in `ManufacturerIdentifierType`. + See [[project-asset-condition-design]] for the orthogonal-axis + precedent (the scheme is one axis, the identifier value is the + other; coupled through the `Manufacturer` VO's pairing invariant). + """ + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + error_class=InvalidManufacturerIdentifierError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class Manufacturer: + """A model's manufacturer: required name, optional (identifier, type). + + Pairing invariant: `identifier` and `identifier_type` are both set + or both None. A bare identifier with no scheme cannot be resolved; + a scheme with no identifier is meaningless. Enforced in + `__post_init__`; raises `InvalidManufacturerIdentifierPairingError`. + """ + + name: ManufacturerName + identifier: ManufacturerIdentifier | None = None + identifier_type: ManufacturerIdentifierType | None = None + + def __post_init__(self) -> None: + has_id = self.identifier is not None + has_type = self.identifier_type is not None + if has_id != has_type: + raise InvalidManufacturerIdentifierPairingError( + identifier=self.identifier.value if self.identifier is not None else None, + identifier_type=self.identifier_type, + ) + + +@dataclass(frozen=True) +class ModelVersionTag: + """Operator-supplied revision label. Trimmed; 1-50 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + error_class=InvalidModelVersionTagError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class ModelDeprecationReason: + """Operator-supplied deprecation rationale. Trimmed; 1-500 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_DEPRECATION_REASON_MAX_LENGTH, + error_class=InvalidModelDeprecationReasonError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class Model: + """Aggregate root: a vendor-catalog entry. + + `version` is the operator-supplied label of the most recent + `version_model` call (None until first version). State always + holds the latest tag; past tags live in the event stream as + `ModelVersioned` events. + + `declared_families` is the frozenset of Family ids the catalog + entry satisfies. Required non-empty at `define_model` time. + Mutated incrementally through `add_model_family` / + `remove_model_family` (targeted-mutation), or wholesale through + `version_model` (replace-on-version). + + Cross-BC subset invariant `Model.declared_families subset-of + Asset.families` evaluated by the Asset BC at `register_asset` and + `add_asset_family`; NOT enforced inside the Model aggregate. + """ + + id: UUID + name: ModelName + manufacturer: Manufacturer + part_number: PartNumber + declared_families: frozenset[UUID] + status: ModelStatus = ModelStatus.DEFINED + version: str | None = None diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 00764ccef..2f5efe4b5 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -77,6 +77,22 @@ InvalidFrameRevisionError, InvalidFrameRootError, ) +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidManufacturerNameError, + InvalidModelDeprecationReasonError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + ModelAlreadyExistsError, + ModelCannotDeprecateError, + ModelCannotVersionError, + ModelFamilyAlreadyPresentError, + ModelFamilyNotPresentError, + ModelNotFoundError, +) from cora.equipment.aggregates.mount import ( AssetAlreadyInstalledElsewhereError, AssetNotFoundForMountError, @@ -238,6 +254,14 @@ def register_equipment_routes(app: FastAPI) -> None: InvalidPlacementError, InvalidDrawingError, InvalidSlotCodeError, + InvalidModelNameError, + InvalidPartNumberError, + InvalidManufacturerNameError, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidModelVersionTagError, + InvalidModelDeprecationReasonError, + InvalidDeclaredFamiliesError, ): app.add_exception_handler(validation_cls, _handle_validation_error) for not_found_cls in ( @@ -246,6 +270,7 @@ def register_equipment_routes(app: FastAPI) -> None: FrameNotFoundError, MountNotFoundError, AssetNotFoundForMountError, + ModelNotFoundError, ): app.add_exception_handler(not_found_cls, _handle_not_found) for already_exists_cls in ( @@ -253,6 +278,7 @@ def register_equipment_routes(app: FastAPI) -> None: AssetAlreadyExistsError, FrameAlreadyExistsError, MountAlreadyExistsError, + ModelAlreadyExistsError, ): app.add_exception_handler(already_exists_cls, _handle_already_exists) for cannot_transition_cls in ( @@ -279,6 +305,10 @@ def register_equipment_routes(app: FastAPI) -> None: MountIsEmptyError, AssetNotInstallableError, AssetAlreadyInstalledElsewhereError, + ModelCannotVersionError, + ModelCannotDeprecateError, + ModelFamilyAlreadyPresentError, + ModelFamilyNotPresentError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/tests/unit/equipment/test_model.py b/apps/api/tests/unit/equipment/test_model.py new file mode 100644 index 000000000..504ccc74d --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model.py @@ -0,0 +1,195 @@ +"""Value objects + Model dataclass + ModelStatus enum tests.""" + +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_DEPRECATION_REASON_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidManufacturerNameError, + InvalidModelDeprecationReasonError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelDeprecationReason, + ModelName, + ModelStatus, + ModelVersionTag, + PartNumber, +) + + +@pytest.mark.unit +def test_model_name_accepts_normal_string() -> None: + name = ModelName("Aerotech ANT130-L") + assert name.value == "Aerotech ANT130-L" + + +@pytest.mark.unit +def test_model_name_trims_whitespace() -> None: + name = ModelName(" PCO Edge 5.5 ") + assert name.value == "PCO Edge 5.5" + + +@pytest.mark.unit +def test_model_name_rejects_empty_string() -> None: + with pytest.raises(InvalidModelNameError): + ModelName("") + + +@pytest.mark.unit +def test_model_name_rejects_whitespace_only() -> None: + with pytest.raises(InvalidModelNameError): + ModelName(" \t\n ") + + +@pytest.mark.unit +def test_model_name_rejects_too_long() -> None: + with pytest.raises(InvalidModelNameError): + ModelName("X" * (MODEL_NAME_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_part_number_accepts_case_sensitive_sku() -> None: + upper = PartNumber("RV120CCHL") + lower = PartNumber("rv120cchl") + assert upper.value == "RV120CCHL" + assert lower.value == "rv120cchl" + assert upper.value != lower.value + + +@pytest.mark.unit +def test_part_number_trims_whitespace() -> None: + assert PartNumber(" ANT130-L ").value == "ANT130-L" + + +@pytest.mark.unit +def test_part_number_rejects_empty() -> None: + with pytest.raises(InvalidPartNumberError): + PartNumber("") + + +@pytest.mark.unit +def test_part_number_rejects_too_long() -> None: + with pytest.raises(InvalidPartNumberError): + PartNumber("X" * (MODEL_PART_NUMBER_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_manufacturer_name_rejects_empty() -> None: + with pytest.raises(InvalidManufacturerNameError): + ManufacturerName("") + + +@pytest.mark.unit +def test_manufacturer_name_rejects_too_long() -> None: + with pytest.raises(InvalidManufacturerNameError): + ManufacturerName("X" * (MANUFACTURER_NAME_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_manufacturer_identifier_trims_and_accepts_ror() -> None: + ident = ManufacturerIdentifier(" https://ror.org/05gvnxz63 ") + assert ident.value == "https://ror.org/05gvnxz63" + + +@pytest.mark.unit +def test_manufacturer_identifier_rejects_empty() -> None: + with pytest.raises(InvalidManufacturerIdentifierError): + ManufacturerIdentifier(" ") + + +@pytest.mark.unit +def test_manufacturer_identifier_rejects_too_long() -> None: + with pytest.raises(InvalidManufacturerIdentifierError): + ManufacturerIdentifier("X" * (MANUFACTURER_IDENTIFIER_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_manufacturer_accepts_name_only() -> None: + mfr = Manufacturer(name=ManufacturerName("Aerotech")) + assert mfr.name.value == "Aerotech" + assert mfr.identifier is None + assert mfr.identifier_type is None + + +@pytest.mark.unit +def test_manufacturer_accepts_full_triple() -> None: + mfr = Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + assert mfr.identifier is not None + assert mfr.identifier.value == "https://ror.org/05gvnxz63" + assert mfr.identifier_type is ManufacturerIdentifierType.ROR + + +@pytest.mark.unit +def test_manufacturer_rejects_identifier_without_type() -> None: + with pytest.raises(InvalidManufacturerIdentifierPairingError): + Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=None, + ) + + +@pytest.mark.unit +def test_manufacturer_rejects_type_without_identifier() -> None: + with pytest.raises(InvalidManufacturerIdentifierPairingError): + Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=None, + identifier_type=ManufacturerIdentifierType.GRID, + ) + + +@pytest.mark.unit +def test_model_version_tag_rejects_empty_and_too_long() -> None: + with pytest.raises(InvalidModelVersionTagError): + ModelVersionTag("") + with pytest.raises(InvalidModelVersionTagError): + ModelVersionTag("X" * (MODEL_VERSION_TAG_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_model_deprecation_reason_rejects_empty_and_too_long() -> None: + with pytest.raises(InvalidModelDeprecationReasonError): + ModelDeprecationReason("") + with pytest.raises(InvalidModelDeprecationReasonError): + ModelDeprecationReason("X" * (MODEL_DEPRECATION_REASON_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_model_status_enum_values() -> None: + assert ModelStatus.DEFINED.value == "Defined" + assert ModelStatus.VERSIONED.value == "Versioned" + assert ModelStatus.DEPRECATED.value == "Deprecated" + + +@pytest.mark.unit +def test_model_aggregate_constructs_with_required_fields() -> None: + family_a = uuid4() + model = Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({family_a}), + ) + assert model.status is ModelStatus.DEFINED + assert model.version is None + assert model.declared_families == frozenset({family_a}) diff --git a/apps/api/tests/unit/equipment/test_model_events.py b/apps/api/tests/unit/equipment/test_model_events.py new file mode 100644 index 000000000..2a690ceeb --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model_events.py @@ -0,0 +1,169 @@ +"""Round-trip tests for Model event payloads (to_payload / from_stored).""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + ModelDefined, + ModelDeprecated, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelVersioned, + event_type_name, + from_stored, + to_payload, +) +from cora.infrastructure.ports.event_store import StoredEvent + +_NOW = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, object]) -> StoredEvent: + """Wrap a payload as a StoredEvent for from_stored round-tripping. + + Only `event_type` and `payload` are read by `from_stored`; the rest + is fixture noise. + """ + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Model", + stream_id=uuid4(), + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +@pytest.mark.unit +def test_model_defined_round_trips_with_minimal_manufacturer() -> None: + family_a = uuid4() + family_b = uuid4() + event = ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + ) + payload = to_payload(event) + assert payload["manufacturer"] == {"name": "Aerotech"} + assert "identifier" not in payload["manufacturer"] + assert "version_tag" not in payload + restored = from_stored(_stored("ModelDefined", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_defined_round_trips_with_full_manufacturer_and_version_tag() -> None: + event = ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + version_tag="rev-A", + ) + payload = to_payload(event) + assert payload["manufacturer"]["identifier"] == "https://ror.org/05gvnxz63" + assert payload["manufacturer"]["identifier_type"] == "ROR" + assert payload["version_tag"] == "rev-A" + restored = from_stored(_stored("ModelDefined", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_defined_payload_sorts_declared_families_deterministically() -> None: + family_a = uuid4() + family_b = uuid4() + event = ModelDefined( + model_id=uuid4(), + name="N", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 0, 0, tzinfo=UTC), + ) + payload = to_payload(event) + assert payload["declared_families"] == sorted([str(family_a), str(family_b)]) + + +@pytest.mark.unit +def test_model_versioned_round_trips() -> None: + event = ModelVersioned( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=datetime(2026, 6, 1, 13, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelVersioned", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_deprecated_round_trips() -> None: + event = ModelDeprecated( + model_id=uuid4(), + reason="Vendor end-of-life announcement 2026-05-28", + occurred_at=datetime(2026, 6, 1, 14, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelDeprecated", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_family_added_round_trips() -> None: + event = ModelFamilyAdded( + model_id=uuid4(), + family_id=uuid4(), + occurred_at=datetime(2026, 6, 1, 15, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelFamilyAdded", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_family_removed_round_trips() -> None: + event = ModelFamilyRemoved( + model_id=uuid4(), + family_id=uuid4(), + occurred_at=datetime(2026, 6, 1, 16, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelFamilyRemoved", payload)) + assert restored == event + + +@pytest.mark.unit +def test_from_stored_rejects_unknown_event_type() -> None: + with pytest.raises(ValueError, match="Unknown ModelEvent event_type"): + from_stored(_stored("ModelMystery", {})) + + +@pytest.mark.unit +def test_event_type_name_returns_class_name() -> None: + event = ModelDeprecated(model_id=uuid4(), reason="r", occurred_at=datetime.now(tz=UTC)) + assert event_type_name(event) == "ModelDeprecated" diff --git a/apps/api/tests/unit/equipment/test_model_evolver.py b/apps/api/tests/unit/equipment/test_model_evolver.py new file mode 100644 index 000000000..e2b233587 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model_evolver.py @@ -0,0 +1,199 @@ +"""FSM evolution tests for the Model aggregate.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelDefined, + ModelDeprecated, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelStatus, + ModelVersioned, + evolve, + fold, +) + + +def _now() -> datetime: + return datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _defined(family_id: object | None = None) -> ModelDefined: + family = family_id if isinstance(family_id, type(uuid4())) else uuid4() + return ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family}), + occurred_at=_now(), + ) + + +@pytest.mark.unit +def test_model_defined_sets_genesis_state() -> None: + event = _defined() + state = evolve(None, event) + assert state.id == event.model_id + assert state.name.value == "Aerotech ANT130-L" + assert state.status is ModelStatus.DEFINED + assert state.version is None + assert state.declared_families == event.declared_families + + +@pytest.mark.unit +def test_model_defined_with_initial_version_tag_carries_through() -> None: + event = ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + occurred_at=_now(), + version_tag="rev-A", + ) + state = evolve(None, event) + assert state.version == "rev-A" + + +@pytest.mark.unit +def test_model_versioned_transitions_from_defined() -> None: + defined = _defined() + new_family = uuid4() + versioned = ModelVersioned( + model_id=defined.model_id, + name="Aerotech ANT130-L (rev B)", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech Newport JV")), + part_number="ANT130-L-B", + declared_families=frozenset({new_family}), + version_tag="rev-B", + occurred_at=_now(), + ) + state = fold([defined, versioned]) + assert state is not None + assert state.status is ModelStatus.VERSIONED + assert state.version == "rev-B" + # Wholesale replacement: name, manufacturer, part_number, families all swapped. + assert state.name.value == "Aerotech ANT130-L (rev B)" + assert state.manufacturer.name.value == "Aerotech Newport JV" + assert state.part_number.value == "ANT130-L-B" + assert state.declared_families == frozenset({new_family}) + + +@pytest.mark.unit +def test_model_versioned_transitions_from_versioned() -> None: + defined = _defined() + v1 = ModelVersioned( + model_id=defined.model_id, + name="A", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=_now(), + ) + v2 = ModelVersioned( + model_id=defined.model_id, + name="A", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-C", + occurred_at=_now(), + ) + state = fold([defined, v1, v2]) + assert state is not None + assert state.status is ModelStatus.VERSIONED + assert state.version == "rev-C" + + +@pytest.mark.unit +def test_model_deprecated_transitions_from_defined() -> None: + defined = _defined() + deprecated = ModelDeprecated( + model_id=defined.model_id, + reason="EOL", + occurred_at=_now(), + ) + state = fold([defined, deprecated]) + assert state is not None + assert state.status is ModelStatus.DEPRECATED + # declared_families preserved across deprecation (audit trail). + assert state.declared_families == defined.declared_families + + +@pytest.mark.unit +def test_model_deprecated_transitions_from_versioned() -> None: + defined = _defined() + versioned = ModelVersioned( + model_id=defined.model_id, + name="A", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=_now(), + ) + deprecated = ModelDeprecated(model_id=defined.model_id, reason="r", occurred_at=_now()) + state = fold([defined, versioned, deprecated]) + assert state is not None + assert state.status is ModelStatus.DEPRECATED + assert state.version == "rev-B" + + +@pytest.mark.unit +def test_model_family_added_extends_declared_families() -> None: + defined = _defined() + extra = uuid4() + added = ModelFamilyAdded(model_id=defined.model_id, family_id=extra, occurred_at=_now()) + state = fold([defined, added]) + assert state is not None + assert state.status is ModelStatus.DEFINED # status preserved on targeted mutation + assert state.declared_families == defined.declared_families | {extra} + + +@pytest.mark.unit +def test_model_family_removed_shrinks_declared_families() -> None: + family_a = uuid4() + family_b = uuid4() + defined = ModelDefined( + model_id=uuid4(), + name="N", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({family_a, family_b}), + occurred_at=_now(), + ) + removed = ModelFamilyRemoved( + model_id=defined.model_id, + family_id=family_a, + occurred_at=_now(), + ) + state = fold([defined, removed]) + assert state is not None + assert state.declared_families == frozenset({family_b}) + + +@pytest.mark.unit +def test_versioning_empty_stream_raises() -> None: + versioned = ModelVersioned( + model_id=uuid4(), + name="N", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=_now(), + ) + with pytest.raises(ValueError): + evolve(None, versioned) + + +@pytest.mark.unit +def test_fold_empty_stream_returns_none() -> None: + assert fold([]) is None From 000b29c1edc2e98455332638658681dc532e0a1a Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 16:59:48 +0300 Subject: [PATCH 02/11] feat(equipment): define_model slice (vendor-catalog Model genesis) Adds the writable POST /models REST endpoint and define_model MCP tool, surfacing the Model aggregate's genesis command per the design memo (project_model_aggregate_design.md). Command shape: DefineModel(name, manufacturer, part_number, declared_families, version_tag=None). The Manufacturer VO carries a required name plus an optional (identifier, identifier_type) pair (closed StrEnum scheme: ROR | GRID | ISNI; pairing invariant enforced at the VO). Cross-BC family lookup: the handler loads list_family_ids from the Family read repo and rejects with FamilyNotFoundError (404) if any declared_families element does not resolve to a registered Family. Bulk single-query approach (cheap at pilot scale, <50 Families). Family.read.list_family_ids widened to accept Pool|None mirroring the load_asset_lifecycle precedent so test/no-pool environments short-circuit cleanly. Slice files (10 new): - src/cora/equipment/features/define_model/{command,decider, handler,route,tool,__init__}.py - tests/unit/equipment/test_define_model_decider.py (8 decider tests, value-object validation + invariants) - tests/unit/equipment/test_define_model_decider_properties.py (7 Hypothesis PBTs over name/part_number/version_tag/families) - tests/unit/equipment/test_define_model_handler.py (6 handler tests, mocked Kernel; cross-BC list_family_ids monkeypatched) - tests/contract/test_define_model_contract.py (10 REST contract tests; idempotency-key parity with define_family) - tests/contract/test_define_model_mcp_tool.py (5 MCP tool contract tests; happy-path deferred to integration tier because in-memory MCP harness has no pool, mirrors inspect_plan_binding precedent) - tests/integration/test_define_model_handler_postgres.py (4 PG integration tests; real cross-BC family lookup, real idempotency-key dedup, real event-store persistence) Wiring (3 edits): - equipment/routes.py: include define_model.router - equipment/wire.py: EquipmentHandlers.define_model field + with_tracing(with_idempotency(...)) builder mirroring define_family - equipment/tools.py: register define_model MCP tool openapi.json regenerated to include POST /models. Architecture fitness gates that previously blocked partial-slice commits (paired PBT, slice-contract, REST contract, MCP contract, integration test, decider docstring invariants) all pass. Tests: 15072 pass + 566 skipped across the full architecture + unit + contract + Model integration suite. ruff clean, pyright 0/0/0. Pre-existing failure noted: tests/contract/ test_conduct_procedure_endpoint.py:: test_post_conduct_against_unregistered_procedure_returns_200_with_lifecycle_failure fails on origin/main too (verified by reproducing with this branch's changes stashed). Unrelated to Model work. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 225 +++++++++++ .../cora/equipment/aggregates/family/read.py | 12 +- .../features/define_model/__init__.py | 36 ++ .../features/define_model/command.py | 33 ++ .../features/define_model/decider.py | 75 ++++ .../features/define_model/handler.py | 174 +++++++++ .../equipment/features/define_model/route.py | 183 +++++++++ .../equipment/features/define_model/tool.py | 144 +++++++ apps/api/src/cora/equipment/routes.py | 2 + apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 14 + .../contract/test_define_model_contract.py | 192 +++++++++ .../contract/test_define_model_mcp_tool.py | 132 +++++++ .../test_define_model_handler_postgres.py | 265 +++++++++++++ .../equipment/test_define_model_decider.py | 144 +++++++ .../test_define_model_decider_properties.py | 367 ++++++++++++++++++ .../equipment/test_define_model_handler.py | 209 ++++++++++ 17 files changed, 2210 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/cora/equipment/features/define_model/__init__.py create mode 100644 apps/api/src/cora/equipment/features/define_model/command.py create mode 100644 apps/api/src/cora/equipment/features/define_model/decider.py create mode 100644 apps/api/src/cora/equipment/features/define_model/handler.py create mode 100644 apps/api/src/cora/equipment/features/define_model/route.py create mode 100644 apps/api/src/cora/equipment/features/define_model/tool.py create mode 100644 apps/api/tests/contract/test_define_model_contract.py create mode 100644 apps/api/tests/contract/test_define_model_mcp_tool.py create mode 100644 apps/api/tests/integration/test_define_model_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_define_model_decider.py create mode 100644 apps/api/tests/unit/equipment/test_define_model_decider_properties.py create mode 100644 apps/api/tests/unit/equipment/test_define_model_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index e44b34d54..91d3d26de 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -4174,6 +4174,76 @@ "title": "DefineMethodResponse", "type": "object" }, + "DefineModelRequest": { + "description": "Body for `POST /models`.", + "properties": { + "declared_families": { + "description": "Family ids the catalog entry satisfies. At least one required; deduplicated server-side.", + "items": { + "format": "uuid", + "type": "string" + }, + "minItems": 1, + "title": "Declared Families", + "type": "array" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerBody", + "description": "Vendor identity (name plus optional ROR/GRID/ISNI identifier)." + }, + "name": { + "description": "Display name for the new Model.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "part_number": { + "description": "Vendor SKU; case-sensitive (RV120CCHL and rv120cchl are different Newport entries).", + "maxLength": 100, + "minLength": 1, + "title": "Part Number", + "type": "string" + }, + "version_tag": { + "anyOf": [ + { + "maxLength": 50, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional initial revision label (e.g., 'rev-A').", + "title": "Version Tag" + } + }, + "required": [ + "name", + "manufacturer", + "part_number", + "declared_families" + ], + "title": "DefineModelRequest", + "type": "object" + }, + "DefineModelResponse": { + "description": "Response body for `POST /models`.", + "properties": { + "model_id": { + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + "required": [ + "model_id" + ], + "title": "DefineModelResponse", + "type": "object" + }, "DefinePermitRequest": { "additionalProperties": false, "description": "Body for `POST /federation/permits`.", @@ -5499,6 +5569,58 @@ "title": "ListPermissionsResponse", "type": "object" }, + "ManufacturerBody": { + "description": "Pydantic mirror of the Manufacturer VO for the request body.\n\n`identifier` and `identifier_type` are both optional but must be\nsupplied together or both omitted (the pairing invariant is\nenforced at the VO constructor; a bare identifier with no scheme\ncannot be resolved).", + "properties": { + "identifier": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional opaque identifier value. If supplied, `identifier_type` is required (and vice versa).", + "title": "Identifier" + }, + "identifier_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/ManufacturerIdentifierType" + }, + { + "type": "null" + } + ], + "description": "Closed scheme for the optional manufacturer identifier." + }, + "name": { + "description": "Display name of the manufacturer.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ManufacturerBody", + "type": "object" + }, + "ManufacturerIdentifierType": { + "description": "Closed scheme for the optional manufacturer identifier.\n\nThree members ship in v1: ROR (Research Organization Registry),\nGRID (Global Research Identifier Database; subsumed by ROR but\nstill in active use at many facilities), ISNI (International\nStandard Name Identifier). Adding a fourth scheme is an additive\nenum change.", + "enum": [ + "ROR", + "GRID", + "ISNI" + ], + "title": "ManufacturerIdentifierType", + "type": "string" + }, "MarkSupplyAvailableRequest": { "description": "Body for `POST /supplies/{supply_id}/mark-available`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining the first-observation declaration. Examples:\n\"operator walkdown confirms LN2 dewar pressure nominal\", \"control\nroom reports beam delivered after morning startup\", \"first-time\ncommissioning verified by ops\".", "properties": { @@ -23108,6 +23230,109 @@ ] } }, + "/models": { + "post": { + "operationId": "post_models_models_post", + "parameters": [ + { + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the model.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the model.", + "title": "Idempotency-Key" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefineModelRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefineModelResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (for example whitespace-only name)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "One or more declared families does not resolve to a registered Family." + }, + "422": { + "description": "Request body failed schema validation OR Idempotency-Key was reused with a different request body." + } + }, + "summary": "Define a new vendor-catalog Model", + "tags": [ + "equipment" + ] + } + }, "/mounts": { "post": { "operationId": "post_mounts_mounts_post", diff --git a/apps/api/src/cora/equipment/aggregates/family/read.py b/apps/api/src/cora/equipment/aggregates/family/read.py index 1299a270c..63dbf57e7 100644 --- a/apps/api/src/cora/equipment/aggregates/family/read.py +++ b/apps/api/src/cora/equipment/aggregates/family/read.py @@ -90,10 +90,11 @@ async def load_family_timestamps( """ -async def list_family_ids(pool: asyncpg.Pool) -> list[UUID]: +async def list_family_ids(pool: asyncpg.Pool | None) -> list[UUID]: """Read every non-Deprecated Family id from the summary projection. - Used by `inspect_plan_binding`'s candidate enumeration: callers + Used by `inspect_plan_binding`'s candidate enumeration and by + `define_model`'s cross-BC family_lookup precondition. Callers iterate every Family, load its aggregate state via `load_family`, and filter by `Family.affordances` membership. Deprecated Families are excluded at the SQL layer so they're not offered @@ -101,6 +102,11 @@ async def list_family_ids(pool: asyncpg.Pool) -> list[UUID]: when they're directly wired into a Plan; this is discovery-side only). + Returns `[]` when `pool is None` (test / no-database app_env), + mirroring the `load_asset_lifecycle` / `load_asset_location` + null-pool short-circuit. Tests that need a populated lookup + must wire a real pool. + The summary projection doesn't carry an affordances column today (5j deferred it); when the first caller demands affordance- filtered queries at scale, ship the column + GIN index here and @@ -109,6 +115,8 @@ async def list_family_ids(pool: asyncpg.Pool) -> list[UUID]: `inspect_plan_binding` crosses 200ms. Pilot scale (~9 Families) keeps the load-all-then-filter approach cheap. """ + if pool is None: + return [] async with pool.acquire() as conn: rows = await conn.fetch(_SELECT_FAMILY_IDS_SQL) return [row["family_id"] for row in rows] diff --git a/apps/api/src/cora/equipment/features/define_model/__init__.py b/apps/api/src/cora/equipment/features/define_model/__init__.py new file mode 100644 index 000000000..de936d2c2 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/__init__.py @@ -0,0 +1,36 @@ +"""Vertical slice for the `DefineModel` command. + +Module-as-namespace surface, symmetric with the other create-style +command slices: + + from cora.equipment.features import define_model + + cmd = define_model.DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({rotary_stage_family_id}), + ) + handler = define_model.bind(deps) + model_id = await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.define_model import tool +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.decider import decide +from cora.equipment.features.define_model.handler import ( + Handler, + IdempotentHandler, + bind, +) +from cora.equipment.features.define_model.route import router + +__all__ = [ + "DefineModel", + "Handler", + "IdempotentHandler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/define_model/command.py b/apps/api/src/cora/equipment/features/define_model/command.py new file mode 100644 index 000000000..6bcee9cff --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/command.py @@ -0,0 +1,33 @@ +"""The `DefineModel` command, intent dataclass for this slice. + +Carries only what the caller controls (display name, manufacturer, +part_number, declared_families, optional initial version_tag). +Server-side concerns (new aggregate id, wall-clock timestamp, +correlation id, per-event ids) are injected by the handler from +infrastructure ports. + +Status is implicit at definition (`Defined`) and not part of the +command; see the Model aggregate's `state.py` docstring for the +enum-in-state, str-in-event convention. + +`declared_families` is REQUIRED at definition time with cardinality +at least one. Empty `frozenset()` is rejected by the decider with +`InvalidDeclaredFamiliesError`; the catalog tier without any Family +declaration has no instantiation contract. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.model import Manufacturer + + +@dataclass(frozen=True) +class DefineModel: + """Define a new vendor-catalog Model with manufacturer, part_number, families.""" + + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + version_tag: str | None = None diff --git a/apps/api/src/cora/equipment/features/define_model/decider.py b/apps/api/src/cora/equipment/features/define_model/decider.py new file mode 100644 index 000000000..e79813274 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/decider.py @@ -0,0 +1,75 @@ +"""Pure decider for the `DefineModel` command. + +Pure function: given the current Model state (None for a fresh +stream) and a `DefineModel` command, returns the events to append. +No I/O, no awaits, no side effects. + +`now` and `new_id` are injected by the application handler from the +Clock and IdGenerator ports. The handler is also responsible for +the cross-BC `family_lookup` validation (every element of +`command.declared_families` must resolve to a registered Family); +that lookup happens before the decider is called, since the decider +is pure and the Family lookup is impure. + +The `version_tag` VO validation is performed here (defensively) when +the caller supplies one; an empty initial tag is rejected with +`InvalidModelVersionTagError` just like Family's version_tag. +""" + +from datetime import datetime +from uuid import UUID + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + Model, + ModelAlreadyExistsError, + ModelDefined, + ModelName, + ModelVersionTag, + PartNumber, +) +from cora.equipment.features.define_model.command import DefineModel + + +def decide( + state: Model | None, + command: DefineModel, + *, + now: datetime, + new_id: UUID, +) -> list[ModelDefined]: + """Decide the events produced by defining a new model. + + Invariants: + - State must be None (genesis-only) -> ModelAlreadyExistsError + - declared_families must be non-empty -> InvalidDeclaredFamiliesError + - Name must be valid -> InvalidModelNameError (via ModelName VO) + - Part number must be valid -> InvalidPartNumberError + (via PartNumber VO) + - version_tag, if supplied, must be valid + -> InvalidModelVersionTagError (via ModelVersionTag VO) + + The Manufacturer VO's own pairing invariant is enforced by the + Manufacturer dataclass itself before the command reaches the + decider (raises InvalidManufacturerIdentifierPairingError). + """ + if state is not None: + raise ModelAlreadyExistsError(state.id) + if not command.declared_families: + raise InvalidDeclaredFamiliesError + name = ModelName(command.name) + part_number = PartNumber(command.part_number) + if command.version_tag is not None: + # Validate but discard the VO; the event carries the raw str. + ModelVersionTag(command.version_tag) + return [ + ModelDefined( + model_id=new_id, + name=name.value, + manufacturer=command.manufacturer, + part_number=part_number.value, + declared_families=command.declared_families, + occurred_at=now, + version_tag=command.version_tag, + ) + ] diff --git a/apps/api/src/cora/equipment/features/define_model/handler.py b/apps/api/src/cora/equipment/features/define_model/handler.py new file mode 100644 index 000000000..4a160abfc --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/handler.py @@ -0,0 +1,174 @@ +"""Application handler for the `define_model` slice. + +Same shape as the locked cross-BC create-style command pattern +(register_actor / register_subject / define_zone / define_conduit +/ define_policy / define_family). Module-as-namespace: callers use +`from cora.equipment.features import define_model` then +`define_model.bind(deps)` returning a `define_model.Handler`. + +Cross-BC concern: this handler loads `list_family_ids` from the +Family read repo before invoking the decider, and verifies every +element of `command.declared_families` resolves to a registered +non-Deprecated Family. On miss, raises `FamilyNotFoundError` +(404) carrying the FIRST missing Family id. Operators iterating +through a multi-family catalog entry get a single missing id at +a time, matching the operational pattern. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.family import FamilyNotFoundError, list_family_ids +from cora.equipment.aggregates.model import event_type_name, to_payload +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "DefineModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare define_model handler, the type returned by `bind()`. + + Has no idempotency_key kwarg. The cross-BC `with_idempotency` + decorator wraps a bare Handler into an `IdempotentHandler`; + production wiring in `wire.py` always wraps. Tests can use bare + Handler directly when they don't need idempotency semantics. + + `causation_id` is the id of the event/message that triggered + this command (None for HTTP / MCP root calls; sagas / process + managers pass the upstream event's id). + """ + + async def __call__( + self, + command: DefineModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +class IdempotentHandler(Protocol): + """define_model handler with Idempotency-Key support.""" + + async def __call__( + self, + command: DefineModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + idempotency_key: str | None = None, + ) -> UUID: ... + + +def bind(deps: Kernel) -> Handler: + """Build a define_model handler closed over the shared deps.""" + + async def handler( + command: DefineModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "define_model.start", + command_name=_COMMAND_NAME, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "define_model.denied", + command_name=_COMMAND_NAME, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + # Cross-BC family_lookup: every declared family must resolve. + # Bulk single-query approach (cheap at pilot scale, <50 Families). + # Trigger to switch to per-id load: facility Family count crosses + # ~500 OR p95 of define_model crosses 200ms. + known_family_ids = set(await list_family_ids(deps.pool)) + missing = command.declared_families - known_family_ids + if missing: + # Sorted for deterministic error ordering across runs; surface + # the first missing id (operators get one at a time). + first_missing = sorted(missing, key=str)[0] + _log.info( + "define_model.family_not_found", + command_name=_COMMAND_NAME, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + first_missing_family_id=str(first_missing), + missing_count=len(missing), + ) + raise FamilyNotFoundError(first_missing) + + new_id = deps.id_generator.new_id() + now = deps.clock.now() + + domain_events = decide( + state=None, + command=command, + now=now, + new_id=new_id, + ) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=new_id, + expected_version=0, + events=new_events, + ) + + _log.info( + "define_model.success", + command_name=_COMMAND_NAME, + model_id=str(new_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + ) + return new_id + + return handler diff --git a/apps/api/src/cora/equipment/features/define_model/route.py b/apps/api/src/cora/equipment/features/define_model/route.py new file mode 100644 index 000000000..cf5585d29 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/route.py @@ -0,0 +1,183 @@ +"""HTTP route for the `define_model` slice. + +Pydantic request/response schemas + APIRouter for `POST /models`. +The slice's BC-level wiring (`cora.equipment.routes.register_equipment_routes`) +includes this router on the FastAPI app. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.handler import IdempotentHandler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class ManufacturerBody(BaseModel): + """Pydantic mirror of the Manufacturer VO for the request body. + + `identifier` and `identifier_type` are both optional but must be + supplied together or both omitted (the pairing invariant is + enforced at the VO constructor; a bare identifier with no scheme + cannot be resolved). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, `identifier_type` " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +class DefineModelRequest(BaseModel): + """Body for `POST /models`.""" + + name: str = Field( + ..., + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Display name for the new Model.", + ) + manufacturer: ManufacturerBody = Field( + ..., + description="Vendor identity (name plus optional ROR/GRID/ISNI identifier).", + ) + part_number: str = Field( + ..., + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Vendor SKU; case-sensitive (RV120CCHL and rv120cchl are different Newport entries)." + ), + ) + declared_families: list[UUID] = Field( + ..., + min_length=1, + description=( + "Family ids the catalog entry satisfies. At least one required; " + "deduplicated server-side." + ), + ) + version_tag: str | None = Field( + default=None, + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description="Optional initial revision label (e.g., 'rev-A').", + ) + + +class DefineModelResponse(BaseModel): + """Response body for `POST /models`.""" + + model_id: UUID + + +def _get_handler(request: Request) -> IdempotentHandler: + handler: IdempotentHandler = request.app.state.equipment.define_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models", + status_code=status.HTTP_201_CREATED, + response_model=DefineModelResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated (for example whitespace-only name).", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "One or more declared families does not resolve to a registered Family.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation OR Idempotency-Key " + "was reused with a different request body." + ), + }, + }, + summary="Define a new vendor-catalog Model", +) +async def post_models( + body: DefineModelRequest, + handler: Annotated[IdempotentHandler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], + idempotency_key: Annotated[ + str | None, + Header( + alias="Idempotency-Key", + description=( + "Optional client-supplied unique key per logical request. " + "Retries with the same key + same body return the cached " + "response instead of re-creating the model." + ), + ), + ] = None, +) -> DefineModelResponse: + model_id = await handler( + DefineModel( + name=body.name, + manufacturer=body.manufacturer.to_vo(), + part_number=body.part_number, + declared_families=frozenset(body.declared_families), + version_tag=body.version_tag, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + idempotency_key=idempotency_key, + ) + return DefineModelResponse(model_id=model_id) diff --git a/apps/api/src/cora/equipment/features/define_model/tool.py b/apps/api/src/cora/equipment/features/define_model/tool.py new file mode 100644 index 000000000..38bf87fa2 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/tool.py @@ -0,0 +1,144 @@ +"""MCP tool for the `define_model` slice. + +Surfaces the same handler the REST route uses, exposed as a Model +Context Protocol tool. +""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.handler import IdempotentHandler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +class ManufacturerInput(BaseModel): + """MCP tool input mirror of the Manufacturer VO. + + `identifier` and `identifier_type` are both optional but must be + supplied together (pairing invariant; enforced at the VO). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, identifier_type " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +class DefineModelOutput(BaseModel): + """Structured output of the `define_model` MCP tool.""" + + model_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> None: + """Register the `define_model` tool on the given MCP server.""" + + @mcp.tool( + name="define_model", + description=( + "Define a new vendor-catalog Model with manufacturer, part number, " + "and the set of Family ids it satisfies." + ), + ) + async def define_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + name: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Display name for the new Model.", + ), + ], + manufacturer: Annotated[ + ManufacturerInput, + Field(description="Vendor identity (name plus optional identifier)."), + ], + part_number: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Vendor SKU; case-sensitive (RV120CCHL and rv120cchl are " + "different Newport entries)." + ), + ), + ], + declared_families: Annotated[ + list[UUID], + Field( + min_length=1, + description=( + "Family ids the catalog entry satisfies. At least one required; " + "deduplicated server-side." + ), + ), + ], + version_tag: Annotated[ + str | None, + Field( + default=None, + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description="Optional initial revision label (e.g., 'rev-A').", + ), + ] = None, + ) -> DefineModelOutput: + handler = get_handler() + model_id = await handler( + DefineModel( + name=name, + manufacturer=manufacturer.to_vo(), + part_number=part_number, + declared_families=frozenset(declared_families), + version_tag=version_tag, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return DefineModelOutput(model_id=model_id) diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 2f5efe4b5..253b496ac 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -116,6 +116,7 @@ decommission_frame, decommission_mount, define_family, + define_model, degrade_asset, deprecate_family, enter_maintenance, @@ -208,6 +209,7 @@ async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONRes def register_equipment_routes(app: FastAPI) -> None: """Attach Equipment slice routers and exception handlers to the FastAPI app.""" app.include_router(define_family.router) + app.include_router(define_model.router) app.include_router(get_family.router) app.include_router(version_family.router) app.include_router(deprecate_family.router) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index a79ba7831..ce9dbf6e0 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -20,6 +20,7 @@ from cora.equipment.features.decommission_frame import tool as decommission_frame_tool from cora.equipment.features.decommission_mount import tool as decommission_mount_tool from cora.equipment.features.define_family import tool as define_family_tool +from cora.equipment.features.define_model import tool as define_model_tool from cora.equipment.features.degrade_asset import tool as degrade_asset_tool from cora.equipment.features.deprecate_family import ( tool as deprecate_family_tool, @@ -69,6 +70,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().define_family, ) + define_model_tool.register( + mcp, + get_handler=lambda: get_handlers().define_model, + ) get_family_tool.register( mcp, get_handler=lambda: get_handlers().get_family, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 964c2da25..bc7bb1f1b 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -39,6 +39,7 @@ decommission_frame, decommission_mount, define_family, + define_model, degrade_asset, deprecate_family, enter_maintenance, @@ -84,6 +85,7 @@ class EquipmentHandlers: """ define_family: define_family.IdempotentHandler + define_model: define_model.IdempotentHandler get_family: get_family.Handler version_family: version_family.Handler deprecate_family: deprecate_family.Handler @@ -133,6 +135,18 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DefineFamily", bc=_BC, ), + define_model=with_tracing( + with_idempotency( + define_model.bind(deps), + deps.idempotency_store, + command_name="DefineModel", + serialize_result=str, + deserialize_result=UUID, + lock_stale_seconds=deps.settings.idempotency_lock_stale_seconds, + ), + command_name="DefineModel", + bc=_BC, + ), get_family=with_tracing( get_family.bind(deps), command_name="GetFamily", diff --git a/apps/api/tests/contract/test_define_model_contract.py b/apps/api/tests/contract/test_define_model_contract.py new file mode 100644 index 000000000..a7a4fd886 --- /dev/null +++ b/apps/api/tests/contract/test_define_model_contract.py @@ -0,0 +1,192 @@ +"""Contract tests for `POST /models`. + +Covers the create-style slice surface: happy-path 201 + UUID, Pydantic +422 on schema misses (missing required, empty `declared_families`), +domain 400 on whitespace-only name, and the cross-BC +`with_idempotency` decorator semantics (same key + same body returns +the cached id; same key + different body returns 422). Test keys are +short to stay below the gitleaks generic-API-key entropy threshold. + +The Model handler enforces a cross-BC precondition: every entry in +`declared_families` must resolve via the Family read repo's +`list_family_ids`, which is pool-backed and returns `[]` in the +in-memory TestClient harness. We monkeypatch the symbol imported into +the handler module to a fixed accept-all stub so the contract surface +under test stays focused on HTTP shape + idempotency semantics (the +cross-BC lookup is exercised at the unit and integration tiers). +""" + +from collections.abc import Iterator +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_family_ids` so `_FIXED_FAMILY_ID` always resolves. + + The handler imports `list_family_ids` by name at module load, so we + patch the binding in the handler's namespace (the one it actually + calls), mirroring the unit-test pattern in + `tests/unit/equipment/test_define_model_handler.py`. + """ + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _body( + *, + name: str = "ANT130-L", + part_number: str = "ANT130-L-150", + manufacturer_name: str = "Aerotech", +) -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": manufacturer_name}, + "part_number": part_number, + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +@pytest.mark.contract +def test_post_models_happy_path_returns_201_and_uuid(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + response = client.post("/models", json=_body()) + + assert response.status_code == 201 + UUID(response.json()["model_id"]) # parses + + +@pytest.mark.contract +def test_post_models_missing_required_field_returns_422(accept_family: UUID) -> None: + """Pydantic schema validation: missing `part_number`.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + del body["part_number"] + response = client.post("/models", json=body) + + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_models_empty_declared_families_returns_422(accept_family: UUID) -> None: + """Pydantic `min_length=1` on `declared_families`.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + body["declared_families"] = [] + response = client.post("/models", json=body) + + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_models_whitespace_only_name_returns_400(accept_family: UUID) -> None: + """Domain `InvalidModelNameError` after Pydantic min_length=1 passes.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body(name=" ") + response = client.post("/models", json=body) + + assert response.status_code == 400 + detail = response.json()["detail"] + assert "name" in detail.lower() + + +@pytest.mark.contract +def test_post_models_unknown_declared_family_returns_404(accept_family: UUID) -> None: + """Cross-BC precondition surfaces as 404 when a declared family does + not resolve against `list_family_ids`.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + body["declared_families"] = [str(uuid4())] + response = client.post("/models", json=body) + + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_models_without_key_creates_distinct_models_on_each_call( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + r1 = client.post("/models", json=body) + r2 = client.post("/models", json=body) + + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["model_id"] != r2.json()["model_id"] + + +@pytest.mark.contract +def test_post_models_same_key_and_body_returns_same_model_id(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = {"Idempotency-Key": "mk-1"} + body = _body() + r1 = client.post("/models", json=body, headers=headers) + r2 = client.post("/models", json=body, headers=headers) + + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["model_id"] == r2.json()["model_id"] + + +@pytest.mark.contract +def test_post_models_same_key_different_body_returns_422(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = {"Idempotency-Key": "mk-2"} + r1 = client.post("/models", json=_body(name="ANT130-L"), headers=headers) + r2 = client.post("/models", json=_body(name="Other"), headers=headers) + + assert r1.status_code == 201 + assert r2.status_code == 422 + body = r2.json() + assert "detail" in body + assert "idempotency-key" in body["detail"].lower() + + +@pytest.mark.contract +def test_post_models_different_keys_create_distinct_models(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + r1 = client.post("/models", json=body, headers={"Idempotency-Key": "mk-A"}) + r2 = client.post("/models", json=body, headers={"Idempotency-Key": "mk-B"}) + + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["model_id"] != r2.json()["model_id"] + + +@pytest.mark.contract +def test_post_models_cached_response_returns_valid_uuid(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = {"Idempotency-Key": "mk-uuid"} + body = _body() + r1 = client.post("/models", json=body, headers=headers) + r2 = client.post("/models", json=body, headers=headers) + + UUID(r1.json()["model_id"]) # parses + UUID(r2.json()["model_id"]) # parses + assert r1.json()["model_id"] == r2.json()["model_id"] diff --git a/apps/api/tests/contract/test_define_model_mcp_tool.py b/apps/api/tests/contract/test_define_model_mcp_tool.py new file mode 100644 index 000000000..ba4541692 --- /dev/null +++ b/apps/api/tests/contract/test_define_model_mcp_tool.py @@ -0,0 +1,132 @@ +"""Contract tests for the `define_model` MCP tool. + +Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_family_ids` lookup returns `[]` and every `define_model` call +surfaces `FamilyNotFoundError`. The structured-output happy path is +pinned at the integration tier (see `tests/integration/equipment/`); +this file pins the MCP-wire shape: tool registration, description, +structured-output schema (declares `model_id`), and the failure +branches reachable without a database. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +@pytest.mark.contract +def test_mcp_lists_define_model_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "define_model" in tool_names + + +@pytest.mark.contract +def test_mcp_define_model_tool_description_matches_vendor_catalog_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + define_model = tools_by_name["define_model"] + description = define_model["description"] + assert "vendor-catalog Model" in description + assert "manufacturer" in description + assert "part number" in description + assert "Family" in description + + +@pytest.mark.contract +def test_mcp_define_model_tool_advertises_model_id_in_output_schema() -> None: + """Pin the structured-output schema: DefineModelOutput.model_id is on the + wire. The actual happy-path emission of a model_id value requires a + Postgres pool (cross-BC family_lookup), so it is covered at the + integration tier; here we verify the schema contract.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 4, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + define_model = tools_by_name["define_model"] + output_schema = define_model["outputSchema"] + assert "model_id" in output_schema["properties"] + assert output_schema["properties"]["model_id"]["format"] == "uuid" + assert "model_id" in output_schema["required"] + + +@pytest.mark.contract +def test_mcp_define_model_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "define_model", + "arguments": {}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_define_model_tool_returns_iserror_on_unregistered_family() -> None: + """Cross-BC check: declared_families must resolve to a registered Family. + An unknown family id surfaces FamilyNotFoundError, which FastMCP wraps as + isError: true with a 'not found' diagnostic (same shape as the REST 404). + """ + unknown_family_id = str(uuid4()) + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "define_model", + "arguments": { + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [unknown_family_id], + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/integration/test_define_model_handler_postgres.py b/apps/api/tests/integration/test_define_model_handler_postgres.py new file mode 100644 index 000000000..0dd119440 --- /dev/null +++ b/apps/api/tests/integration/test_define_model_handler_postgres.py @@ -0,0 +1,265 @@ +"""End-to-end integration test: define_model handler against real Postgres. + +Pinned: +- Happy path: ModelDefined round-trips through jsonb with the + manufacturer sub-dict, sorted declared_families UUID list, and the + optional version_tag dropped when None. +- Cross-BC family_lookup: define_model loads the Family read repo + (`list_family_ids`) against the real `proj_equipment_family_summary` + projection before invoking the decider, so an unregistered family id + surfaces `FamilyNotFoundError` (404), and a registered family id + proceeds to event-write. +- Idempotency: the wired `IdempotentHandler` (`define_model` slice in + `wire_equipment`) round-trips the Brandur envelope at the storage + tier: same Idempotency-Key plus same command body returns the same + model_id without writing a second Model stream. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import define_family, define_model +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.wire import wire_equipment +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 5, 31, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Pump the Equipment-owned projections to flush `FamilyDefined` + rows into `proj_equipment_family_summary`. The Family read repo + (`list_family_ids`) called by `define_model.handler` queries this + projection, so an upstream `define_family` write is only visible + to the next `define_model` after a drain. + """ + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_define_model_persists_event_to_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: register a Family, define a Model declaring it, + read the events back from the event store, and verify ModelDefined + is persisted with the expected payload shape (sorted + declared_families, no version_tag key when None).""" + family_id = UUID("01900000-0000-7000-8000-000000054c01") + family_event_id = UUID("01900000-0000-7000-8000-000000054c0e") + model_id = UUID("01900000-0000-7000-8000-00000054ca01") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca0e") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, model_event_id], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + returned_id = await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/02jbv0t02"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == model_id + + events, version = await deps.event_store.load("Model", model_id) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "ModelDefined" + assert stored.schema_version == 1 + assert stored.payload == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/02jbv0t02", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + # Sorted by UUID string form (deterministic). Pinned by + # tests/unit/equipment/test_model_events.py. + "declared_families": [str(family_id)], + # version_tag is omitted from payload when None (per to_payload). + "occurred_at": _NOW.isoformat(), + } + assert stored.correlation_id == _CORRELATION_ID + assert stored.causation_id is None + assert stored.event_id == model_event_id + assert stored.metadata == {"command": "DefineModel"} + assert stored.occurred_at == _NOW + + +@pytest.mark.integration +async def test_define_model_rejects_unregistered_family_id( + db_pool: asyncpg.Pool, +) -> None: + """Cross-BC family_lookup: defining a Model with a Family id that + has never been registered raises `FamilyNotFoundError`. Real PG + lookup against `proj_equipment_family_summary`; no Family seeded.""" + model_id = UUID("01900000-0000-7000-8000-00000054ca02") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca0f") + missing_family_id = UUID("01900000-0000-7000-8000-0000000bad01") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[model_id, model_event_id]) + + with pytest.raises(FamilyNotFoundError) as exc_info: + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({missing_family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.family_id == missing_family_id + + # No Model stream was written on the rejected command. + _, version = await deps.event_store.load("Model", model_id) + assert version == 0 + + +@pytest.mark.integration +async def test_define_model_proceeds_when_family_is_registered( + db_pool: asyncpg.Pool, +) -> None: + """Cross-BC family_lookup success: a Family seeded via + `define_family` plus a projection drain resolves through + `list_family_ids`, and `define_model` proceeds to event-write.""" + family_id = UUID("01900000-0000-7000-8000-000000054d01") + family_event_id = UUID("01900000-0000-7000-8000-000000054d0e") + model_id = UUID("01900000-0000-7000-8000-00000054ca03") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, model_event_id], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + returned_id = await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == model_id + + events, version = await deps.event_store.load("Model", model_id) + assert version == 1 + assert events[0].event_type == "ModelDefined" + assert events[0].payload["declared_families"] == [str(family_id)] + + +@pytest.mark.integration +async def test_define_model_idempotency_key_replay_returns_same_model_id( + db_pool: asyncpg.Pool, +) -> None: + """Same Idempotency-Key plus same command body returns the same + model_id without writing a second Model stream. Storage-cardinality + pin against the Brandur cache-miss regression class.""" + family_id = UUID("01900000-0000-7000-8000-000000054e01") + family_event_id = UUID("01900000-0000-7000-8000-000000054e0e") + first_model_id = UUID("01900000-0000-7000-8000-00000054ca21") + first_event_id = UUID("01900000-0000-7000-8000-00000054ca2e") + # The second model_id is queued but never consumed: the Brandur + # cache hit short-circuits before `id_generator.new_id()` runs on + # the replay. The id sits unclaimed at the end of the test. + unused_replay_model_id = UUID("01900000-0000-7000-8000-00000054ca31") + unused_replay_event_id = UUID("01900000-0000-7000-8000-00000054ca3e") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + first_model_id, + first_event_id, + unused_replay_model_id, + unused_replay_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + handler = wire_equipment(deps).define_model + idempotency_key = f"ck-define-model-{uuid4().hex[:8]}" + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ) + + first_id = await handler( + cmd, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + idempotency_key=idempotency_key, + ) + second_id = await handler( + cmd, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + idempotency_key=idempotency_key, + ) + + # Same model_id replayed via Brandur cache. + assert first_id == second_id + assert first_id == first_model_id + + # Exactly one Model stream exists, with exactly one ModelDefined event. + _, version = await deps.event_store.load("Model", first_model_id) + assert version == 1 + # The "second" queued model_id was never written. + _, second_version = await deps.event_store.load("Model", unused_replay_model_id) + assert second_version == 0 diff --git a/apps/api/tests/unit/equipment/test_define_model_decider.py b/apps/api/tests/unit/equipment/test_define_model_decider.py new file mode 100644 index 000000000..92f20354c --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_model_decider.py @@ -0,0 +1,144 @@ +"""Pure-decider tests for the `define_model` slice.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelAlreadyExistsError, + ModelName, + ModelStatus, + PartNumber, +) +from cora.equipment.features.define_model import DefineModel, decide + +_NOW = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _minimal_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + ) + + +@pytest.mark.unit +def test_decide_emits_model_defined_for_minimal_command() -> None: + cmd = _minimal_command() + new_id = uuid4() + events = decide(None, cmd, now=_NOW, new_id=new_id) + assert len(events) == 1 + event = events[0] + assert event.model_id == new_id + assert event.name == "Aerotech ANT130-L" + assert event.manufacturer == cmd.manufacturer + assert event.part_number == "ANT130-L" + assert event.declared_families == cmd.declared_families + assert event.occurred_at == _NOW + assert event.version_tag is None + + +@pytest.mark.unit +def test_decide_carries_version_tag_when_supplied() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + version_tag="rev-A", + ) + events = decide(None, cmd, now=_NOW, new_id=uuid4()) + assert events[0].version_tag == "rev-A" + + +@pytest.mark.unit +def test_decide_carries_full_manufacturer_triple() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + ) + events = decide(None, cmd, now=_NOW, new_id=uuid4()) + assert events[0].manufacturer.identifier is not None + assert events[0].manufacturer.identifier.value == "https://ror.org/05gvnxz63" + assert events[0].manufacturer.identifier_type is ManufacturerIdentifierType.ROR + + +@pytest.mark.unit +def test_decide_rejects_when_stream_already_has_state() -> None: + existing = Model( + id=uuid4(), + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({uuid4()}), + status=ModelStatus.DEFINED, + ) + with pytest.raises(ModelAlreadyExistsError): + decide(existing, _minimal_command(), now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_empty_declared_families() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset(), + ) + with pytest.raises(InvalidDeclaredFamiliesError): + decide(None, cmd, now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_invalid_name() -> None: + cmd = DefineModel( + name=" ", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + ) + with pytest.raises(InvalidModelNameError): + decide(None, cmd, now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_invalid_part_number() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="", + declared_families=frozenset({uuid4()}), + ) + with pytest.raises(InvalidPartNumberError): + decide(None, cmd, now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_empty_initial_version_tag() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + version_tag=" ", + ) + with pytest.raises(InvalidModelVersionTagError): + decide(None, cmd, now=_NOW, new_id=uuid4()) diff --git a/apps/api/tests/unit/equipment/test_define_model_decider_properties.py b/apps/api/tests/unit/equipment/test_define_model_decider_properties.py new file mode 100644 index 000000000..74b2bf973 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_model_decider_properties.py @@ -0,0 +1,367 @@ +"""Property-based tests for `define_model.decide` (Equipment BC). + +Mirrors the Recipe BC `define_capability` decider-PBT pattern on an +Equipment BC create-style command with bounded-text VOs, an optional +paired manufacturer identifier, and a non-empty `declared_families` +frozenset invariant. Universal claims across generated inputs: + + - state=None + valid command emits a single ModelDefined with the + injected new_id / now and the command's manufacturer / parts / + declared_families intact. + - state=Model always raises ModelAlreadyExistsError, carrying the + pre-existing model_id. + - Empty `declared_families` always raises InvalidDeclaredFamiliesError. + - Empty, whitespace-only, or over-long `name` always raises + InvalidModelNameError (via the ModelName VO). + - Empty, whitespace-only, or over-long `part_number` always raises + InvalidPartNumberError (via the PartNumber VO). + - Empty, whitespace-only, or over-long `version_tag` (when non-None) + always raises InvalidModelVersionTagError (via the ModelVersionTag + VO). + - Pure: same (state, command, now, new_id) returns the same events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelAlreadyExistsError, + ModelDefined, + ModelName, + PartNumber, +) +from cora.equipment.features import define_model +from cora.equipment.features.define_model import DefineModel +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + + +_NAME = printable_ascii_text(min_size=1, max_size=MODEL_NAME_MAX_LENGTH) +_PART_NUMBER = printable_ascii_text(min_size=1, max_size=MODEL_PART_NUMBER_MAX_LENGTH) +_MANUFACTURER_NAME = printable_ascii_text(min_size=1, max_size=MANUFACTURER_NAME_MAX_LENGTH) +_MANUFACTURER_IDENTIFIER = printable_ascii_text( + min_size=1, max_size=MANUFACTURER_IDENTIFIER_MAX_LENGTH +) +_VERSION_TAG = printable_ascii_text(min_size=1, max_size=MODEL_VERSION_TAG_MAX_LENGTH) + +# 1 to 5 distinct Family ids per the prompt; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + +# Negative-case alphabet for bounded-text VOs: empty, whitespace-only, +# and over-long strings. Each ALWAYS raises after `.strip()` either +# yields "" (empty/whitespace) or exceeds the length cap. +_WHITESPACE_CHARS = st.sampled_from([" ", "\t", "\n", "\r", " ", " \t\n"]) + + +def _invalid_bounded_text(max_length: int) -> st.SearchStrategy[str]: + """Empty, whitespace-only, or over-length strings for VO rejection PBTs. + + Bounded-text VOs reject when `.strip()` yields an empty string or + when the trimmed length exceeds `max_length`. This strategy unions + all three rejection shapes; every drawn value triggers the VO's + error class. + """ + return st.one_of( + st.just(""), + _WHITESPACE_CHARS, + printable_ascii_text(min_size=max_length + 1, max_size=max_length + 50), + ) + + +@st.composite +def _manufacturers(draw: st.DrawFn) -> Manufacturer: + """Build a Manufacturer VO with optional paired identifier + type. + + The pairing invariant is enforced inside the Manufacturer dataclass: + `identifier` and `identifier_type` are both set or both None. This + composite draws both halves together so generated Manufacturers + always satisfy the invariant. + """ + name = ManufacturerName(draw(_MANUFACTURER_NAME)) + has_identifier = draw(st.booleans()) + if not has_identifier: + return Manufacturer(name=name) + identifier = ManufacturerIdentifier(draw(_MANUFACTURER_IDENTIFIER)) + identifier_type = draw(st.sampled_from(list(ManufacturerIdentifierType))) + return Manufacturer(name=name, identifier=identifier, identifier_type=identifier_type) + + +def _command( + *, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None = None, +) -> DefineModel: + return DefineModel( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + + +def _model(model_id: UUID) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({model_id}), + ) + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_emits_exactly_one_event_with_injected_fields( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty stream + valid command -> single ModelDefined with injected ids/time.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = define_model.decide(state=None, command=command, now=now, new_id=new_id) + assert events == [ + ModelDefined( + model_id=new_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + occurred_at=now, + version_tag=version_tag, + ) + ] + + +@pytest.mark.unit +@given( + existing_id=st.uuids(), + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_on_existing_state_always_raises_already_exists( + existing_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Any non-None state -> ModelAlreadyExistsError, regardless of command.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(ModelAlreadyExistsError) as exc: + define_model.decide(state=_model(existing_id), command=command, now=now, new_id=new_id) + assert exc.value.model_id == existing_id + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_empty_declared_families_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty declared_families -> InvalidDeclaredFamiliesError, regardless of other fields.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=frozenset[UUID](), + version_tag=version_tag, + ) + with pytest.raises(InvalidDeclaredFamiliesError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_invalid_bounded_text(MODEL_NAME_MAX_LENGTH), + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_invalid_name_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty, whitespace-only, or over-long name -> InvalidModelNameError.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelNameError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_invalid_bounded_text(MODEL_PART_NUMBER_MAX_LENGTH), + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_invalid_part_number_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty, whitespace-only, or over-long part_number -> InvalidPartNumberError.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidPartNumberError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_invalid_bounded_text(MODEL_VERSION_TAG_MAX_LENGTH), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_invalid_version_tag_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, + new_id: UUID, +) -> None: + """Empty, whitespace-only, or over-long non-None version_tag -> + InvalidModelVersionTagError. None is excluded from this strategy + because None is a valid version_tag (no initial revision label). + """ + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelVersionTagError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_is_pure_same_input_same_output( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Two calls with identical args return identical events (no clock leakage).""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + first = define_model.decide(state=None, command=command, now=now, new_id=new_id) + second = define_model.decide(state=None, command=command, now=now, new_id=new_id) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_define_model_handler.py b/apps/api/tests/unit/equipment/test_define_model_handler.py new file mode 100644 index 000000000..17dcf0386 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_model_handler.py @@ -0,0 +1,209 @@ +"""Unit tests for the `define_model` application handler. + +Mirrors the `define_family` handler test's shape (same Handler +protocol, same authorize + event-store wiring, same Kernel deps). +The Model-specific addition is the cross-BC `list_family_ids` +precondition: the handler resolves every element of +`command.declared_families` against the Family read repo before +invoking the decider, and raises `FamilyNotFoundError` on miss. + +`list_family_ids` reads from `proj_equipment_family_summary` and +returns `[]` when `pool is None` (the in-memory test default). +Tests that need a populated Family set monkeypatch the symbol +imported into `define_model.handler` rather than seeding a real +projection. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import UnauthorizedError +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import Manufacturer, ManufacturerName +from cora.equipment.features import define_model +from cora.equipment.features.define_model import DefineModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-000000007ab1") +_EVENT_ID = UUID("01900000-0000-7000-8000-000000007be1") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fa02") +_FAMILY_MISSING_ID = UUID("01900000-0000-7000-8000-00000000fa99") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_family_ids` as imported into the handler module. + + The handler does `from cora.equipment.aggregates.family import + list_family_ids` at module load, so monkeypatching the source + function leaves the bound name in the handler stale. We patch the + name in the handler module's namespace, which is the binding the + handler actually calls. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _fake_list_family_ids, + ) + + +def _command(declared_families: frozenset[UUID]) -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=declared_families, + ) + + +@pytest.mark.unit +async def test_handler_returns_generated_model_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + handler = define_model.bind(deps) + + result = await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert result == _NEW_ID + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps(deny=True) + handler = define_model.bind(deps) + + with pytest.raises(UnauthorizedError) as exc_info: + await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_does_not_append_when_denied( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store, deny=True) + handler = define_model.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _NEW_ID) + assert events == [] + assert version == 0 + + +@pytest.mark.unit +async def test_handler_raises_family_not_found_for_unregistered_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cross-BC precondition: declared_families containing an id that + doesn't resolve via `list_family_ids` raises `FamilyNotFoundError`.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + handler = define_model.bind(deps) + + with pytest.raises(FamilyNotFoundError): + await handler( + _command(frozenset({_FAMILY_A_ID, _FAMILY_MISSING_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_proceeds_when_all_declared_families_resolve( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every declared family resolves against the fake lookup, so the + handler reaches the decider and returns the new id.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + deps = _build_deps() + handler = define_model.bind(deps) + + result = await handler( + _command(frozenset({_FAMILY_A_ID, _FAMILY_B_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert result == _NEW_ID + + +@pytest.mark.unit +async def test_handler_appends_model_defined_event_to_store( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """After success, the event store receives stream_type='Model', + expected_version=0, and exactly one NewEvent of type 'ModelDefined'.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + handler = define_model.bind(deps) + + await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _NEW_ID) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "ModelDefined" + assert stored.schema_version == 1 + assert stored.correlation_id == _CORRELATION_ID + assert stored.causation_id is None + assert stored.event_id == _EVENT_ID + assert stored.metadata == {"command": "DefineModel"} + assert stored.occurred_at == _NOW + assert stored.payload["model_id"] == str(_NEW_ID) + assert stored.payload["name"] == "Aerotech ANT130-L" + assert stored.payload["part_number"] == "ANT130-L" + assert stored.payload["declared_families"] == [str(_FAMILY_A_ID)] From ef2a07eeaad835dcbc57825966a26669255442ad Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 17:27:57 +0300 Subject: [PATCH 03/11] feat(equipment): version_model slice (Model revision genesis) Adds POST /models/{model_id}/versions REST endpoint and version_model MCP tool. Update-style command issuing a new revision of an existing Model with replacement name, manufacturer, part_number, declared_families, and version_tag. Command shape: VersionModel(model_id, name, manufacturer, part_number, declared_families, version_tag). version_tag is required here (unlike define_model where it is optional). Wholesale replacement semantics: ModelVersioned event replaces the entire user-facing tuple. Per the design memo Lock, version_model does NOT re-validate declared_families against Family existence (that validation is the add_model_family slice's job for incremental edits). Callers who want to add or remove individual families should use the targeted-mutation slices add_model_family / remove_model_family. FSM: (Defined | Versioned) -> Versioned. Rejected from Deprecated with ModelCannotVersionError (409). Update-style: no idempotency wrapping (domain-idempotent via ModelCannotVersionError on retry). Slice files (10 new): - src/cora/equipment/features/version_model/{command,decider, handler,route,tool,__init__}.py - tests/unit/equipment/test_version_model_decider.py (decider tests covering happy path from Defined + Versioned, rejection from Deprecated, all VO-level validation failures, ModelNotFoundError on missing stream) - tests/unit/equipment/test_version_model_decider_properties.py (Hypothesis PBT mirroring define_model_decider_properties.py pattern; properties over all valid commands + state transitions) - tests/unit/equipment/test_version_model_handler.py (handler tests covering authorize denial, ModelNotFoundError, ModelCannotVersionError, happy path event-store append) - tests/contract/test_version_model_endpoint.py (REST contract: 204 on success, 422 on validation, 400 on domain invariant, 404 on unknown model_id, 409 on Deprecated) - tests/contract/test_version_model_mcp_tool.py (MCP contract: tool registered, description, missing-arg isError, not-found isError) - tests/integration/test_version_model_handler_postgres.py (PG integration: seed Family + define Model + version Model, verify ModelVersioned event payload, verify failure paths) Wiring (3 edits): - equipment/routes.py: include version_model.router - equipment/wire.py: EquipmentHandlers.version_model field (bare Handler, no IdempotentHandler) + with_tracing builder (no idempotency wrap; update-style) - equipment/tools.py: register version_model MCP tool openapi.json regenerated to include POST /models/{model_id}/ versions. Tests: 38 new tests pass + 14178 architecture tests pass. ruff clean, pyright 0/0/0. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 149 +++++++ .../features/version_model/__init__.py | 32 ++ .../features/version_model/command.py | 38 ++ .../features/version_model/decider.py | 95 ++++ .../features/version_model/handler.py | 144 ++++++ .../equipment/features/version_model/route.py | 185 ++++++++ .../equipment/features/version_model/tool.py | 143 ++++++ apps/api/src/cora/equipment/routes.py | 2 + apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 7 + .../contract/test_version_model_endpoint.py | 203 +++++++++ .../contract/test_version_model_mcp_tool.py | 105 +++++ .../test_version_model_handler_postgres.py | 252 +++++++++++ .../equipment/test_version_model_decider.py | 194 ++++++++ .../test_version_model_decider_properties.py | 421 ++++++++++++++++++ .../equipment/test_version_model_handler.py | 248 +++++++++++ 16 files changed, 2223 insertions(+) create mode 100644 apps/api/src/cora/equipment/features/version_model/__init__.py create mode 100644 apps/api/src/cora/equipment/features/version_model/command.py create mode 100644 apps/api/src/cora/equipment/features/version_model/decider.py create mode 100644 apps/api/src/cora/equipment/features/version_model/handler.py create mode 100644 apps/api/src/cora/equipment/features/version_model/route.py create mode 100644 apps/api/src/cora/equipment/features/version_model/tool.py create mode 100644 apps/api/tests/contract/test_version_model_endpoint.py create mode 100644 apps/api/tests/contract/test_version_model_mcp_tool.py create mode 100644 apps/api/tests/integration/test_version_model_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_version_model_decider.py create mode 100644 apps/api/tests/unit/equipment/test_version_model_decider_properties.py create mode 100644 apps/api/tests/unit/equipment/test_version_model_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 91d3d26de..40017dfc9 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -10830,6 +10830,55 @@ "title": "VersionMethodRequest", "type": "object" }, + "VersionModelRequest": { + "description": "Body for `POST /models/{model_id}/versions`.\n\nA new version IS a new declaration: every field REPLACES the prior\nvalue wholesale (no diff/merge semantics). `version_tag` is\nREQUIRED here, unlike `define_model` where it is optional.", + "properties": { + "declared_families": { + "description": "Replacement Family ids the catalog entry satisfies at this version. At least one required; deduplicated server-side.", + "items": { + "format": "uuid", + "type": "string" + }, + "minItems": 1, + "title": "Declared Families", + "type": "array" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerBody", + "description": "Replacement vendor identity (name plus optional ROR/GRID/ISNI identifier)." + }, + "name": { + "description": "Replacement display name for the Model at this version.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "part_number": { + "description": "Replacement vendor SKU; case-sensitive (RV120CCHL and rv120cchl are different Newport entries).", + "maxLength": 100, + "minLength": 1, + "title": "Part Number", + "type": "string" + }, + "version_tag": { + "description": "Operator-supplied label for this revision (for example 'rev-B', '2026-Q3'). Free text; institution-specific.", + "maxLength": 50, + "minLength": 1, + "title": "Version Tag", + "type": "string" + } + }, + "required": [ + "name", + "manufacturer", + "part_number", + "declared_families", + "version_tag" + ], + "title": "VersionModelRequest", + "type": "object" + }, "VersionPlanRequest": { "description": "Body for `POST /plans/{plan_id}/version`.", "properties": { @@ -23333,6 +23382,106 @@ ] } }, + "/models/{model_id}/versions": { + "post": { + "operationId": "post_models_versions_models__model_id__versions_post", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionModelRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (for example whitespace-only name, whitespace-only part_number, whitespace-only version_tag, or empty declared_families)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is in `Deprecated` status (version requires `Defined` or `Versioned`), OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation." + } + }, + "summary": "Issue a new version declaration for an existing Model", + "tags": [ + "equipment" + ] + } + }, "/mounts": { "post": { "operationId": "post_mounts_mounts_post", diff --git a/apps/api/src/cora/equipment/features/version_model/__init__.py b/apps/api/src/cora/equipment/features/version_model/__init__.py new file mode 100644 index 000000000..22f652e83 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/__init__.py @@ -0,0 +1,32 @@ +"""Vertical slice for the `VersionModel` command. + +Module-as-namespace surface: + + from cora.equipment.features import version_model + + cmd = version_model.VersionModel( + model_id=..., + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({rotary_stage_family_id}), + version_tag="v2", + ) + handler = version_model.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.version_model import tool +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.decider import decide +from cora.equipment.features.version_model.handler import Handler, bind +from cora.equipment.features.version_model.route import router + +__all__ = [ + "Handler", + "VersionModel", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/version_model/command.py b/apps/api/src/cora/equipment/features/version_model/command.py new file mode 100644 index 000000000..5560d387d --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/command.py @@ -0,0 +1,38 @@ +"""The `VersionModel` command, intent dataclass for this slice. + +Multi-source transition: `Defined | Versioned -> Versioned`. Operators +issue a new `version_tag` (free text like "v2", "2026-Q3") to mark a +revision of the vendor-catalog entry. + +A new version IS a new declaration: `name`, `manufacturer`, +`part_number`, `declared_families`, and `version_tag` are ALL required +at version time. The supplied values REPLACE the prior catalog entry +wholesale (no diff/merge semantics). Matches Family/Method/Plan/ +Practice replace-on-version precedent. + +`declared_families` must be non-empty: a catalog entry without any +Family declaration has no instantiation contract, and a re-authored +revision is no exception. Empty `frozenset()` is rejected by the +decider with `InvalidDeclaredFamiliesError`. + +`version_tag` is REQUIRED for `version_model` (unlike `define_model` +where the initial label is optional). The whole point of the slice is +to issue a new label. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.model import Manufacturer + + +@dataclass(frozen=True) +class VersionModel: + """Issue a new version label plus replacement catalog body for a Model.""" + + model_id: UUID + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + version_tag: str diff --git a/apps/api/src/cora/equipment/features/version_model/decider.py b/apps/api/src/cora/equipment/features/version_model/decider.py new file mode 100644 index 000000000..967ca24d9 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/decider.py @@ -0,0 +1,95 @@ +"""Pure decider for the `VersionModel` command. + +Multi-source-state transition: `Defined | Versioned -> Versioned`. +Both Defined (first revision) and Versioned (subsequent revisions) +are valid sources; only Deprecated is rejected. + +Source-state guard uses tuple-membership (same precedent as +version_family and decommission_asset). The decider validates the +bounded-text VOs defensively (`ModelName`, `PartNumber`, +`ModelVersionTag`) so direct in-process callers get the same +protection as API-boundary callers. `declared_families` cardinality +is checked here (must be non-empty); the `Manufacturer` pairing +invariant is enforced by the `Manufacturer` dataclass itself before +the command reaches the decider (raises +`InvalidManufacturerIdentifierPairingError`). + +The handler does NOT cross-BC-validate `declared_families` here: +per the design memo Lock, full-set re-validation at version time is +deferred to incremental `add_model_family` edits. `version_model` +accepts whatever `declared_families` the caller passes; downstream +slices catch stale family references at their own boundaries. + +## Deliberate divergence from strict-not-idempotent + +Most update-style transitions in the codebase are strict-not- +idempotent: re-mounting / re-activating / re-decommissioning raises. +version_model is the EXCEPTION (mirroring version_family). Calling +`version_model("v2")` twice in a row both succeed, producing two +`ModelVersioned` events with the same tag. This is intentional: +re-attestation is a legitimate audit moment ("the operator confirmed +v2 again on date X"), and the multi-source Versioned to Versioned +transition already permits the operation structurally. Tightening to +"must use a different tag" would couple the decider to history- +walking, which the eventual-consistency stance avoids. + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must be in {Defined, Versioned}, i.e., not Deprecated + -> ModelCannotVersionError(current_status=...) + - declared_families must be non-empty -> InvalidDeclaredFamiliesError + - Name must be valid -> InvalidModelNameError (via ModelName VO) + - Part number must be valid -> InvalidPartNumberError + (via PartNumber VO) + - version_tag must be valid -> InvalidModelVersionTagError + (via ModelVersionTag VO) +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + Model, + ModelCannotVersionError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersioned, + ModelVersionTag, + PartNumber, +) +from cora.equipment.features.version_model.command import VersionModel + +_VERSIONABLE_STATUSES: tuple[ModelStatus, ...] = ( + ModelStatus.DEFINED, + ModelStatus.VERSIONED, +) + + +def decide( + state: Model | None, + command: VersionModel, + *, + now: datetime, +) -> list[ModelVersioned]: + """Decide the events produced by versioning an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status not in _VERSIONABLE_STATUSES: + raise ModelCannotVersionError(state.id, current_status=state.status) + if not command.declared_families: + raise InvalidDeclaredFamiliesError + name = ModelName(command.name) + part_number = PartNumber(command.part_number) + version_tag = ModelVersionTag(command.version_tag) + return [ + ModelVersioned( + model_id=state.id, + name=name.value, + manufacturer=command.manufacturer, + part_number=part_number.value, + declared_families=command.declared_families, + version_tag=version_tag.value, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/version_model/handler.py b/apps/api/src/cora/equipment/features/version_model/handler.py new file mode 100644 index 000000000..ab6d66e13 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/handler.py @@ -0,0 +1,144 @@ +"""Application handler for the `version_model` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the version_family precedent (no idempotency wrapping; the command +carries `model_id` and `version_tag` and the handler logs both for +diagnostic visibility). + +Not idempotency-wrapped: re-versioning with the same tag is still a +real revision the operator wanted (matches version_family's +re-attestation stance). Domain-idempotent via `ModelCannotVersionError` +on retry from `Deprecated`. + +NO cross-BC family lookup here: per the Model design memo Lock, +`version_model` accepts whatever `declared_families` the caller +supplies without round-tripping the Family read repo. Incremental +declared-family edits use `add_model_family`, which is where the +cross-BC `list_family_ids` check lives. The wholesale replacement +that `version_model` performs is treated as authoritative operator +intent at version time. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "VersionModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every version_model handler implements.""" + + async def __call__( + self, + command: VersionModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a version_model handler closed over the shared deps.""" + + async def handler( + command: VersionModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "version_model.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + version_tag=command.version_tag, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "version_model.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + version_tag=command.version_tag, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "version_model.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + version_tag=command.version_tag, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/version_model/route.py b/apps/api/src/cora/equipment/features/version_model/route.py new file mode 100644 index 000000000..5b62e3435 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/route.py @@ -0,0 +1,185 @@ +"""HTTP route for the `version_model` slice. + +Action endpoint at `POST /models/{model_id}/versions`. Body carries +the full replacement declaration (name, manufacturer, part_number, +declared_families, version_tag). 204 No Content on success. A new +version IS a new declaration, so the supplied fields REPLACE the +prior values wholesale. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class ManufacturerBody(BaseModel): + """Pydantic mirror of the Manufacturer VO for the request body. + + `identifier` and `identifier_type` are both optional but must be + supplied together or both omitted (the pairing invariant is + enforced at the VO constructor; a bare identifier with no scheme + cannot be resolved). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, `identifier_type` " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +class VersionModelRequest(BaseModel): + """Body for `POST /models/{model_id}/versions`. + + A new version IS a new declaration: every field REPLACES the prior + value wholesale (no diff/merge semantics). `version_tag` is + REQUIRED here, unlike `define_model` where it is optional. + """ + + name: str = Field( + ..., + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Replacement display name for the Model at this version.", + ) + manufacturer: ManufacturerBody = Field( + ..., + description="Replacement vendor identity (name plus optional ROR/GRID/ISNI identifier).", + ) + part_number: str = Field( + ..., + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Replacement vendor SKU; case-sensitive (RV120CCHL and rv120cchl " + "are different Newport entries)." + ), + ) + declared_families: list[UUID] = Field( + ..., + min_length=1, + description=( + "Replacement Family ids the catalog entry satisfies at this " + "version. At least one required; deduplicated server-side." + ), + ) + version_tag: str = Field( + ..., + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description=( + "Operator-supplied label for this revision (for example " + "'rev-B', '2026-Q3'). Free text; institution-specific." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.version_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models/{model_id}/versions", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated (for example whitespace-only " + "name, whitespace-only part_number, whitespace-only " + "version_tag, or empty declared_families)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is in `Deprecated` status (version requires " + "`Defined` or `Versioned`), OR a concurrent write to the " + "same model stream conflicted (optimistic concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter or request body failed schema validation.", + }, + }, + summary="Issue a new version declaration for an existing Model", +) +async def post_models_versions( + model_id: Annotated[UUID, Path(description="Target model's id.")], + body: VersionModelRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + VersionModel( + model_id=model_id, + name=body.name, + manufacturer=body.manufacturer.to_vo(), + part_number=body.part_number, + declared_families=frozenset(body.declared_families), + version_tag=body.version_tag, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/version_model/tool.py b/apps/api/src/cora/equipment/features/version_model/tool.py new file mode 100644 index 000000000..61527f97a --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/tool.py @@ -0,0 +1,143 @@ +"""MCP tool for the `version_model` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +class ManufacturerInput(BaseModel): + """MCP tool input mirror of the Manufacturer VO. + + `identifier` and `identifier_type` are both optional but must be + supplied together (pairing invariant; enforced at the VO). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, identifier_type " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `version_model` tool on the given MCP server.""" + + @mcp.tool( + name="version_model", + description=( + "Issue a new version of a vendor-catalog Model with updated name, " + "manufacturer, part number, family set, and version tag. Accepts " + "both Defined and Versioned source states (subsequent revisions " + "allowed). Deprecated Models cannot be re-versioned. A new version " + "IS a new declaration; the supplied fields REPLACE the prior values " + "wholesale." + ), + ) + async def version_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + name: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Replacement display name for the new version.", + ), + ], + manufacturer: Annotated[ + ManufacturerInput, + Field(description="Replacement vendor identity (name plus optional identifier)."), + ], + part_number: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Replacement vendor SKU; case-sensitive (RV120CCHL and rv120cchl " + "are different Newport entries)." + ), + ), + ], + declared_families: Annotated[ + list[UUID], + Field( + min_length=1, + description=( + "Replacement Family id set the catalog entry satisfies. At least " + "one required; deduplicated server-side." + ), + ), + ], + version_tag: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description=( + "Operator-supplied label for this revision (for example 'v2', '2026-Q3')." + ), + ), + ], + ) -> None: + handler = get_handler() + await handler( + VersionModel( + model_id=model_id, + name=name, + manufacturer=manufacturer.to_vo(), + part_number=part_number, + declared_families=frozenset(declared_families), + version_tag=version_tag, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 253b496ac..21a5bcec5 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -141,6 +141,7 @@ update_frame_placement, update_mount_placement, version_family, + version_model, ) @@ -210,6 +211,7 @@ def register_equipment_routes(app: FastAPI) -> None: """Attach Equipment slice routers and exception handlers to the FastAPI app.""" app.include_router(define_family.router) app.include_router(define_model.router) + app.include_router(version_model.router) app.include_router(get_family.router) app.include_router(version_family.router) app.include_router(deprecate_family.router) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index ce9dbf6e0..0e0f36008 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -57,6 +57,7 @@ from cora.equipment.features.update_frame_placement import tool as update_frame_placement_tool from cora.equipment.features.update_mount_placement import tool as update_mount_placement_tool from cora.equipment.features.version_family import tool as version_family_tool +from cora.equipment.features.version_model import tool as version_model_tool from cora.equipment.wire import EquipmentHandlers @@ -74,6 +75,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().define_model, ) + version_model_tool.register( + mcp, + get_handler=lambda: get_handlers().version_model, + ) get_family_tool.register( mcp, get_handler=lambda: get_handlers().get_family, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index bc7bb1f1b..8c624e378 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -64,6 +64,7 @@ update_frame_placement, update_mount_placement, version_family, + version_model, ) from cora.infrastructure.idempotency import with_idempotency from cora.infrastructure.kernel import Kernel @@ -86,6 +87,7 @@ class EquipmentHandlers: define_family: define_family.IdempotentHandler define_model: define_model.IdempotentHandler + version_model: version_model.Handler get_family: get_family.Handler version_family: version_family.Handler deprecate_family: deprecate_family.Handler @@ -147,6 +149,11 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DefineModel", bc=_BC, ), + version_model=with_tracing( + version_model.bind(deps), + command_name="VersionModel", + bc=_BC, + ), get_family=with_tracing( get_family.bind(deps), command_name="GetFamily", diff --git a/apps/api/tests/contract/test_version_model_endpoint.py b/apps/api/tests/contract/test_version_model_endpoint.py new file mode 100644 index 000000000..65e0516fe --- /dev/null +++ b/apps/api/tests/contract/test_version_model_endpoint.py @@ -0,0 +1,203 @@ +"""Contract tests for `POST /models/{model_id}/versions`. + +Action endpoint carrying a full replacement body (name, manufacturer, +part_number, declared_families, version_tag). Multi-source guard +(Defined | Versioned -> Versioned). + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_family_ids` lookup performed by `define_model` returns `[]` and +every model-seeding call would fail. We stub the symbol to a +fixed accept-all set so we can seed a Model via `POST /models` and +exercise `POST /models/{model_id}/versions` end-to-end. + +The 409-on-Deprecated path appends a `ModelDeprecated` event directly +to the in-memory event store (no `deprecate_model` slice exists yet), +then exercises the route and expects 409. +""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + ModelDeprecated, + event_type_name, + to_payload, +) +from cora.infrastructure.event_envelope import to_new_event + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_OTHER_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa02") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_family_ids` so the seeding `define_model` succeeds.""" + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_body(*, name: str = "ANT130-L") -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _version_body( + *, + name: str = "ANT130-L rev-B", + part_number: str = "ANT130-L-B", + version_tag: str = "v2", + declared_families: list[str] | None = None, +) -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": "Aerotech"}, + "part_number": part_number, + "declared_families": declared_families + if declared_families is not None + else [str(_FIXED_FAMILY_ID), str(_OTHER_FAMILY_ID)], + "version_tag": version_tag, + } + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +async def _append_deprecated_event(app: FastAPI, model_id: UUID) -> None: + deps = app.state.deps + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=uuid4(), + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + _, current_version = await deps.event_store.load("Model", model_id) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=current_version, + events=[new_event], + ) + + +@pytest.mark.contract +def test_post_version_model_returns_204_from_defined_state(accept_family: UUID) -> None: + """First revision (Defined -> Versioned).""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/versions", + json=_version_body(), + ) + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_post_version_model_returns_204_from_versioned_state(accept_family: UUID) -> None: + """Subsequent revision (Versioned -> Versioned).""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + first = client.post(f"/models/{model_id}/versions", json=_version_body(version_tag="v1")) + assert first.status_code == 204 + second = client.post(f"/models/{model_id}/versions", json=_version_body(version_tag="v2")) + assert second.status_code == 204 + + +@pytest.mark.contract +def test_post_version_model_returns_404_when_model_does_not_exist(accept_family: UUID) -> None: + _ = accept_family + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post(f"/models/{missing_id}/versions", json=_version_body()) + assert response.status_code == 404 + + +@pytest.mark.contract +async def test_post_version_model_returns_409_when_deprecated(accept_family: UUID) -> None: + """Deprecated Models cannot be re-versioned.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + await _append_deprecated_event(client.app, model_id) # type: ignore[arg-type] + response = client.post(f"/models/{model_id}/versions", json=_version_body()) + assert response.status_code == 409 + assert "Deprecated" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_version_model_missing_required_field_returns_422(accept_family: UUID) -> None: + """Pydantic schema validation: missing `version_tag`.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + body = _version_body() + del body["version_tag"] + response = client.post(f"/models/{model_id}/versions", json=body) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_version_model_empty_declared_families_returns_422(accept_family: UUID) -> None: + """Pydantic `min_length=1` on `declared_families`.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + body = _version_body(declared_families=[]) + response = client.post(f"/models/{model_id}/versions", json=body) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_version_model_whitespace_only_name_returns_400(accept_family: UUID) -> None: + """Domain `InvalidModelNameError` after Pydantic min_length=1 passes.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/versions", + json=_version_body(name=" "), + ) + assert response.status_code == 400 + assert "name" in response.json()["detail"].lower() + + +@pytest.mark.contract +def test_post_version_model_rejects_invalid_path_uuid_with_422(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + response = client.post("/models/not-a-uuid/versions", json=_version_body()) + assert response.status_code == 422 diff --git a/apps/api/tests/contract/test_version_model_mcp_tool.py b/apps/api/tests/contract/test_version_model_mcp_tool.py new file mode 100644 index 000000000..b604ba113 --- /dev/null +++ b/apps/api/tests/contract/test_version_model_mcp_tool.py @@ -0,0 +1,105 @@ +"""Contract tests for the `version_model` MCP tool. + +In-memory contract harness has no Postgres pool, so happy-path +versioning end-to-end requires seeding a Model first. We exercise: +- tool registration (the tool appears in `tools/list`) +- description matches the wholesale-replacement spec +- missing-argument call surfaces `isError: true` +- unknown `model_id` surfaces `ModelNotFoundError` as `isError: true` +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +@pytest.mark.contract +def test_mcp_lists_version_model_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "version_model" in tool_names + + +@pytest.mark.contract +def test_mcp_version_model_tool_description_matches_replacement_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + version_model = tools_by_name["version_model"] + description = version_model["description"] + assert "vendor-catalog Model" in description + assert "Deprecated" in description + assert "REPLACE" in description + + +@pytest.mark.contract +def test_mcp_version_model_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "version_model", + "arguments": {}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_version_model_tool_returns_iserror_for_unknown_model() -> None: + unknown_id = str(uuid4()) + family_id = str(uuid4()) + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "version_model", + "arguments": { + "model_id": unknown_id, + "name": "ANT130-L rev-B", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L-B", + "declared_families": [family_id], + "version_tag": "v2", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/integration/test_version_model_handler_postgres.py b/apps/api/tests/integration/test_version_model_handler_postgres.py new file mode 100644 index 000000000..b23ef9fd5 --- /dev/null +++ b/apps/api/tests/integration/test_version_model_handler_postgres.py @@ -0,0 +1,252 @@ +"""End-to-end integration test: version_model against real Postgres. + +Round-trip: define Family, define Model, version Model, and read the +events back from the event store. Verifies the ModelVersioned payload +shape (sorted declared_families, manufacturer sub-dict, version_tag), +the multi-source guard (ModelNotFoundError on a missing stream), and +the Deprecated rejection (ModelCannotVersionError after appending a +ModelDeprecated event onto the same stream). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotVersionError, + ModelDeprecated, + ModelNotFoundError, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.features import define_family, define_model, version_model +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.version_model import VersionModel +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined into `proj_equipment_family_summary` so the + Family read repo called by `define_model.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_version_model_persists_event_with_full_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed Family + define Model + version Model. Verify + ModelVersioned is persisted with the wholesale-replacement payload + (sorted declared_families, manufacturer sub-dict, version_tag).""" + family_id = UUID("01900000-0000-7000-8000-00000060d001") + family_event_id = UUID("01900000-0000-7000-8000-00000060d00e") + other_family_id = UUID("01900000-0000-7000-8000-00000060d002") + other_family_event_id = UUID("01900000-0000-7000-8000-00000060d00f") + model_id = UUID("01900000-0000-7000-8000-00000060ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000060ca0e") + version_event_id = UUID("01900000-0000-7000-8000-00000060ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + other_family_id, + other_family_event_id, + model_id, + define_event_id, + version_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-L rev-B", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L-B", + declared_families=frozenset({family_id, other_family_id}), + version_tag="2026-Q3", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelVersioned"] + versioned = events[1] + assert versioned.event_id == version_event_id + assert versioned.metadata == {"command": "VersionModel"} + assert versioned.payload == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L rev-B", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L-B", + "declared_families": sorted([str(family_id), str(other_family_id)]), + "version_tag": "2026-Q3", + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the wholesale replacement. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.name.value == "Aerotech ANT130-L rev-B" + assert state.part_number.value == "ANT130-L-B" + assert state.declared_families == frozenset({family_id, other_family_id}) + assert state.version == "2026-Q3" + + +@pytest.mark.integration +async def test_version_model_raises_not_found_for_unknown_id( + db_pool: asyncpg.Pool, +) -> None: + """Versioning a model whose stream has no events raises ModelNotFoundError.""" + missing_id = UUID("01900000-0000-7000-8000-0000000bad02") + family_id = UUID("01900000-0000-7000-8000-0000000bad03") + version_event_id = UUID("01900000-0000-7000-8000-0000000bad0e") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[version_event_id]) + + with pytest.raises(ModelNotFoundError) as exc_info: + await version_model.bind(deps)( + VersionModel( + model_id=missing_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + version_tag="v2", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == missing_id + + _, version = await deps.event_store.load("Model", missing_id) + assert version == 0 + + +@pytest.mark.integration +async def test_version_model_raises_cannot_version_after_deprecation( + db_pool: asyncpg.Pool, +) -> None: + """After appending a ModelDeprecated event, version_model raises + ModelCannotVersionError and no new event is written.""" + family_id = UUID("01900000-0000-7000-8000-00000060e001") + family_event_id = UUID("01900000-0000-7000-8000-00000060e00e") + model_id = UUID("01900000-0000-7000-8000-00000060ca21") + define_event_id = UUID("01900000-0000-7000-8000-00000060ca2e") + deprecate_event_id = UUID("01900000-0000-7000-8000-00000060ca2f") + # The version_model call lands on the disallowed source and rejects + # before consuming any id; queue an extra to be safe. + unused_version_event_id = UUID("01900000-0000-7000-8000-00000060ca3a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + unused_version_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=1, + events=[ + to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=deprecate_event_id, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + with pytest.raises(ModelCannotVersionError): + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-L rev-B", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L-B", + declared_families=frozenset({family_id}), + version_tag="v2", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + _, version = await deps.event_store.load("Model", model_id) + assert version == 2 diff --git a/apps/api/tests/unit/equipment/test_version_model_decider.py b/apps/api/tests/unit/equipment/test_version_model_decider.py new file mode 100644 index 000000000..5eb00746f --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_model_decider.py @@ -0,0 +1,194 @@ +"""Pure-decider tests for the `version_model` slice. + +Multi-source-state guard: `Defined | Versioned -> Versioned`. Both +source states are valid; only Deprecated is rejected. Bounded-text VOs +(name, part_number, version_tag) and `declared_families` cardinality +are validated defensively in the decider so direct callers get the same +protection as API-boundary callers. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersioned, + PartNumber, +) +from cora.equipment.features import version_model +from cora.equipment.features.version_model import VersionModel + +_NOW = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({uuid4()}), + status=status, + version=version, + ) + + +def _command( + model_id: object, + *, + name: str = "Aerotech ANT130-L rev-B", + part_number: str = "ANT130-L-B", + version_tag: str = "v2", + declared_families: frozenset[object] | None = None, +) -> VersionModel: + return VersionModel( + model_id=model_id, # type: ignore[arg-type] + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=declared_families # type: ignore[arg-type] + if declared_families is not None + else frozenset({uuid4()}), + version_tag=version_tag, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "source", + [ModelStatus.DEFINED, ModelStatus.VERSIONED], +) +def test_decide_emits_model_versioned_for_each_allowed_source_status( + source: ModelStatus, +) -> None: + """Both Defined and Versioned are valid sources; the emitted event + carries the same wholesale-replacement payload regardless of which + one preceded.""" + state = _model(status=source) + new_families = frozenset({uuid4(), uuid4()}) + events = version_model.decide( + state=state, + command=_command( + state.id, + name="Aerotech ANT130-L rev-B", + part_number="ANT130-L-B", + version_tag="v2", + declared_families=new_families, + ), + now=_NOW, + ) + assert events == [ + ModelVersioned( + model_id=state.id, + name="Aerotech ANT130-L rev-B", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L-B", + declared_families=new_families, + version_tag="v2", + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + version_model.decide( + state=None, + command=_command(target_id), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_cannot_version_for_deprecated_status() -> None: + """Deprecated is the only disallowed source state. Re-versioning a + deprecated model raises (would otherwise un-deprecate via side + effect).""" + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotVersionError) as exc_info: + version_model.decide( + state=state, + command=_command(state.id), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_rejects_empty_declared_families() -> None: + state = _model() + with pytest.raises(InvalidDeclaredFamiliesError): + version_model.decide( + state=state, + command=_command(state.id, declared_families=frozenset()), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_invalid_name() -> None: + state = _model() + with pytest.raises(InvalidModelNameError): + version_model.decide( + state=state, + command=_command(state.id, name=" "), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_invalid_part_number() -> None: + state = _model() + with pytest.raises(InvalidPartNumberError): + version_model.decide( + state=state, + command=_command(state.id, part_number=""), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_invalid_version_tag_for_whitespace_only() -> None: + state = _model() + with pytest.raises(InvalidModelVersionTagError): + version_model.decide( + state=state, + command=_command(state.id, version_tag=" "), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_allows_versioning_with_same_tag_for_re_attestation() -> None: + """Deliberate divergence from strict-not-idempotent: calling + version_model with a tag that already matches state.version + succeeds rather than raising. Re-attestation is a legitimate audit + moment, mirroring the version_family precedent.""" + state = _model(status=ModelStatus.VERSIONED, version="v2") + events = version_model.decide( + state=state, + command=_command(state.id, version_tag="v2"), + now=_NOW, + ) + assert len(events) == 1 + assert events[0].version_tag == "v2" diff --git a/apps/api/tests/unit/equipment/test_version_model_decider_properties.py b/apps/api/tests/unit/equipment/test_version_model_decider_properties.py new file mode 100644 index 000000000..197c48032 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_model_decider_properties.py @@ -0,0 +1,421 @@ +"""Property-based tests for `version_model.decide` (Equipment BC). + +Mirrors the `define_model` decider-PBT pattern, adapted for the +multi-source `Defined | Versioned -> Versioned` transition. Universal +claims across generated inputs: + + - state in {Defined, Versioned} + valid command emits exactly one + ModelVersioned with the wholesale replacement payload and the + injected `now` timestamp. + - state=None always raises ModelNotFoundError, regardless of command. + - state.status==Deprecated always raises ModelCannotVersionError. + - Empty `declared_families` always raises InvalidDeclaredFamiliesError. + - Empty, whitespace-only, or over-long `name` always raises + InvalidModelNameError (via the ModelName VO). + - Empty, whitespace-only, or over-long `part_number` always raises + InvalidPartNumberError (via the PartNumber VO). + - Empty, whitespace-only, or over-long `version_tag` always raises + InvalidModelVersionTagError (via the ModelVersionTag VO). The tag + is REQUIRED for version_model (unlike define_model). + - Pure: same (state, command, now) returns the same events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersioned, + PartNumber, +) +from cora.equipment.features import version_model +from cora.equipment.features.version_model import VersionModel +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + + +_NAME = printable_ascii_text(min_size=1, max_size=MODEL_NAME_MAX_LENGTH) +_PART_NUMBER = printable_ascii_text(min_size=1, max_size=MODEL_PART_NUMBER_MAX_LENGTH) +_MANUFACTURER_NAME = printable_ascii_text(min_size=1, max_size=MANUFACTURER_NAME_MAX_LENGTH) +_MANUFACTURER_IDENTIFIER = printable_ascii_text( + min_size=1, max_size=MANUFACTURER_IDENTIFIER_MAX_LENGTH +) +_VERSION_TAG = printable_ascii_text(min_size=1, max_size=MODEL_VERSION_TAG_MAX_LENGTH) + +# 1 to 5 distinct Family ids per the prompt; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + +# Versionable source statuses: Defined (first revision) and Versioned +# (subsequent revisions). Deprecated is excluded; it's covered by a +# dedicated rejection property. +_VERSIONABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# Negative-case alphabet for bounded-text VOs. +_WHITESPACE_CHARS = st.sampled_from([" ", "\t", "\n", "\r", " ", " \t\n"]) + + +def _invalid_bounded_text(max_length: int) -> st.SearchStrategy[str]: + """Empty, whitespace-only, or over-length strings for VO rejection PBTs.""" + return st.one_of( + st.just(""), + _WHITESPACE_CHARS, + printable_ascii_text(min_size=max_length + 1, max_size=max_length + 50), + ) + + +@st.composite +def _manufacturers(draw: st.DrawFn) -> Manufacturer: + """Build a Manufacturer VO with optional paired identifier + type.""" + name = ManufacturerName(draw(_MANUFACTURER_NAME)) + has_identifier = draw(st.booleans()) + if not has_identifier: + return Manufacturer(name=name) + identifier = ManufacturerIdentifier(draw(_MANUFACTURER_IDENTIFIER)) + identifier_type = draw(st.sampled_from(list(ManufacturerIdentifierType))) + return Manufacturer(name=name, identifier=identifier, identifier_type=identifier_type) + + +def _command( + *, + model_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, +) -> VersionModel: + return VersionModel( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + + +def _model(model_id: UUID, *, status: ModelStatus) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({model_id}), + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_emits_exactly_one_event_with_injected_fields( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Versionable source + valid command -> single ModelVersioned with the + wholesale-replacement payload and injected `now`.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = version_model.decide(state=state, command=command, now=now) + assert events == [ + ModelVersioned( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_on_empty_state_always_raises_not_found( + model_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(ModelNotFoundError) as exc: + version_model.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_on_deprecated_state_always_raises_cannot_version( + model_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotVersionError.""" + state = _model(model_id, status=ModelStatus.DEPRECATED) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(ModelCannotVersionError) as exc: + version_model.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_with_empty_declared_families_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + version_tag: str, + now: datetime, +) -> None: + """Empty declared_families -> InvalidDeclaredFamiliesError, regardless + of other fields.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=frozenset[UUID](), + version_tag=version_tag, + ) + with pytest.raises(InvalidDeclaredFamiliesError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_invalid_bounded_text(MODEL_NAME_MAX_LENGTH), + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_with_invalid_name_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long name -> InvalidModelNameError.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelNameError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_invalid_bounded_text(MODEL_PART_NUMBER_MAX_LENGTH), + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_with_invalid_part_number_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long part_number -> InvalidPartNumberError.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidPartNumberError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_invalid_bounded_text(MODEL_VERSION_TAG_MAX_LENGTH), + now=aware_datetimes(), +) +def test_version_model_with_invalid_version_tag_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long version_tag -> InvalidModelVersionTagError. + The tag is REQUIRED here (unlike define_model where None is valid).""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelVersionTagError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_is_pure_same_input_same_output( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Two calls with identical args return identical events.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + first = version_model.decide(state=state, command=command, now=now) + second = version_model.decide(state=state, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_version_model_handler.py b/apps/api/tests/unit/equipment/test_version_model_handler.py new file mode 100644 index 000000000..b2a3359c6 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_model_handler.py @@ -0,0 +1,248 @@ +"""Unit tests for the `version_model` application handler. + +Update-style handler (mirrors version_family): load + fold + decide + +append. Not idempotency-wrapped. No cross-BC family lookup at version +time (per the design memo Lock: incremental edits go through +add_model_family). Tests cover auth deny, multi-source guard, the +Deprecated rejection, the ModelNotFoundError on a missing stream, and +the appended event payload shape. + +The Deprecated path seeds a `ModelDeprecated` event directly onto the +in-memory store (no `deprecate_model` slice exists yet), then invokes +the version handler and expects `ModelCannotVersionError`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotVersionError, + ModelDeprecated, + ModelNotFoundError, + event_type_name, + to_payload, +) +from cora.equipment.features import define_model, version_model +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.version_model import VersionModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ab11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ab12") +_VERSIONED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ab13") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ab14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fa02") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _VERSIONED_EVENT_ID, _DEPRECATED_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_family_ids` as imported into the define_model handler. + + `version_model` does NOT call `list_family_ids`, but the + seeding call to `define_model` does. We stub it accept-all so the + seed succeeds in the in-memory harness. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +def _version_command( + *, + name: str = "Aerotech ANT130-L rev-B", + part_number: str = "ANT130-L-B", + version_tag: str = "v2", + declared_families: frozenset[UUID] | None = None, +) -> VersionModel: + return VersionModel( + model_id=_MODEL_ID, + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=declared_families + if declared_families is not None + else frozenset({_FAMILY_A_ID, _FAMILY_B_ID}), + version_tag=version_tag, + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_deprecated_model(deps: Kernel, store: InMemoryEventStore) -> None: + """Append a `ModelDeprecated` event directly so the model lands in + Deprecated state without going through a `deprecate_model` slice + (which does not exist yet).""" + await _seed_model(deps) + deprecated = ModelDeprecated( + model_id=_MODEL_ID, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=_DEPRECATED_EVENT_ID, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + await store.append( + stream_type="Model", + stream_id=_MODEL_ID, + expected_version=1, + events=[new_event], + ) + + +@pytest.mark.unit +async def test_handler_returns_none_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await version_model.bind(deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + +@pytest.mark.unit +async def test_handler_appends_model_versioned_event_with_replacement_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + await version_model.bind(deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelVersioned"] + versioned = events[1] + assert versioned.event_id == _VERSIONED_EVENT_ID + assert versioned.metadata == {"command": "VersionModel"} + assert versioned.payload["model_id"] == str(_MODEL_ID) + assert versioned.payload["name"] == "Aerotech ANT130-L rev-B" + assert versioned.payload["part_number"] == "ANT130-L-B" + assert versioned.payload["version_tag"] == "v2" + assert versioned.payload["declared_families"] == sorted([str(_FAMILY_A_ID), str(_FAMILY_B_ID)]) + assert versioned.payload["manufacturer"] == {"name": "Aerotech"} + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_model_does_not_exist() -> None: + deps = _build_deps() + handler = version_model.bind(deps) + + with pytest.raises(ModelNotFoundError): + await handler( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_cannot_version_when_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_deprecated_model(deps, store) + + with pytest.raises(ModelCannotVersionError): + await version_model.bind(deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await version_model.bind(deny_deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +def test_wire_equipment_includes_version_model() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.version_model) From b0bf16586b24a0abd4503e1efbf04d2f58d6137a Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 17:41:41 +0300 Subject: [PATCH 04/11] feat(equipment): deprecate_model slice (Model lifecycle terminal) Adds POST /models/{model_id}/deprecation REST endpoint and deprecate_model MCP tool. Update-style command marking a Model as no longer recommended for new Assets. Command shape: DeprecateModel(model_id, reason). Reason is a 1-500 char operator-supplied rationale (trimmed at the ModelDeprecationReason VO). FSM: (Defined | Versioned) -> Deprecated. Rejected from Deprecated with ModelCannotDeprecateError (409). Update-style: no idempotency wrapping; domain-idempotent via the Cannot error on retry. Deprecation is an AUTHORING signal, not a runtime gate. Existing Assets with model_id pointing at a Deprecated Model continue to function (mirrors the Family-deprecation posture per the design memo Lock). Once Deprecated, no further ModelVersioned, ModelFamilyAdded, or ModelFamilyRemoved events accepted (Genesis-NoOp-or-Reject pattern; declared_families preserved across deprecation for audit). Slice files (10 new): - src/cora/equipment/features/deprecate_model/{command,decider, handler,route,tool,__init__}.py - tests/unit/equipment/test_deprecate_model_decider.py - tests/unit/equipment/test_deprecate_model_decider_properties.py (Hypothesis PBT) - tests/unit/equipment/test_deprecate_model_handler.py - tests/contract/test_deprecate_model_endpoint.py - tests/contract/test_deprecate_model_mcp_tool.py - tests/integration/test_deprecate_model_handler_postgres.py Wiring (3 edits): routes.py + wire.py + tools.py mirroring the version_model wiring shape (bare Handler, no idempotency). openapi.json regenerated to include POST /models/{model_id}/ deprecation. Tests: 35 new tests pass + 14242 architecture tests pass. ruff clean, pyright 0/0/0. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 117 +++++++++ .../features/deprecate_model/__init__.py | 28 +++ .../features/deprecate_model/command.py | 23 ++ .../features/deprecate_model/decider.py | 64 +++++ .../features/deprecate_model/handler.py | 137 +++++++++++ .../features/deprecate_model/route.py | 107 +++++++++ .../features/deprecate_model/tool.py | 57 +++++ apps/api/src/cora/equipment/routes.py | 2 + apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 7 + .../contract/test_deprecate_model_endpoint.py | 146 ++++++++++++ .../contract/test_deprecate_model_mcp_tool.py | 100 ++++++++ .../test_deprecate_model_handler_postgres.py | 195 +++++++++++++++ .../equipment/test_deprecate_model_decider.py | 164 +++++++++++++ ...test_deprecate_model_decider_properties.py | 185 +++++++++++++++ .../equipment/test_deprecate_model_handler.py | 224 ++++++++++++++++++ 16 files changed, 1561 insertions(+) create mode 100644 apps/api/src/cora/equipment/features/deprecate_model/__init__.py create mode 100644 apps/api/src/cora/equipment/features/deprecate_model/command.py create mode 100644 apps/api/src/cora/equipment/features/deprecate_model/decider.py create mode 100644 apps/api/src/cora/equipment/features/deprecate_model/handler.py create mode 100644 apps/api/src/cora/equipment/features/deprecate_model/route.py create mode 100644 apps/api/src/cora/equipment/features/deprecate_model/tool.py create mode 100644 apps/api/tests/contract/test_deprecate_model_endpoint.py create mode 100644 apps/api/tests/contract/test_deprecate_model_mcp_tool.py create mode 100644 apps/api/tests/integration/test_deprecate_model_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_deprecate_model_decider.py create mode 100644 apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py create mode 100644 apps/api/tests/unit/equipment/test_deprecate_model_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 40017dfc9..8695b09ed 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -4666,6 +4666,23 @@ "title": "DeprecateCapabilityRequest", "type": "object" }, + "DeprecateModelRequest": { + "description": "Body for `POST /models/{model_id}/deprecation`.\n\n`reason` is operator free text recording why the catalog entry is\nbeing retired (for example \"superseded by part RV120CCHL\", \"vendor\nEOL 2026\"). Trimmed and length-validated at the\n`ModelDeprecationReason` VO; whitespace-only is rejected as a\ndomain invariant violation (400).", + "properties": { + "reason": { + "description": "Operator-supplied rationale for retiring this Model (for example 'superseded by RV120CCHL', 'vendor EOL 2026'). Free text; trimmed server-side.", + "maxLength": 500, + "minLength": 1, + "title": "Reason", + "type": "string" + } + }, + "required": [ + "reason" + ], + "title": "DeprecateModelRequest", + "type": "object" + }, "DeregisterSupplyRequest": { "description": "Body for `POST /supplies/{supply_id}/deregister`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining why the supply is being deregistered. Examples:\n\"typo on scope at registration; re-registering correctly\",\n\"beamline retired\", \"duplicate of supply \".", "properties": { @@ -23382,6 +23399,106 @@ ] } }, + "/models/{model_id}/deprecation": { + "post": { + "operationId": "post_models_deprecation_models__model_id__deprecation_post", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeprecateModelRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (for example whitespace-only reason after trimming)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is already in `Deprecated` status (deprecate requires `Defined` or `Versioned`), OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation." + } + }, + "summary": "Mark an existing Model as deprecated", + "tags": [ + "equipment" + ] + } + }, "/models/{model_id}/versions": { "post": { "operationId": "post_models_versions_models__model_id__versions_post", diff --git a/apps/api/src/cora/equipment/features/deprecate_model/__init__.py b/apps/api/src/cora/equipment/features/deprecate_model/__init__.py new file mode 100644 index 000000000..08d1ba1da --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/__init__.py @@ -0,0 +1,28 @@ +"""Vertical slice for the `DeprecateModel` command. + +Module-as-namespace surface: + + from cora.equipment.features import deprecate_model + + cmd = deprecate_model.DeprecateModel( + model_id=..., + reason="Vendor end-of-life 2026-Q3; replaced by ANT130-LZS", + ) + handler = deprecate_model.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.deprecate_model import tool +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.decider import decide +from cora.equipment.features.deprecate_model.handler import Handler, bind +from cora.equipment.features.deprecate_model.route import router + +__all__ = [ + "DeprecateModel", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/deprecate_model/command.py b/apps/api/src/cora/equipment/features/deprecate_model/command.py new file mode 100644 index 000000000..60be7aa53 --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/command.py @@ -0,0 +1,23 @@ +"""The `DeprecateModel` command, intent dataclass for this slice. + +Multi-source transition: `Defined | Versioned -> Deprecated`. Carries +the target `model_id` plus an operator-supplied `reason` (1-500 chars +after trimming, validated via `ModelDeprecationReason` at the decider). + +`reason` is REQUIRED. Deprecation is an authoring signal that informs +later operators why the catalog entry should not be reused for new +Assets; recording a rationale keeps that signal actionable. Existing +Assets bound to the Model continue to function (deprecation is not a +runtime gate). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class DeprecateModel: + """Mark an existing model as no longer recommended for new Assets.""" + + model_id: UUID + reason: str diff --git a/apps/api/src/cora/equipment/features/deprecate_model/decider.py b/apps/api/src/cora/equipment/features/deprecate_model/decider.py new file mode 100644 index 000000000..0b9f05318 --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/decider.py @@ -0,0 +1,64 @@ +"""Pure decider for the `DeprecateModel` command. + +Multi-source-state transition: `Defined | Versioned -> Deprecated`. +Same source-set as version_model but the target is terminal. +Re-deprecating an already-Deprecated model raises (strict-not- +idempotent; mirrors deprecate_family). + +Source-state guard uses tuple-membership (same precedent as +deprecate_family and version_model). The decider validates the +bounded-text `reason` defensively via `ModelDeprecationReason` so +direct in-process callers get the same protection as API-boundary +callers. + +Once Deprecated, no further `ModelVersioned`, `ModelFamilyAdded`, or +`ModelFamilyRemoved` events are accepted (enforced by the relevant +deciders via their own source-state guards). Existing Assets bound to +the Model continue to function; deprecation is an authoring signal, +not a runtime gate. + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must be in {Defined, Versioned} + -> ModelCannotDeprecateError(current_status=...) + - reason must be valid -> InvalidModelDeprecationReasonError + (via ModelDeprecationReason VO) +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + Model, + ModelCannotDeprecateError, + ModelDeprecated, + ModelDeprecationReason, + ModelNotFoundError, + ModelStatus, +) +from cora.equipment.features.deprecate_model.command import DeprecateModel + +_DEPRECATABLE_STATUSES: tuple[ModelStatus, ...] = ( + ModelStatus.DEFINED, + ModelStatus.VERSIONED, +) + + +def decide( + state: Model | None, + command: DeprecateModel, + *, + now: datetime, +) -> list[ModelDeprecated]: + """Decide the events produced by deprecating an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status not in _DEPRECATABLE_STATUSES: + raise ModelCannotDeprecateError(state.id, current_status=state.status) + reason = ModelDeprecationReason(command.reason) + return [ + ModelDeprecated( + model_id=state.id, + reason=reason.value, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/deprecate_model/handler.py b/apps/api/src/cora/equipment/features/deprecate_model/handler.py new file mode 100644 index 000000000..c3aedb561 --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/handler.py @@ -0,0 +1,137 @@ +"""Application handler for the `deprecate_model` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the version_model precedent for Model-aggregate transitions and the +deprecate_family precedent for the deprecation command shape. + +Not idempotency-wrapped: domain-idempotent via +`ModelCannotDeprecateError` on retry from `Deprecated` (matches the +deprecate_family stance). The reason field is treated as authoring +intent; a fresh attempt against an already-Deprecated Model is a +real conflict the operator should see, not a silent no-op. + +NO cross-BC lookup here: deprecation is an authoring signal on the +Model stream itself. Existing Assets bound to this Model continue +to function; the runtime gate is elsewhere. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "DeprecateModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every deprecate_model handler implements.""" + + async def __call__( + self, + command: DeprecateModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a deprecate_model handler closed over the shared deps.""" + + async def handler( + command: DeprecateModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "deprecate_model.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "deprecate_model.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "deprecate_model.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/deprecate_model/route.py b/apps/api/src/cora/equipment/features/deprecate_model/route.py new file mode 100644 index 000000000..6d7c80fcd --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/route.py @@ -0,0 +1,107 @@ +"""HTTP route for the `deprecate_model` slice. + +Action endpoint at `POST /models/{model_id}/deprecation`. Body carries +the operator-supplied `reason` (1-500 chars, trimmed at the VO). +204 No Content on success. Once deprecated the Model rejects further +versioning or family edits at the decider; existing Assets bound to +the Model continue to function (deprecation is an authoring signal, +not a runtime gate). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import MODEL_DEPRECATION_REASON_MAX_LENGTH +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class DeprecateModelRequest(BaseModel): + """Body for `POST /models/{model_id}/deprecation`. + + `reason` is operator free text recording why the catalog entry is + being retired (for example "superseded by part RV120CCHL", "vendor + EOL 2026"). Trimmed and length-validated at the + `ModelDeprecationReason` VO; whitespace-only is rejected as a + domain invariant violation (400). + """ + + reason: str = Field( + ..., + min_length=1, + max_length=MODEL_DEPRECATION_REASON_MAX_LENGTH, + description=( + "Operator-supplied rationale for retiring this Model " + "(for example 'superseded by RV120CCHL', 'vendor EOL 2026'). " + "Free text; trimmed server-side." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.deprecate_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models/{model_id}/deprecation", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated (for example whitespace-only reason after trimming)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is already in `Deprecated` status (deprecate " + "requires `Defined` or `Versioned`), OR a concurrent " + "write to the same model stream conflicted (optimistic " + "concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter or request body failed schema validation.", + }, + }, + summary="Mark an existing Model as deprecated", +) +async def post_models_deprecation( + model_id: Annotated[UUID, Path(description="Target model's id.")], + body: DeprecateModelRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + DeprecateModel( + model_id=model_id, + reason=body.reason, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/deprecate_model/tool.py b/apps/api/src/cora/equipment/features/deprecate_model/tool.py new file mode 100644 index 000000000..5437a267a --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/tool.py @@ -0,0 +1,57 @@ +"""MCP tool for the `deprecate_model` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import Field + +from cora.equipment.aggregates.model import MODEL_DEPRECATION_REASON_MAX_LENGTH +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `deprecate_model` tool on the given MCP server.""" + + @mcp.tool( + name="deprecate_model", + description=( + "Deprecate a vendor-catalog Model with a reason. Existing " + "Assets bound to this Model continue to function; " + "deprecation is an authoring signal." + ), + ) + async def deprecate_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + reason: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_DEPRECATION_REASON_MAX_LENGTH, + description=( + "Operator-supplied rationale for retiring this Model " + "(for example 'superseded by RV120CCHL', 'vendor EOL 2026'). " + "Free text; trimmed server-side." + ), + ), + ], + ) -> None: + handler = get_handler() + await handler( + DeprecateModel( + model_id=model_id, + reason=reason, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 21a5bcec5..5838e56ce 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -119,6 +119,7 @@ define_model, degrade_asset, deprecate_family, + deprecate_model, enter_maintenance, exit_maintenance, fault_asset, @@ -212,6 +213,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(define_family.router) app.include_router(define_model.router) app.include_router(version_model.router) + app.include_router(deprecate_model.router) app.include_router(get_family.router) app.include_router(version_family.router) app.include_router(deprecate_family.router) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index 0e0f36008..3b30ec2d4 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -25,6 +25,7 @@ from cora.equipment.features.deprecate_family import ( tool as deprecate_family_tool, ) +from cora.equipment.features.deprecate_model import tool as deprecate_model_tool from cora.equipment.features.enter_maintenance import tool as enter_maintenance_tool from cora.equipment.features.exit_maintenance import ( tool as exit_maintenance_tool, @@ -79,6 +80,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().version_model, ) + deprecate_model_tool.register( + mcp, + get_handler=lambda: get_handlers().deprecate_model, + ) get_family_tool.register( mcp, get_handler=lambda: get_handlers().get_family, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 8c624e378..7b6754cd4 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -42,6 +42,7 @@ define_model, degrade_asset, deprecate_family, + deprecate_model, enter_maintenance, exit_maintenance, fault_asset, @@ -88,6 +89,7 @@ class EquipmentHandlers: define_family: define_family.IdempotentHandler define_model: define_model.IdempotentHandler version_model: version_model.Handler + deprecate_model: deprecate_model.Handler get_family: get_family.Handler version_family: version_family.Handler deprecate_family: deprecate_family.Handler @@ -154,6 +156,11 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="VersionModel", bc=_BC, ), + deprecate_model=with_tracing( + deprecate_model.bind(deps), + command_name="DeprecateModel", + bc=_BC, + ), get_family=with_tracing( get_family.bind(deps), command_name="GetFamily", diff --git a/apps/api/tests/contract/test_deprecate_model_endpoint.py b/apps/api/tests/contract/test_deprecate_model_endpoint.py new file mode 100644 index 000000000..7a80f5f23 --- /dev/null +++ b/apps/api/tests/contract/test_deprecate_model_endpoint.py @@ -0,0 +1,146 @@ +"""Contract tests for `POST /models/{model_id}/deprecation`. + +Action endpoint carrying a `reason` body. Multi-source guard +(Defined | Versioned -> Deprecated). Strict-not-idempotent. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_family_ids` lookup performed by `define_model` returns `[]` and +every model-seeding call would fail. We stub the symbol to a fixed +accept-all set so we can seed a Model via `POST /models` and exercise +`POST /models/{model_id}/deprecation` end-to-end. +""" + +from collections.abc import Iterator +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fad1") +_REASON = "Vendor end-of-life 2026-Q3; replaced by ANT130-LZS" + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_family_ids` so the seeding `define_model` succeeds.""" + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_body(*, name: str = "ANT130-L") -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _deprecate_body(*, reason: str = _REASON) -> dict[str, object]: + return {"reason": reason} + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +@pytest.mark.contract +def test_post_deprecate_model_returns_204_on_success(accept_family: UUID) -> None: + """Defined -> Deprecated.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(), + ) + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_post_deprecate_model_missing_reason_returns_422(accept_family: UUID) -> None: + """Pydantic schema validation: `reason` is required.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/deprecation", + json={}, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_deprecate_model_whitespace_only_reason_returns_400(accept_family: UUID) -> None: + """Domain `InvalidModelDeprecationReasonError` after Pydantic min_length=1 passes.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(reason=" "), + ) + assert response.status_code == 400 + assert "reason" in response.json()["detail"].lower() + + +@pytest.mark.contract +def test_post_deprecate_model_returns_404_when_model_does_not_exist( + accept_family: UUID, +) -> None: + _ = accept_family + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/models/{missing_id}/deprecation", + json=_deprecate_body(), + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_deprecate_model_returns_409_when_already_deprecated( + accept_family: UUID, +) -> None: + """Strict-not-idempotent: re-deprecating raises 409.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + first = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(), + ) + assert first.status_code == 204 + second = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(), + ) + assert second.status_code == 409 + body = second.json() + assert "Defined" in body["detail"] + assert "Versioned" in body["detail"] + + +@pytest.mark.contract +def test_post_deprecate_model_rejects_invalid_path_uuid_with_422( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + response = client.post( + "/models/not-a-uuid/deprecation", + json=_deprecate_body(), + ) + assert response.status_code == 422 diff --git a/apps/api/tests/contract/test_deprecate_model_mcp_tool.py b/apps/api/tests/contract/test_deprecate_model_mcp_tool.py new file mode 100644 index 000000000..639a73b1e --- /dev/null +++ b/apps/api/tests/contract/test_deprecate_model_mcp_tool.py @@ -0,0 +1,100 @@ +"""Contract tests for the `deprecate_model` MCP tool. + +In-memory contract harness has no Postgres pool, so happy-path +deprecation end-to-end requires seeding a Model first. We exercise: +- tool registration (the tool appears in `tools/list`) +- description matches the authoring-signal spec +- missing-argument call surfaces `isError: true` +- unknown `model_id` surfaces `ModelNotFoundError` as `isError: true` +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +@pytest.mark.contract +def test_mcp_lists_deprecate_model_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "deprecate_model" in tool_names + + +@pytest.mark.contract +def test_mcp_deprecate_model_tool_description_matches_authoring_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + deprecate_model = tools_by_name["deprecate_model"] + description = deprecate_model["description"] + assert "vendor-catalog Model" in description + assert "authoring signal" in description + assert "Assets" in description + + +@pytest.mark.contract +def test_mcp_deprecate_model_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "deprecate_model", + "arguments": {}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_deprecate_model_tool_returns_iserror_for_unknown_model() -> None: + unknown_id = str(uuid4()) + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "deprecate_model", + "arguments": { + "model_id": unknown_id, + "reason": "Vendor EOL 2026-Q3", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/integration/test_deprecate_model_handler_postgres.py b/apps/api/tests/integration/test_deprecate_model_handler_postgres.py new file mode 100644 index 000000000..df5ad29c6 --- /dev/null +++ b/apps/api/tests/integration/test_deprecate_model_handler_postgres.py @@ -0,0 +1,195 @@ +"""End-to-end integration test: deprecate_model against real Postgres. + +Round-trip: define Family, define Model, deprecate Model, and read the +events back from the event store. Verifies the ModelDeprecated payload +shape (model_id, trimmed reason, occurred_at), the multi-source guard +(ModelNotFoundError on a missing stream), and the strict-not-idempotent +re-deprecate rejection (ModelCannotDeprecateError). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotDeprecateError, + ModelNotFoundError, + ModelStatus, + fold, + from_stored, +) +from cora.equipment.features import define_family, define_model, deprecate_model +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_REASON = "Vendor end-of-life 2026-Q3" + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined into `proj_equipment_family_summary` so the + Family read repo called by `define_model.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_deprecate_model_persists_event_with_full_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed Family + define Model + deprecate Model. Verify + ModelDeprecated is persisted with the trimmed reason payload and the + state folds to Deprecated.""" + family_id = UUID("01900000-0000-7000-8000-00000061d001") + family_event_id = UUID("01900000-0000-7000-8000-00000061d00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca0e") + deprecate_event_id = UUID("01900000-0000-7000-8000-00000061ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + deprecate_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelDeprecated"] + deprecated = events[1] + assert deprecated.event_id == deprecate_event_id + assert deprecated.metadata == {"command": "DeprecateModel"} + assert deprecated.payload == { + "model_id": str(model_id), + "reason": _REASON, + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the Deprecated status. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.status is ModelStatus.DEPRECATED + + +@pytest.mark.integration +async def test_deprecate_model_raises_not_found_for_unknown_id( + db_pool: asyncpg.Pool, +) -> None: + """Deprecating a model whose stream has no events raises ModelNotFoundError.""" + missing_id = UUID("01900000-0000-7000-8000-0000000bad12") + deprecate_event_id = UUID("01900000-0000-7000-8000-0000000bad1e") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[deprecate_event_id]) + + with pytest.raises(ModelNotFoundError) as exc_info: + await deprecate_model.bind(deps)( + DeprecateModel(model_id=missing_id, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == missing_id + + _, version = await deps.event_store.load("Model", missing_id) + assert version == 0 + + +@pytest.mark.integration +async def test_deprecate_model_raises_cannot_deprecate_after_first_deprecation( + db_pool: asyncpg.Pool, +) -> None: + """Strict-not-idempotent: re-deprecating raises ModelCannotDeprecateError + and no new event is written.""" + family_id = UUID("01900000-0000-7000-8000-00000061e001") + family_event_id = UUID("01900000-0000-7000-8000-00000061e00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca21") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca2e") + deprecate_event_id = UUID("01900000-0000-7000-8000-00000061ca2f") + # The second deprecate_model call lands on the disallowed source and + # rejects before consuming any id; queue an extra to be safe. + unused_event_id = UUID("01900000-0000-7000-8000-00000061ca3a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + deprecate_event_id, + unused_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(ModelCannotDeprecateError): + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason="another reason"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + _, version = await deps.event_store.load("Model", model_id) + assert version == 2 diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_decider.py b/apps/api/tests/unit/equipment/test_deprecate_model_decider.py new file mode 100644 index 000000000..12da52551 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_deprecate_model_decider.py @@ -0,0 +1,164 @@ +"""Unit tests for the `deprecate_model` slice's pure decider. + +Multi-source-state guard: `Defined | Versioned -> Deprecated`. Same +source-set as version_model but the target is terminal. +Re-deprecating raises (strict-not-idempotent, mirrors deprecate_family). +The `reason` is validated defensively via `ModelDeprecationReason` so +direct decider callers get the same bounded-text protection as +API-boundary callers. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + MODEL_DEPRECATION_REASON_MAX_LENGTH, + InvalidModelDeprecationReasonError, + Manufacturer, + ManufacturerName, + Model, + ModelCannotDeprecateError, + ModelDeprecated, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import deprecate_model +from cora.equipment.features.deprecate_model import DeprecateModel + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_REASON = "Vendor end-of-life 2026-Q3; replaced by ANT130-LZS" + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({uuid4()}), + status=status, + version=version, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "source", + [ModelStatus.DEFINED, ModelStatus.VERSIONED], +) +def test_decide_emits_model_deprecated_for_each_allowed_source_status( + source: ModelStatus, +) -> None: + state = _model(status=source, version="v1" if source is ModelStatus.VERSIONED else None) + events = deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=_REASON), + now=_NOW, + ) + assert events == [ + ModelDeprecated(model_id=state.id, reason=_REASON, occurred_at=_NOW), + ] + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + deprecate_model.decide( + state=None, + command=DeprecateModel(model_id=target_id, reason=_REASON), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_cannot_deprecate_when_already_deprecated() -> None: + """Strict-not-idempotent: re-deprecating raises.""" + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotDeprecateError) as exc_info: + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=_REASON), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_error_message_lists_both_allowed_source_statuses() -> None: + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotDeprecateError) as exc_info: + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=_REASON), + now=_NOW, + ) + msg = str(exc_info.value) + assert "Defined" in msg + assert "Versioned" in msg + + +@pytest.mark.unit +def test_decide_rejects_empty_reason() -> None: + state = _model() + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=""), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_whitespace_only_reason() -> None: + state = _model() + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=" "), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_over_long_reason() -> None: + state = _model() + too_long = "x" * (MODEL_DEPRECATION_REASON_MAX_LENGTH + 1) + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=too_long), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_trims_reason_before_embedding_in_event() -> None: + """The VO trims surrounding whitespace; the emitted event carries + the trimmed value, not the raw input.""" + state = _model() + events = deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=f" {_REASON} "), + now=_NOW, + ) + assert events[0].reason == _REASON + + +@pytest.mark.unit +def test_decide_is_pure_same_inputs_same_outputs() -> None: + state = _model() + command = DeprecateModel(model_id=state.id, reason=_REASON) + first = deprecate_model.decide(state=state, command=command, now=_NOW) + second = deprecate_model.decide(state=state, command=command, now=_NOW) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py b/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py new file mode 100644 index 000000000..4015c4e2c --- /dev/null +++ b/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py @@ -0,0 +1,185 @@ +"""Property-based tests for `deprecate_model.decide` (Equipment BC). + +Mirrors the `version_model` decider-PBT pattern, adapted for the +multi-source `Defined | Versioned -> Deprecated` transition. Universal +claims across generated inputs: + + - state in {Defined, Versioned} + valid command emits exactly one + ModelDeprecated carrying the trimmed reason and the injected + `now` timestamp. + - state=None always raises ModelNotFoundError, regardless of command. + - state.status==Deprecated always raises ModelCannotDeprecateError. + - Empty, whitespace-only, or over-long `reason` always raises + InvalidModelDeprecationReasonError (via the ModelDeprecationReason VO). + - Pure: same (state, command, now) returns the same events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + MODEL_DEPRECATION_REASON_MAX_LENGTH, + InvalidModelDeprecationReasonError, + Manufacturer, + ManufacturerName, + Model, + ModelCannotDeprecateError, + ModelDeprecated, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import deprecate_model +from cora.equipment.features.deprecate_model import DeprecateModel +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + +_REASON = printable_ascii_text(min_size=1, max_size=MODEL_DEPRECATION_REASON_MAX_LENGTH) + +# Deprecatable source statuses: Defined (first revision) and Versioned +# (subsequent revisions). Deprecated is excluded; it's covered by a +# dedicated rejection property. +_DEPRECATABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# Negative-case alphabet for the bounded-text reason VO. +_WHITESPACE_CHARS = st.sampled_from([" ", "\t", "\n", "\r", " ", " \t\n"]) + + +def _invalid_reason() -> st.SearchStrategy[str]: + """Empty, whitespace-only, or over-length strings for VO rejection PBTs.""" + return st.one_of( + st.just(""), + _WHITESPACE_CHARS, + printable_ascii_text( + min_size=MODEL_DEPRECATION_REASON_MAX_LENGTH + 1, + max_size=MODEL_DEPRECATION_REASON_MAX_LENGTH + 50, + ), + ) + + +def _model(model_id: UUID, *, status: ModelStatus) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({model_id}), + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_emits_exactly_one_event_with_injected_fields( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Deprecatable source + valid command -> single ModelDeprecated with + the trimmed reason and injected `now`.""" + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + events = deprecate_model.decide(state=state, command=command, now=now) + assert events == [ + ModelDeprecated( + model_id=model_id, + reason=reason, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_on_empty_state_always_raises_not_found( + model_id: UUID, + reason: str, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = DeprecateModel(model_id=model_id, reason=reason) + with pytest.raises(ModelNotFoundError) as exc: + deprecate_model.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_on_deprecated_state_always_raises_cannot_deprecate( + model_id: UUID, + reason: str, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotDeprecateError.""" + state = _model(model_id, status=ModelStatus.DEPRECATED) + command = DeprecateModel(model_id=model_id, reason=reason) + with pytest.raises(ModelCannotDeprecateError) as exc: + deprecate_model.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_invalid_reason(), + now=aware_datetimes(), +) +def test_deprecate_model_with_invalid_reason_always_raises( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long reason -> InvalidModelDeprecationReasonError.""" + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_is_pure_same_input_same_output( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Two calls with identical args return identical events.""" + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + first = deprecate_model.decide(state=state, command=command, now=now) + second = deprecate_model.decide(state=state, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_handler.py b/apps/api/tests/unit/equipment/test_deprecate_model_handler.py new file mode 100644 index 000000000..40e9fddec --- /dev/null +++ b/apps/api/tests/unit/equipment/test_deprecate_model_handler.py @@ -0,0 +1,224 @@ +"""Unit tests for the `deprecate_model` application handler. + +Update-style handler (mirrors version_model and deprecate_family): +load + fold + decide + append. Not idempotency-wrapped; domain- +idempotent via `ModelCannotDeprecateError` on retry from `Deprecated`. + +Tests cover the happy path (returns None + appends one +ModelDeprecated event), the auth deny path, the +ModelNotFoundError on a missing stream, and the +ModelCannotDeprecateError on re-deprecation. + +The Deprecated path drives the slice itself to land the Model in +Deprecated state, then invokes the handler a second time and expects +ModelCannotDeprecateError (strict-not-idempotent). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotDeprecateError, + ModelNotFoundError, +) +from cora.equipment.features import define_model, deprecate_model +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ad11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad12") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad13") +_EXTRA_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fad1") + +_REASON = "Vendor end-of-life 2026-Q3" + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _DEPRECATED_EVENT_ID, _EXTRA_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_family_ids` as imported into the define_model handler. + + `deprecate_model` does NOT call `list_family_ids`, but the + seeding call to `define_model` does. We stub it accept-all so the + seed succeeds in the in-memory harness. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_returns_none_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await deprecate_model.bind(deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + +@pytest.mark.unit +async def test_handler_appends_model_deprecated_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelDeprecated"] + deprecated = events[1] + assert deprecated.event_id == _DEPRECATED_EVENT_ID + assert deprecated.metadata == {"command": "DeprecateModel"} + assert deprecated.payload["model_id"] == str(_MODEL_ID) + assert deprecated.payload["reason"] == _REASON + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_model_does_not_exist() -> None: + deps = _build_deps() + handler = deprecate_model.bind(deps) + + with pytest.raises(ModelNotFoundError): + await handler( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_cannot_deprecate_when_already_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Strict-not-idempotent: re-deprecating raises.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + handler = deprecate_model.bind(deps) + await handler( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + with pytest.raises(ModelCannotDeprecateError): + await handler( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await deprecate_model.bind(deny_deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_propagates_causation_id_to_appended_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + causation = UUID("01900000-0000-7000-8000-0000000000bb") + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + causation_id=causation, + ) + + events, _ = await store.load("Model", _MODEL_ID) + assert events[1].causation_id == causation + + +@pytest.mark.unit +def test_wire_equipment_includes_deprecate_model() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.deprecate_model) From f49f5951ea4a684c39c506e47a900b9a52f0650d Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 17:56:49 +0300 Subject: [PATCH 05/11] feat(equipment): add_model_family slice (targeted-mutation) Adds POST /models/{model_id}/families REST endpoint and add_model_family MCP tool. Update-style targeted-mutation command that appends a single Family to a Model's declared_families set. Command shape: AddModelFamily(model_id, family_id). Both required. Strict-not-idempotent: re-adding a present family raises ModelFamilyAlreadyPresentError (409). Cross-BC family lookup: the handler loads list_family_ids from the Family read repo and rejects with FamilyNotFoundError (404) if family_id does not resolve to a registered Family. Same pattern as define_model handler. FSM: status preserved on success (Defined stays Defined; Versioned stays Versioned). Rejected from Deprecated with the existing ModelCannotVersionError (Deprecated catalog entries cannot be mutated; mirrors the design memo Lock posture "Genesis-NoOp-or-Reject" for ModelFamilyAdded after deprecation). Operational rationale per the design memo: targeted-mutation events are preferred over re-emitting ModelVersioned with the full new set because the beamline pattern is "vendor shipped firmware update, one extra Family declared" rather than wholesale re-author. Mirrors the Caution / Safety amend_* event precedent. Slice files (10 new): - src/cora/equipment/features/add_model_family/{command, decider, handler, route, tool, __init__}.py - tests/unit/equipment/test_add_model_family_decider.py - tests/unit/equipment/test_add_model_family_decider_properties.py - tests/unit/equipment/test_add_model_family_handler.py - tests/contract/test_add_model_family_endpoint.py - tests/contract/test_add_model_family_mcp_tool.py - tests/integration/test_add_model_family_handler_postgres.py Wiring (3 edits): routes.py + wire.py + tools.py mirroring the version_model / deprecate_model update-style wiring shape (bare Handler, no idempotency). openapi.json regenerated to include POST /models/{model_id}/ families. Tests: 31 new tests pass + 14306 architecture tests pass. ruff clean, pyright 0/0/0. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 116 ++++++++ .../features/add_model_family/__init__.py | 25 ++ .../features/add_model_family/command.py | 31 ++ .../features/add_model_family/decider.py | 65 +++++ .../features/add_model_family/handler.py | 161 ++++++++++ .../features/add_model_family/route.py | 91 ++++++ .../features/add_model_family/tool.py | 51 ++++ apps/api/src/cora/equipment/routes.py | 2 + apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 7 + .../test_add_model_family_endpoint.py | 192 ++++++++++++ .../test_add_model_family_mcp_tool.py | 148 ++++++++++ .../test_add_model_family_handler_postgres.py | 222 ++++++++++++++ .../test_add_model_family_decider.py | 131 +++++++++ ...est_add_model_family_decider_properties.py | 167 +++++++++++ .../test_add_model_family_handler.py | 274 ++++++++++++++++++ 16 files changed, 1688 insertions(+) create mode 100644 apps/api/src/cora/equipment/features/add_model_family/__init__.py create mode 100644 apps/api/src/cora/equipment/features/add_model_family/command.py create mode 100644 apps/api/src/cora/equipment/features/add_model_family/decider.py create mode 100644 apps/api/src/cora/equipment/features/add_model_family/handler.py create mode 100644 apps/api/src/cora/equipment/features/add_model_family/route.py create mode 100644 apps/api/src/cora/equipment/features/add_model_family/tool.py create mode 100644 apps/api/tests/contract/test_add_model_family_endpoint.py create mode 100644 apps/api/tests/contract/test_add_model_family_mcp_tool.py create mode 100644 apps/api/tests/integration/test_add_model_family_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_add_model_family_decider.py create mode 100644 apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py create mode 100644 apps/api/tests/unit/equipment/test_add_model_family_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 8695b09ed..7e6e407c3 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -274,6 +274,22 @@ "title": "AddAssetPortRequest", "type": "object" }, + "AddModelFamilyRequest": { + "description": "Body for `POST /models/{model_id}/families`.", + "properties": { + "family_id": { + "description": "Family id to add to the Model.declared_families set.", + "format": "uuid", + "title": "Family Id", + "type": "string" + } + }, + "required": [ + "family_id" + ], + "title": "AddModelFamilyRequest", + "type": "object" + }, "AddPlanWireRequest": { "description": "Body for `POST /plans/{plan_id}/add-wire`.\n\nAll four fields are required. Pydantic enforces non-empty port\nnames at the boundary; the `Wire` VO then trims and re-validates\nlength within the decider. Direction + signal_type validation\nhappens in the decider against the loaded Asset.ports.", "properties": { @@ -23499,6 +23515,106 @@ ] } }, + "/models/{model_id}/families": { + "post": { + "operationId": "post_models_add_family_models__model_id__families_post", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddModelFamilyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id, OR the supplied family_id does not resolve to a registered Family." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is in `Deprecated` status (mutation requires `Defined` or `Versioned`), OR the family_id is already in the Model's declared_families set, OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation." + } + }, + "summary": "Add a Family to an existing Model's declared_families set", + "tags": [ + "equipment" + ] + } + }, "/models/{model_id}/versions": { "post": { "operationId": "post_models_versions_models__model_id__versions_post", diff --git a/apps/api/src/cora/equipment/features/add_model_family/__init__.py b/apps/api/src/cora/equipment/features/add_model_family/__init__.py new file mode 100644 index 000000000..0b34179e9 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/__init__.py @@ -0,0 +1,25 @@ +"""Vertical slice for the `AddModelFamily` command. + +Module-as-namespace surface: + + from cora.equipment.features import add_model_family + + cmd = add_model_family.AddModelFamily(model_id=..., family_id=...) + handler = add_model_family.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.add_model_family import tool +from cora.equipment.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.decider import decide +from cora.equipment.features.add_model_family.handler import Handler, bind +from cora.equipment.features.add_model_family.route import router + +__all__ = [ + "AddModelFamily", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/add_model_family/command.py b/apps/api/src/cora/equipment/features/add_model_family/command.py new file mode 100644 index 000000000..07f9f67d6 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/command.py @@ -0,0 +1,31 @@ +"""The `AddModelFamily` command, intent dataclass for this slice. + +Targeted-mutation: incremental add of a single Family to the +Model's `declared_families` set. Sibling of `remove_model_family`. + +The operational pattern is "vendor firmware update declares an +additional Family" rather than a wholesale re-author; `version_model` +remains the path when a revision genuinely re-authors the catalog +entry (matches the Family/Method/Plan/Practice replace-on-version +precedent). + +`model_id` is the target Model aggregate. `family_id` is the Family +being declared; the handler resolves it against the Family registry +(cross-BC lookup mirrors `define_model`'s `list_family_ids` pattern) +and raises `FamilyNotFoundError` if it does not resolve. + +Strict-not-idempotent: re-adding a Family already in +`declared_families` raises `ModelFamilyAlreadyPresentError` (same +precedent as `add_asset_family` and `activate_asset`). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class AddModelFamily: + """Add a Family to an existing model's `declared_families` set.""" + + model_id: UUID + family_id: UUID diff --git a/apps/api/src/cora/equipment/features/add_model_family/decider.py b/apps/api/src/cora/equipment/features/add_model_family/decider.py new file mode 100644 index 000000000..106670c7e --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/decider.py @@ -0,0 +1,65 @@ +"""Pure decider for the `AddModelFamily` command. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected, on +the same "deprecated catalog entry is frozen" rationale that drives +`ModelVersioned` and `ModelFamilyRemoved` rejection from +`Deprecated` in the events module. + +There is no dedicated `ModelCannotAddFamilyError` in the Model +aggregate; the Model aggregate carries `ModelCannotVersionError` +as its general "cannot mutate from Deprecated" gate (add and remove +are conceptually version-like mutations of the declared-families +set), so this slice reuses it. The diagnostic message stays +accurate because `ModelCannotVersionError` already enumerates the +allowed `Defined | Versioned` source states. + +The decider does NOT verify the referenced Family id resolves to a +real Family stream; the handler performs that cross-BC lookup +upstream (mirroring `define_model`) and raises `FamilyNotFoundError` +before the command reaches the decider. + +Strict-not-idempotent: re-adding a present family raises +`ModelFamilyAlreadyPresentError` (mirrors `add_asset_family`). + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must not be Deprecated -> ModelCannotVersionError + - family_id must not already be in state.declared_families + (strict-not-idempotent) -> ModelFamilyAlreadyPresentError +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + Model, + ModelCannotVersionError, + ModelFamilyAdded, + ModelFamilyAlreadyPresentError, + ModelNotFoundError, + ModelStatus, +) +from cora.equipment.features.add_model_family.command import AddModelFamily + + +def decide( + state: Model | None, + command: AddModelFamily, + *, + now: datetime, +) -> list[ModelFamilyAdded]: + """Decide the events produced by adding a family to an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status is ModelStatus.DEPRECATED: + raise ModelCannotVersionError(state.id, current_status=state.status) + if command.family_id in state.declared_families: + raise ModelFamilyAlreadyPresentError(state.id, command.family_id) + return [ + ModelFamilyAdded( + model_id=state.id, + family_id=command.family_id, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/add_model_family/handler.py b/apps/api/src/cora/equipment/features/add_model_family/handler.py new file mode 100644 index 000000000..71045e1ad --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/handler.py @@ -0,0 +1,161 @@ +"""Application handler for the `add_model_family` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the `add_asset_family` and `version_model` precedents for the +stream load + fold + decide + append spine, and the `define_model` +precedent for the cross-BC `list_family_ids` lookup that resolves +`command.family_id` against the Family registry before the decider +runs. + +Not idempotency-wrapped: domain-idempotent via +`ModelFamilyAlreadyPresentError` on retry (mirrors +`add_asset_family`). + +Cross-BC concern: the referenced `family_id` must resolve to a +registered Family stream. On miss the handler raises +`FamilyNotFoundError(command.family_id)` (404) before the decider +sees the command, matching the `define_model` operational pattern +of surfacing missing-Family errors at the application boundary. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.family import FamilyNotFoundError, list_family_ids +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "AddModelFamily" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every add_model_family handler implements.""" + + async def __call__( + self, + command: AddModelFamily, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build an add_model_family handler closed over the shared deps.""" + + async def handler( + command: AddModelFamily, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "add_model_family.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "add_model_family.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + # Cross-BC family lookup: the referenced Family must resolve. + # Bulk single-query approach (cheap at pilot scale, <50 Families). + # Trigger to switch to per-id load: facility Family count crosses + # ~500 OR p95 of add_model_family crosses 200ms. + known_family_ids = set(await list_family_ids(deps.pool)) + if command.family_id not in known_family_ids: + _log.info( + "add_model_family.family_not_found", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + raise FamilyNotFoundError(command.family_id) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "add_model_family.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/add_model_family/route.py b/apps/api/src/cora/equipment/features/add_model_family/route.py new file mode 100644 index 000000000..089f69c47 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/route.py @@ -0,0 +1,91 @@ +"""HTTP route for the `add_model_family` slice. + +Targeted-mutation endpoint at `POST /models/{model_id}/families`. Body +carries a single `family_id` that is added to the Model's +`declared_families` set. 204 No Content on success. Status (`Defined` +or `Versioned`) is preserved; `Deprecated` rejects the mutation. No +`Idempotency-Key` (update-style, mirrors `version_model`). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class AddModelFamilyRequest(BaseModel): + """Body for `POST /models/{model_id}/families`.""" + + family_id: UUID = Field( + ..., + description="Family id to add to the Model.declared_families set.", + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.add_model_family + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models/{model_id}/families", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated.", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": ( + "No model exists with the given id, OR the supplied " + "family_id does not resolve to a registered Family." + ), + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is in `Deprecated` status (mutation requires " + "`Defined` or `Versioned`), OR the family_id is already " + "in the Model's declared_families set, OR a concurrent " + "write to the same model stream conflicted (optimistic " + "concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter or request body failed schema validation.", + }, + }, + summary="Add a Family to an existing Model's declared_families set", +) +async def post_models_add_family( + model_id: Annotated[UUID, Path(description="Target model's id.")], + body: AddModelFamilyRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + AddModelFamily(model_id=model_id, family_id=body.family_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/add_model_family/tool.py b/apps/api/src/cora/equipment/features/add_model_family/tool.py new file mode 100644 index 000000000..6e30adb43 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/tool.py @@ -0,0 +1,51 @@ +"""MCP tool for the `add_model_family` slice. + +Mirror of `add_asset_family` MCP tool: single model_id arg plus an +extra UUID arg (family_id). Domain / application errors propagate +to FastMCP, which wraps them as `isError: true`. +""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import Field + +from cora.equipment.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `add_model_family` tool on the given MCP server.""" + + @mcp.tool( + name="add_model_family", + description=( + "Add a Family to a vendor-catalog Model declared_families set. " + "Strict-not-idempotent: re-adding a present family raises an error." + ), + ) + async def add_model_family_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + family_id: Annotated[ + UUID, + Field( + description=("Family id to add. Cross-BC existence is verified at the handler."), + ), + ], + ) -> None: + handler = get_handler() + await handler( + AddModelFamily(model_id=model_id, family_id=family_id), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 5838e56ce..1ab721397 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -112,6 +112,7 @@ activate_asset, add_asset_family, add_asset_port, + add_model_family, decommission_asset, decommission_frame, decommission_mount, @@ -214,6 +215,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(define_model.router) app.include_router(version_model.router) app.include_router(deprecate_model.router) + app.include_router(add_model_family.router) app.include_router(get_family.router) app.include_router(version_family.router) app.include_router(deprecate_family.router) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index 3b30ec2d4..befdbb02d 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -16,6 +16,7 @@ tool as add_asset_family_tool, ) from cora.equipment.features.add_asset_port import tool as add_asset_port_tool +from cora.equipment.features.add_model_family import tool as add_model_family_tool from cora.equipment.features.decommission_asset import tool as decommission_asset_tool from cora.equipment.features.decommission_frame import tool as decommission_frame_tool from cora.equipment.features.decommission_mount import tool as decommission_mount_tool @@ -84,6 +85,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().deprecate_model, ) + add_model_family_tool.register( + mcp, + get_handler=lambda: get_handlers().add_model_family, + ) get_family_tool.register( mcp, get_handler=lambda: get_handlers().get_family, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 7b6754cd4..8e9a64a14 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -35,6 +35,7 @@ activate_asset, add_asset_family, add_asset_port, + add_model_family, decommission_asset, decommission_frame, decommission_mount, @@ -90,6 +91,7 @@ class EquipmentHandlers: define_model: define_model.IdempotentHandler version_model: version_model.Handler deprecate_model: deprecate_model.Handler + add_model_family: add_model_family.Handler get_family: get_family.Handler version_family: version_family.Handler deprecate_family: deprecate_family.Handler @@ -161,6 +163,11 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DeprecateModel", bc=_BC, ), + add_model_family=with_tracing( + add_model_family.bind(deps), + command_name="AddModelFamily", + bc=_BC, + ), get_family=with_tracing( get_family.bind(deps), command_name="GetFamily", diff --git a/apps/api/tests/contract/test_add_model_family_endpoint.py b/apps/api/tests/contract/test_add_model_family_endpoint.py new file mode 100644 index 000000000..e83ab906e --- /dev/null +++ b/apps/api/tests/contract/test_add_model_family_endpoint.py @@ -0,0 +1,192 @@ +"""Contract tests for `POST /models/{model_id}/families`. + +Targeted-mutation endpoint adding a single Family to the Model's +`declared_families` set. 204 No Content on success. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_family_ids` lookup performed by both `define_model` (during +seeding) and `add_model_family` (under test) returns `[]` by default. +We stub the symbol in BOTH handler modules to a fixed accept-all set so +we can seed a Model via `POST /models` and exercise +`POST /models/{model_id}/families` end-to-end. The 404-on-unknown- +family branch removes a chosen id from the stub before invoking. + +The 409-on-Deprecated path appends a `ModelDeprecated` event directly +to the in-memory event store (no `deprecate_model` slice exercised), +then exercises the route and expects 409. +""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + ModelDeprecated, + event_type_name, + to_payload, +) +from cora.infrastructure.event_envelope import to_new_event + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fc01") +_OTHER_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fc02") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def accept_families(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[UUID]]: + """Stub `list_family_ids` in both handler modules so the seeding + `define_model` call and the `add_model_family` call under test each + accept the fixed family-id set.""" + known: list[UUID] = [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] + + async def _stub(_pool: object) -> list[UUID]: + return list(known) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + monkeypatch.setattr( + "cora.equipment.features.add_model_family.handler.list_family_ids", + _stub, + ) + yield known + + +def _define_body() -> dict[str, object]: + return { + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +async def _append_deprecated_event(app: FastAPI, model_id: UUID) -> None: + deps = app.state.deps + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=uuid4(), + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + _, current_version = await deps.event_store.load("Model", model_id) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=current_version, + events=[new_event], + ) + + +@pytest.mark.contract +def test_post_add_model_family_returns_204_on_success(accept_families: list[UUID]) -> None: + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/families", + json={"family_id": str(_OTHER_FAMILY_ID)}, + ) + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_post_add_model_family_missing_family_id_returns_422( + accept_families: list[UUID], +) -> None: + """Pydantic schema validation: missing required `family_id`.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post(f"/models/{model_id}/families", json={}) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_add_model_family_returns_404_when_model_does_not_exist( + accept_families: list[UUID], +) -> None: + _ = accept_families + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/models/{missing_id}/families", + json={"family_id": str(_OTHER_FAMILY_ID)}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_add_model_family_returns_404_when_family_unregistered( + accept_families: list[UUID], +) -> None: + """Cross-BC precondition surfaces as 404 when the supplied family_id + does not resolve via `list_family_ids`.""" + _ = accept_families + unknown_family_id = str(uuid4()) + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/families", + json={"family_id": unknown_family_id}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_add_model_family_returns_409_on_duplicate_family( + accept_families: list[UUID], +) -> None: + """Strict-not-idempotent: re-adding a family already in + declared_families surfaces as 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + # `_FIXED_FAMILY_ID` is already in declared_families per the + # seed body; re-adding it must reject. + response = client.post( + f"/models/{model_id}/families", + json={"family_id": str(_FIXED_FAMILY_ID)}, + ) + assert response.status_code == 409 + + +@pytest.mark.contract +async def test_post_add_model_family_returns_409_when_deprecated( + accept_families: list[UUID], +) -> None: + """Deprecated Models cannot accept new family declarations; 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + await _append_deprecated_event(client.app, model_id) # type: ignore[arg-type] + response = client.post( + f"/models/{model_id}/families", + json={"family_id": str(_OTHER_FAMILY_ID)}, + ) + assert response.status_code == 409 + assert "Deprecated" in response.json()["detail"] diff --git a/apps/api/tests/contract/test_add_model_family_mcp_tool.py b/apps/api/tests/contract/test_add_model_family_mcp_tool.py new file mode 100644 index 000000000..246b3cf5d --- /dev/null +++ b/apps/api/tests/contract/test_add_model_family_mcp_tool.py @@ -0,0 +1,148 @@ +"""Contract tests for the `add_model_family` MCP tool. + +Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_family_ids` lookup returns `[]` and every `add_model_family` +call surfaces `FamilyNotFoundError` before the decider runs. The +happy path is pinned at the integration tier; this file pins the +MCP-wire shape: tool registration, description spec, and the failure +branches reachable without a database. +""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +@pytest.mark.contract +def test_mcp_lists_add_model_family_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "add_model_family" in tool_names + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_description_matches_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + add_model_family = tools_by_name["add_model_family"] + description = add_model_family["description"] + assert "Family" in description + assert "vendor-catalog Model" in description + assert "declared_families" in description + assert "Strict-not-idempotent" in description + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "add_model_family", + "arguments": {"model_id": str(uuid4())}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_returns_iserror_on_unregistered_family() -> None: + """Cross-BC check: family_id must resolve to a registered Family. + In-memory harness has no Family registry, so any family_id surfaces + FamilyNotFoundError, which FastMCP wraps as isError: true with a + 'not found' diagnostic (same shape as the REST 404).""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "add_model_family", + "arguments": { + "model_id": str(uuid4()), + "family_id": str(uuid4()), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_returns_iserror_on_unknown_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Family is registered but the model stream is missing; the handler + raises ModelNotFoundError after the cross-BC lookup succeeds. FastMCP + wraps that as isError: true with a 'not found' diagnostic.""" + fake_family_id = uuid4() + + async def _stub(_pool: object) -> list[UUID]: + return [fake_family_id] + + monkeypatch.setattr( + "cora.equipment.features.add_model_family.handler.list_family_ids", + _stub, + ) + missing_model_id = uuid4() + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "add_model_family", + "arguments": { + "model_id": str(missing_model_id), + "family_id": str(fake_family_id), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/integration/test_add_model_family_handler_postgres.py b/apps/api/tests/integration/test_add_model_family_handler_postgres.py new file mode 100644 index 000000000..d3cd1c36f --- /dev/null +++ b/apps/api/tests/integration/test_add_model_family_handler_postgres.py @@ -0,0 +1,222 @@ +"""End-to-end integration test: add_model_family against real Postgres. + +Round-trip: define Families, define Model, add a second Family to the +Model's declared_families set, and read the events back from the event +store. Verifies the ModelFamilyAdded payload shape, the cross-BC +`list_family_ids` lookup against the real `proj_equipment_family_summary` +projection (404 path on a missing Family id), and the +strict-not-idempotent guard (re-adding a present family raises). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelFamilyAlreadyPresentError, + fold, + from_stored, +) +from cora.equipment.features import add_model_family, define_family, define_model +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined rows into `proj_equipment_family_summary` so + the Family read repo called by `add_model_family.handler` sees the + seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_add_model_family_persists_event_with_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed two Families, define a Model declaring one, + add the other via add_model_family. Verify ModelFamilyAdded is + persisted with the expected payload shape and fold reflects the + expanded declared_families set.""" + family_a_id = UUID("01900000-0000-7000-8000-00000061d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000061d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000061d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000061d00f") + model_id = UUID("01900000-0000-7000-8000-00000061ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca0e") + added_event_id = UUID("01900000-0000-7000-8000-00000061ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyAdded"] + added = events[1] + assert added.event_id == added_event_id + assert added.metadata == {"command": "AddModelFamily"} + assert added.payload == { + "model_id": str(model_id), + "family_id": str(family_b_id), + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the targeted mutation. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.declared_families == frozenset({family_a_id, family_b_id}) + + +@pytest.mark.integration +async def test_add_model_family_rejects_unregistered_family_id( + db_pool: asyncpg.Pool, +) -> None: + """Cross-BC family_lookup: adding a Family that has never been + registered raises `FamilyNotFoundError` before the decider sees the + command. Real PG lookup against `proj_equipment_family_summary`.""" + family_id = UUID("01900000-0000-7000-8000-00000061e001") + family_event_id = UUID("01900000-0000-7000-8000-00000061e00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca21") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca2e") + # The add_model_family call rejects before consuming any id; queue + # an extra to be safe. + unused_add_event_id = UUID("01900000-0000-7000-8000-00000061ca3a") + missing_family_id = UUID("01900000-0000-7000-8000-0000000bad21") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, define_event_id, unused_add_event_id], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(FamilyNotFoundError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=missing_family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.family_id == missing_family_id + + # No new event was written on the rejected command. + _, version = await deps.event_store.load("Model", model_id) + assert version == 1 + + +@pytest.mark.integration +async def test_add_model_family_rejects_duplicate_family( + db_pool: asyncpg.Pool, +) -> None: + """Strict-not-idempotent: re-adding a family already in + declared_families raises `ModelFamilyAlreadyPresentError` and writes + no new event.""" + family_id = UUID("01900000-0000-7000-8000-00000061f001") + family_event_id = UUID("01900000-0000-7000-8000-00000061f00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca41") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca4e") + unused_add_event_id = UUID("01900000-0000-7000-8000-00000061ca5a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, define_event_id, unused_add_event_id], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(ModelFamilyAlreadyPresentError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == model_id + assert exc_info.value.family_id == family_id + + _, version = await deps.event_store.load("Model", model_id) + assert version == 1 diff --git a/apps/api/tests/unit/equipment/test_add_model_family_decider.py b/apps/api/tests/unit/equipment/test_add_model_family_decider.py new file mode 100644 index 000000000..7bd9db19b --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_model_family_decider.py @@ -0,0 +1,131 @@ +"""Pure-decider tests for the `add_model_family` slice. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected via +`ModelCannotVersionError` (Model's general "cannot mutate from +Deprecated" gate, reused by the add/remove family slices). + +Strict-not-idempotent: re-adding a present family raises +`ModelFamilyAlreadyPresentError`, mirroring the `add_asset_family` +precedent. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelFamilyAdded, + ModelFamilyAlreadyPresentError, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import add_model_family +from cora.equipment.features.add_model_family import AddModelFamily + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + declared_families: frozenset[UUID] | None = None, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=declared_families + if declared_families is not None + else frozenset({uuid4()}), + status=status, + version=version, + ) + + +@pytest.mark.unit +def test_decide_emits_model_family_added_from_defined_state() -> None: + state = _model(status=ModelStatus.DEFINED) + new_family = uuid4() + events = add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=new_family), + now=_NOW, + ) + assert events == [ModelFamilyAdded(model_id=state.id, family_id=new_family, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_emits_model_family_added_from_versioned_state() -> None: + """Status preserved across targeted mutation; Versioned is a valid source.""" + state = _model(status=ModelStatus.VERSIONED, version="v2") + new_family = uuid4() + events = add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=new_family), + now=_NOW, + ) + assert events == [ModelFamilyAdded(model_id=state.id, family_id=new_family, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_raises_cannot_version_when_deprecated() -> None: + """Deprecated catalog entries are frozen; add_model_family rejects.""" + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotVersionError) as exc_info: + add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=uuid4()), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + add_model_family.decide( + state=None, + command=AddModelFamily(model_id=target_id, family_id=uuid4()), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_already_present_on_duplicate_family() -> None: + """Strict-not-idempotent: re-adding a present family raises rather + than no-op so operators can detect 'wait, this is already declared' + instead of silently succeeding.""" + existing = uuid4() + state = _model(declared_families=frozenset({existing})) + with pytest.raises(ModelFamilyAlreadyPresentError) as exc_info: + add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.family_id == existing + + +@pytest.mark.unit +def test_decide_is_pure_same_inputs_same_outputs() -> None: + state = _model() + family = uuid4() + command = AddModelFamily(model_id=state.id, family_id=family) + first = add_model_family.decide(state=state, command=command, now=_NOW) + second = add_model_family.decide(state=state, command=command, now=_NOW) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py b/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py new file mode 100644 index 000000000..a555acb7e --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py @@ -0,0 +1,167 @@ +"""Property-based tests for `add_model_family.decide` (Equipment BC). + +Targeted mutation of `Model.declared_families`; status is preserved +across the mutation and only `Deprecated` is rejected (via the shared +`ModelCannotVersionError` gate). Universal claims across generated +inputs: + + - state in {Defined, Versioned} + family_id NOT in + declared_families emits exactly one ModelFamilyAdded with the + injected `now` timestamp. + - family_id IN declared_families always raises + ModelFamilyAlreadyPresentError carrying the model + family id. + - state=None always raises ModelNotFoundError carrying the + command's model_id. + - state.status==Deprecated always raises ModelCannotVersionError + carrying the Deprecated source status. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelFamilyAdded, + ModelFamilyAlreadyPresentError, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import add_model_family +from cora.equipment.features.add_model_family import AddModelFamily +from tests._strategies import aware_datetimes + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + +# Mutable source statuses; only Deprecated is rejected at the slice gate. +_MUTABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# 1 to 5 pre-existing declared family ids; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + + +def _model( + model_id: UUID, + *, + status: ModelStatus, + declared_families: frozenset[UUID], +) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=declared_families, + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + new_family=st.uuids(), + now=aware_datetimes(), +) +def test_add_model_family_emits_one_event_for_absent_family( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + new_family: UUID, + now: datetime, +) -> None: + """Mutable source + family_id NOT in declared_families -> exactly one + ModelFamilyAdded with the injected `now`.""" + # Ensure the new family is genuinely absent from the prior set. + declared_without_new = declared_families - {new_family} + state = _model(model_id, status=status, declared_families=declared_without_new) + command = AddModelFamily(model_id=model_id, family_id=new_family) + events = add_model_family.decide(state=state, command=command, now=now) + assert events == [ModelFamilyAdded(model_id=model_id, family_id=new_family, occurred_at=now)] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + now=aware_datetimes(), + pick_index=st.integers(min_value=0, max_value=4), +) +def test_add_model_family_with_duplicate_family_always_raises( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + now: datetime, + pick_index: int, +) -> None: + """family_id already in declared_families -> ModelFamilyAlreadyPresentError.""" + # Pick a deterministic family id from the existing set (declared has + # at least one member, so the modulo always lands). + declared_list = sorted(declared_families, key=str) + duplicate = declared_list[pick_index % len(declared_list)] + state = _model(model_id, status=status, declared_families=declared_families) + command = AddModelFamily(model_id=model_id, family_id=duplicate) + with pytest.raises(ModelFamilyAlreadyPresentError) as exc: + add_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.family_id == duplicate + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_add_model_family_on_empty_state_always_raises_not_found( + model_id: UUID, + family_id: UUID, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = AddModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelNotFoundError) as exc: + add_model_family.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + declared_families=_DECLARED_FAMILIES, + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_add_model_family_on_deprecated_state_always_raises_cannot_version( + model_id: UUID, + declared_families: frozenset[UUID], + family_id: UUID, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotVersionError, regardless of + whether family_id would have been a duplicate or a fresh add.""" + state = _model( + model_id, + status=ModelStatus.DEPRECATED, + declared_families=declared_families, + ) + command = AddModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelCannotVersionError) as exc: + add_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED diff --git a/apps/api/tests/unit/equipment/test_add_model_family_handler.py b/apps/api/tests/unit/equipment/test_add_model_family_handler.py new file mode 100644 index 000000000..0a086442a --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_model_family_handler.py @@ -0,0 +1,274 @@ +"""Unit tests for the `add_model_family` application handler. + +Update-style handler (mirrors `add_asset_family` and `version_model`): +load + fold + decide + append. Not idempotency-wrapped. + +Cross-BC concern: the referenced `family_id` must resolve to a +registered Family via `list_family_ids`. The unit harness has no +Postgres pool, so we monkeypatch the symbol imported into the +handler module to a fixed accept-all stub (mirrors the +`define_model` handler test pattern). The seeding `define_model` +call is also monkeypatched against the same stub for the same +reason. + +The Deprecated path seeds a `ModelDeprecated` event directly onto +the in-memory store (no `deprecate_model` slice is exercised in +this test file), then invokes the handler and expects +`ModelCannotVersionError`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotVersionError, + ModelDeprecated, + ModelFamilyAlreadyPresentError, + ModelNotFoundError, + event_type_name, + to_payload, +) +from cora.equipment.features import add_model_family, define_model +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_model import DefineModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ac11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ac12") +_ADDED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ac13") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ac14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fb01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fb02") +_FAMILY_MISSING_ID = UUID("01900000-0000-7000-8000-00000000fb99") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _ADDED_EVENT_ID, _DEPRECATED_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_family_ids` in both handler modules that look it up. + + The seeding `define_model` call AND the slice under test + (`add_model_family`) each import `list_family_ids` by name at module + load. Patching the binding in each handler's namespace ensures both + paths see the stub. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _fake_list_family_ids, + ) + monkeypatch.setattr( + "cora.equipment.features.add_model_family.handler.list_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized + in `Defined` status with `_FAMILY_A_ID` declared.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_deprecated_model(deps: Kernel, store: InMemoryEventStore) -> None: + """Append a `ModelDeprecated` event directly so the model lands in + `Deprecated` status without going through a `deprecate_model` slice + in this test file.""" + await _seed_model(deps) + deprecated = ModelDeprecated( + model_id=_MODEL_ID, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=_DEPRECATED_EVENT_ID, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + await store.append( + stream_type="Model", + stream_id=_MODEL_ID, + expected_version=1, + events=[new_event], + ) + + +@pytest.mark.unit +async def test_handler_returns_none_and_appends_event_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyAdded"] + added = events[1] + assert added.event_id == _ADDED_EVENT_ID + assert added.metadata == {"command": "AddModelFamily"} + assert added.payload["model_id"] == str(_MODEL_ID) + assert added.payload["family_id"] == str(_FAMILY_B_ID) + assert added.payload["occurred_at"] == _NOW.isoformat() + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await add_model_family.bind(deny_deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_raises_family_not_found_for_unregistered_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cross-BC precondition: the family_id must resolve via + `list_family_ids`; an unregistered id raises `FamilyNotFoundError` + before the decider is reached.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + with pytest.raises(FamilyNotFoundError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_MISSING_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.family_id == _FAMILY_MISSING_ID + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_stream_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unseeded model stream surfaces ModelNotFoundError. The + cross-BC lookup passes (family is known); the decider rejects + because state is None.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + + with pytest.raises(ModelNotFoundError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + + +@pytest.mark.unit +async def test_handler_raises_already_present_on_duplicate_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Re-adding a family already in declared_families surfaces + ModelFamilyAlreadyPresentError (strict-not-idempotent).""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + with pytest.raises(ModelFamilyAlreadyPresentError) as exc_info: + await add_model_family.bind(deps)( + # _FAMILY_A_ID is already declared at define_model time. + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + assert exc_info.value.family_id == _FAMILY_A_ID + + +@pytest.mark.unit +async def test_handler_raises_cannot_version_when_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Deprecated Models cannot accept new family declarations.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_deprecated_model(deps, store) + + with pytest.raises(ModelCannotVersionError): + await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +def test_wire_equipment_includes_add_model_family() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.add_model_family) From 5991f42815eb024b3f2b534e887870da849a8f87 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 18:09:32 +0300 Subject: [PATCH 06/11] feat(equipment): remove_model_family slice (targeted-mutation) Adds DELETE /models/{model_id}/families/{family_id} REST endpoint and remove_model_family MCP tool. Update-style targeted-mutation command that removes a single Family from a Model's declared_families set. Command shape: RemoveModelFamily(model_id, family_id). Both required. Strict-not-idempotent: removing an absent family raises ModelFamilyNotPresentError (409). NO cross-BC family lookup: removing only requires the family_id to be in declared_families; it does not need to be a registered Family (Family may have been deprecated/deleted; removal proceeds anyway). This is the symmetric counterpart to add_model_family which DOES validate the family is registered. FSM: status preserved on success. Rejected from Deprecated with ModelCannotVersionError (mirrors add_model_family posture). Does NOT cascade through existing Assets bound to this Model per the design memo Lock: removing a family from the Model's catalog declaration does not invalidate Asset instances; the subset invariant (Model.declared_families subset-of Asset.families) is satisfied automatically by the removal (the left set got smaller). Slice files (10 new): - src/cora/equipment/features/remove_model_family/{command, decider, handler, route, tool, __init__}.py - tests/unit/equipment/test_remove_model_family_decider.py - tests/unit/equipment/test_remove_model_family_decider_properties.py - tests/unit/equipment/test_remove_model_family_handler.py - tests/contract/test_remove_model_family_endpoint.py - tests/contract/test_remove_model_family_mcp_tool.py - tests/integration/test_remove_model_family_handler_postgres.py Wiring (3 edits): routes.py + wire.py + tools.py mirroring the add_model_family wiring shape. openapi.json regenerated to include DELETE /models/{model_id}/ families/{family_id}. Tests: 27 new tests pass + 14371 architecture tests pass. ruff clean, pyright 0/0/0. With this slice the Model aggregate's 5 mutation slices are complete: define_model, version_model, deprecate_model, add_model_family, remove_model_family. Remaining: get_model read slice + proj_equipment_model_summary projection + architecture BOUNDED_CONTEXTS sync. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 92 +++++++ .../features/remove_model_family/__init__.py | 25 ++ .../features/remove_model_family/command.py | 33 +++ .../features/remove_model_family/decider.py | 66 +++++ .../features/remove_model_family/handler.py | 137 ++++++++++ .../features/remove_model_family/route.py | 76 ++++++ .../features/remove_model_family/tool.py | 50 ++++ apps/api/src/cora/equipment/routes.py | 2 + apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 7 + .../test_remove_model_family_endpoint.py | 150 +++++++++++ .../test_remove_model_family_mcp_tool.py | 175 +++++++++++++ ...st_remove_model_family_handler_postgres.py | 196 ++++++++++++++ .../test_remove_model_family_decider.py | 141 ++++++++++ ..._remove_model_family_decider_properties.py | 167 ++++++++++++ .../test_remove_model_family_handler.py | 246 ++++++++++++++++++ 16 files changed, 1568 insertions(+) create mode 100644 apps/api/src/cora/equipment/features/remove_model_family/__init__.py create mode 100644 apps/api/src/cora/equipment/features/remove_model_family/command.py create mode 100644 apps/api/src/cora/equipment/features/remove_model_family/decider.py create mode 100644 apps/api/src/cora/equipment/features/remove_model_family/handler.py create mode 100644 apps/api/src/cora/equipment/features/remove_model_family/route.py create mode 100644 apps/api/src/cora/equipment/features/remove_model_family/tool.py create mode 100644 apps/api/tests/contract/test_remove_model_family_endpoint.py create mode 100644 apps/api/tests/contract/test_remove_model_family_mcp_tool.py create mode 100644 apps/api/tests/integration/test_remove_model_family_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_remove_model_family_decider.py create mode 100644 apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py create mode 100644 apps/api/tests/unit/equipment/test_remove_model_family_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7e6e407c3..bff835953 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -23615,6 +23615,98 @@ ] } }, + "/models/{model_id}/families/{family_id}": { + "delete": { + "operationId": "delete_models_family_models__model_id__families__family_id__delete", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + { + "description": "Family id to remove from the Model.declared_families set.", + "in": "path", + "name": "family_id", + "required": true, + "schema": { + "description": "Family id to remove from the Model.declared_families set.", + "format": "uuid", + "title": "Family Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is in `Deprecated` status (mutation requires `Defined` or `Versioned`), OR the family_id is not in the Model's declared_families set, OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter failed schema validation." + } + }, + "summary": "Remove a Family from an existing Model's declared_families set", + "tags": [ + "equipment" + ] + } + }, "/models/{model_id}/versions": { "post": { "operationId": "post_models_versions_models__model_id__versions_post", diff --git a/apps/api/src/cora/equipment/features/remove_model_family/__init__.py b/apps/api/src/cora/equipment/features/remove_model_family/__init__.py new file mode 100644 index 000000000..d8e349bb8 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/__init__.py @@ -0,0 +1,25 @@ +"""Vertical slice for the `RemoveModelFamily` command. + +Module-as-namespace surface: + + from cora.equipment.features import remove_model_family + + cmd = remove_model_family.RemoveModelFamily(model_id=..., family_id=...) + handler = remove_model_family.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.remove_model_family import tool +from cora.equipment.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.decider import decide +from cora.equipment.features.remove_model_family.handler import Handler, bind +from cora.equipment.features.remove_model_family.route import router + +__all__ = [ + "Handler", + "RemoveModelFamily", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/remove_model_family/command.py b/apps/api/src/cora/equipment/features/remove_model_family/command.py new file mode 100644 index 000000000..303366630 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/command.py @@ -0,0 +1,33 @@ +"""The `RemoveModelFamily` command, intent dataclass for this slice. + +Targeted-mutation: incremental removal of a single Family from the +Model's `declared_families` set. Sibling of `add_model_family`. + +The operational pattern is "vendor firmware update drops support for +a technique" or "catalog entry no longer advertises a Family"; +`version_model` remains the path when a revision genuinely re-authors +the catalog entry (matches the Family/Method/Plan/Practice +replace-on-version precedent). + +`model_id` is the target Model aggregate. `family_id` is the Family +being undeclared. Unlike `add_model_family`, the handler does NOT +resolve the Family against the Family registry: removal only +requires that `family_id` already sits in `declared_families` (the +Family may have been deprecated or deleted, and removal still +proceeds). + +Strict-not-idempotent: removing a Family not in `declared_families` +raises `ModelFamilyNotPresentError` (same precedent as +`add_model_family` and `remove_asset_family`). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class RemoveModelFamily: + """Remove a Family from an existing model's `declared_families` set.""" + + model_id: UUID + family_id: UUID diff --git a/apps/api/src/cora/equipment/features/remove_model_family/decider.py b/apps/api/src/cora/equipment/features/remove_model_family/decider.py new file mode 100644 index 000000000..2f9f2d7a4 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/decider.py @@ -0,0 +1,66 @@ +"""Pure decider for the `RemoveModelFamily` command. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected, on +the same "deprecated catalog entry is frozen" rationale that drives +`ModelVersioned` and `ModelFamilyAdded` rejection from `Deprecated` +in the events module. + +There is no dedicated `ModelCannotRemoveFamilyError` in the Model +aggregate; the Model aggregate carries `ModelCannotVersionError` +as its general "cannot mutate from Deprecated" gate (add and remove +are conceptually version-like mutations of the declared-families +set), so this slice reuses it. The diagnostic message stays +accurate because `ModelCannotVersionError` already enumerates the +allowed `Defined | Versioned` source states. + +The decider does NOT verify the referenced Family id resolves to a +real Family stream; removal only requires that the id already sits +in `declared_families`. The Family may have been deprecated or +deleted in the Family registry, and removal still proceeds. + +Strict-not-idempotent: removing an absent family raises +`ModelFamilyNotPresentError` (mirrors `add_model_family` and +`remove_asset_family`). + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must not be Deprecated -> ModelCannotVersionError + - family_id must already be in state.declared_families + (strict-not-idempotent) -> ModelFamilyNotPresentError +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + Model, + ModelCannotVersionError, + ModelFamilyNotPresentError, + ModelFamilyRemoved, + ModelNotFoundError, + ModelStatus, +) +from cora.equipment.features.remove_model_family.command import RemoveModelFamily + + +def decide( + state: Model | None, + command: RemoveModelFamily, + *, + now: datetime, +) -> list[ModelFamilyRemoved]: + """Decide the events produced by removing a family from an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status is ModelStatus.DEPRECATED: + raise ModelCannotVersionError(state.id, current_status=state.status) + if command.family_id not in state.declared_families: + raise ModelFamilyNotPresentError(state.id, command.family_id) + return [ + ModelFamilyRemoved( + model_id=state.id, + family_id=command.family_id, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/remove_model_family/handler.py b/apps/api/src/cora/equipment/features/remove_model_family/handler.py new file mode 100644 index 000000000..74706cb25 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/handler.py @@ -0,0 +1,137 @@ +"""Application handler for the `remove_model_family` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the `add_model_family` precedent for the stream load + fold + decide ++ append spine, minus the cross-BC Family lookup: removal only needs +`family_id` to be present in the Model's `declared_families`, and it +proceeds even if the referenced Family has since been +deprecated or deleted from the Family registry. + +Not idempotency-wrapped: domain-strict via +`ModelFamilyNotPresentError` on retry (mirrors +`remove_asset_family`). +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "RemoveModelFamily" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every remove_model_family handler implements.""" + + async def __call__( + self, + command: RemoveModelFamily, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a remove_model_family handler closed over the shared deps.""" + + async def handler( + command: RemoveModelFamily, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "remove_model_family.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "remove_model_family.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "remove_model_family.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/remove_model_family/route.py b/apps/api/src/cora/equipment/features/remove_model_family/route.py new file mode 100644 index 000000000..5ea306d72 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/route.py @@ -0,0 +1,76 @@ +"""HTTP route for the `remove_model_family` slice. + +Targeted-mutation endpoint at `DELETE /models/{model_id}/families/{family_id}`. +Both ids travel as path parameters; there is no request body. 204 No +Content on success. Status (`Defined` or `Versioned`) is preserved; +`Deprecated` rejects the mutation. No `Idempotency-Key` (update-style, +mirrors `add_model_family`). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status + +from cora.equipment.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.remove_model_family + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.delete( + "/models/{model_id}/families/{family_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is in `Deprecated` status (mutation requires " + "`Defined` or `Versioned`), OR the family_id is not in " + "the Model's declared_families set, OR a concurrent " + "write to the same model stream conflicted (optimistic " + "concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter failed schema validation.", + }, + }, + summary="Remove a Family from an existing Model's declared_families set", +) +async def delete_models_family( + model_id: Annotated[UUID, Path(description="Target model's id.")], + family_id: Annotated[ + UUID, Path(description="Family id to remove from the Model.declared_families set.") + ], + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + RemoveModelFamily(model_id=model_id, family_id=family_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/remove_model_family/tool.py b/apps/api/src/cora/equipment/features/remove_model_family/tool.py new file mode 100644 index 000000000..14fd2c00e --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/tool.py @@ -0,0 +1,50 @@ +"""MCP tool for the `remove_model_family` slice. + +Mirror of `add_model_family` MCP tool: single model_id arg plus an +extra UUID arg (family_id). Domain / application errors propagate +to FastMCP, which wraps them as `isError: true`. +""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import Field + +from cora.equipment.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `remove_model_family` tool on the given MCP server.""" + + @mcp.tool( + name="remove_model_family", + description=( + "Remove a Family from a vendor-catalog Model declared_families set. " + "Strict-not-idempotent: removing an absent family raises an error. " + "Does not cascade through existing Assets bound to the Model." + ), + ) + async def remove_model_family_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + family_id: Annotated[ + UUID, + Field(description="Family id to remove from the Model.declared_families set."), + ], + ) -> None: + handler = get_handler() + await handler( + RemoveModelFamily(model_id=model_id, family_id=family_id), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 1ab721397..797332365 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -136,6 +136,7 @@ relocate_asset, remove_asset_family, remove_asset_port, + remove_model_family, restore_asset, uninstall_asset, update_asset_settings, @@ -216,6 +217,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(version_model.router) app.include_router(deprecate_model.router) app.include_router(add_model_family.router) + app.include_router(remove_model_family.router) app.include_router(get_family.router) app.include_router(version_family.router) app.include_router(deprecate_family.router) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index befdbb02d..61aa3b9af 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -48,6 +48,7 @@ tool as remove_asset_family_tool, ) from cora.equipment.features.remove_asset_port import tool as remove_asset_port_tool +from cora.equipment.features.remove_model_family import tool as remove_model_family_tool from cora.equipment.features.restore_asset import tool as restore_asset_tool from cora.equipment.features.uninstall_asset import tool as uninstall_asset_tool from cora.equipment.features.update_asset_settings import ( @@ -89,6 +90,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().add_model_family, ) + remove_model_family_tool.register( + mcp, + get_handler=lambda: get_handlers().remove_model_family, + ) get_family_tool.register( mcp, get_handler=lambda: get_handlers().get_family, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 8e9a64a14..5fdf41179 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -59,6 +59,7 @@ relocate_asset, remove_asset_family, remove_asset_port, + remove_model_family, restore_asset, uninstall_asset, update_asset_settings, @@ -92,6 +93,7 @@ class EquipmentHandlers: version_model: version_model.Handler deprecate_model: deprecate_model.Handler add_model_family: add_model_family.Handler + remove_model_family: remove_model_family.Handler get_family: get_family.Handler version_family: version_family.Handler deprecate_family: deprecate_family.Handler @@ -168,6 +170,11 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="AddModelFamily", bc=_BC, ), + remove_model_family=with_tracing( + remove_model_family.bind(deps), + command_name="RemoveModelFamily", + bc=_BC, + ), get_family=with_tracing( get_family.bind(deps), command_name="GetFamily", diff --git a/apps/api/tests/contract/test_remove_model_family_endpoint.py b/apps/api/tests/contract/test_remove_model_family_endpoint.py new file mode 100644 index 000000000..55d15ed7a --- /dev/null +++ b/apps/api/tests/contract/test_remove_model_family_endpoint.py @@ -0,0 +1,150 @@ +"""Contract tests for `DELETE /models/{model_id}/families/{family_id}`. + +Targeted-mutation endpoint removing a single Family from the Model's +`declared_families` set. 204 No Content on success. Both ids travel +as path parameters; no request body. + +Unlike `add_model_family`, the `remove_model_family` slice performs +NO cross-BC Family lookup; removing a Family that has been deprecated +or deleted from the Family registry still succeeds if it sits in +`declared_families`. The seeding `define_model` call DOES still +resolve `list_family_ids` cross-BC, so we stub that one symbol on +the `define_model` handler module. + +The 409-on-Deprecated path appends a `ModelDeprecated` event directly +to the in-memory event store (no `deprecate_model` slice exercised), +then exercises the route and expects 409. +""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + ModelDeprecated, + event_type_name, + to_payload, +) +from cora.infrastructure.event_envelope import to_new_event + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fd01") +_OTHER_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fd02") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def accept_families(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[UUID]]: + """Stub `list_family_ids` on the `define_model` handler so the + seeding call accepts the fixed family-id set. The + `remove_model_family` handler does NOT perform this lookup.""" + known: list[UUID] = [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] + + async def _stub(_pool: object) -> list[UUID]: + return list(known) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + yield known + + +def _define_body() -> dict[str, object]: + return { + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +async def _append_deprecated_event(app: FastAPI, model_id: UUID) -> None: + deps = app.state.deps + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=uuid4(), + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + _, current_version = await deps.event_store.load("Model", model_id) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=current_version, + events=[new_event], + ) + + +@pytest.mark.contract +def test_delete_remove_model_family_returns_204_on_success( + accept_families: list[UUID], +) -> None: + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.delete(f"/models/{model_id}/families/{_FIXED_FAMILY_ID}") + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_delete_remove_model_family_returns_404_when_model_does_not_exist( + accept_families: list[UUID], +) -> None: + _ = accept_families + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.delete(f"/models/{missing_id}/families/{_FIXED_FAMILY_ID}") + assert response.status_code == 404 + + +@pytest.mark.contract +def test_delete_remove_model_family_returns_409_when_family_absent( + accept_families: list[UUID], +) -> None: + """Strict-not-idempotent: removing a family not in + declared_families surfaces as 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + # `_OTHER_FAMILY_ID` was never added to declared_families; the + # seed only declares `_FIXED_FAMILY_ID`. + response = client.delete(f"/models/{model_id}/families/{_OTHER_FAMILY_ID}") + assert response.status_code == 409 + assert "does not declare" in response.json()["detail"] + + +@pytest.mark.contract +async def test_delete_remove_model_family_returns_409_when_deprecated( + accept_families: list[UUID], +) -> None: + """Deprecated Models cannot accept family removals; 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + await _append_deprecated_event(client.app, model_id) # type: ignore[arg-type] + response = client.delete(f"/models/{model_id}/families/{_FIXED_FAMILY_ID}") + assert response.status_code == 409 + assert "Deprecated" in response.json()["detail"] diff --git a/apps/api/tests/contract/test_remove_model_family_mcp_tool.py b/apps/api/tests/contract/test_remove_model_family_mcp_tool.py new file mode 100644 index 000000000..cff309b52 --- /dev/null +++ b/apps/api/tests/contract/test_remove_model_family_mcp_tool.py @@ -0,0 +1,175 @@ +"""Contract tests for the `remove_model_family` MCP tool. + +Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. + +Unlike `add_model_family`, this slice performs NO cross-BC Family +lookup, so the failure shapes pinned at the MCP wire are: + + - missing argument -> isError: true (Pydantic schema validation) + - present model + absent family -> isError: true ("does not declare") + - missing model stream -> isError: true ("not found") + +The seeding `define_model` call still needs `list_family_ids` +stubbed so we can seed a real model via REST in the same TestClient +before invoking the MCP tool. +""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fe01") + + +def _stub_define_model_family_lookup( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + async def _stub(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + + +def _seed_model_via_rest(client: TestClient) -> UUID: + response = client.post( + "/models", + json={ + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + }, + ) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +@pytest.mark.contract +def test_mcp_lists_remove_model_family_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "remove_model_family" in tool_names + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_description_matches_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + remove_model_family = tools_by_name["remove_model_family"] + description = remove_model_family["description"] + assert "Family" in description + assert "vendor-catalog Model" in description + assert "declared_families" in description + assert "Strict-not-idempotent" in description + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "remove_model_family", + "arguments": {"model_id": str(uuid4())}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_returns_iserror_on_absent_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Strict-not-idempotent: removing a family not in declared_families + raises ModelFamilyNotPresentError, which FastMCP wraps as + isError: true with a 'does not declare' diagnostic.""" + _stub_define_model_family_lookup(monkeypatch, [_FIXED_FAMILY_ID]) + absent_family_id = uuid4() + with TestClient(create_app()) as client: + model_id = _seed_model_via_rest(client) + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "remove_model_family", + "arguments": { + "model_id": str(model_id), + "family_id": str(absent_family_id), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "does not declare" in result["content"][0]["text"] + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_returns_iserror_on_unknown_model() -> None: + """Missing model stream surfaces ModelNotFoundError, which FastMCP + wraps as isError: true with a 'not found' diagnostic. No cross-BC + lookup runs, so no stub is needed.""" + missing_model_id = uuid4() + missing_family_id = uuid4() + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "remove_model_family", + "arguments": { + "model_id": str(missing_model_id), + "family_id": str(missing_family_id), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/integration/test_remove_model_family_handler_postgres.py b/apps/api/tests/integration/test_remove_model_family_handler_postgres.py new file mode 100644 index 000000000..90db70144 --- /dev/null +++ b/apps/api/tests/integration/test_remove_model_family_handler_postgres.py @@ -0,0 +1,196 @@ +"""End-to-end integration test: remove_model_family against real Postgres. + +Round-trip: define a Family, define a Model declaring it, add a second +Family to the Model, remove one of them, and read the events back from +the event store. Verifies the ModelFamilyRemoved payload shape and +the strict-not-idempotent guard (removing an absent family raises). + +Unlike `add_model_family`, this slice performs NO cross-BC Family +lookup; the only cross-BC seeding still required is via +`define_model` (and `add_model_family`) which DO call +`list_family_ids`. Those calls hit the real +`proj_equipment_family_summary` projection backed by the +`db_pool` fixture. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelFamilyNotPresentError, + fold, + from_stored, +) +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + remove_model_family, +) +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.remove_model_family import RemoveModelFamily +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined rows into `proj_equipment_family_summary` so + the Family read repo called by `define_model.handler` and + `add_model_family.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_remove_model_family_persists_event_with_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed two Families, define a Model declaring one, + add the other via add_model_family, then remove the second one via + remove_model_family. Verify ModelFamilyRemoved is persisted with + the expected payload shape and fold reflects the contracted + declared_families set.""" + family_a_id = UUID("01900000-0000-7000-8000-00000062d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000062d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000062d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000062d00f") + model_id = UUID("01900000-0000-7000-8000-00000062ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000062ca0e") + added_event_id = UUID("01900000-0000-7000-8000-00000062ca1a") + removed_event_id = UUID("01900000-0000-7000-8000-00000062ca2a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + added_event_id, + removed_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 3 + assert [e.event_type for e in events] == [ + "ModelDefined", + "ModelFamilyAdded", + "ModelFamilyRemoved", + ] + removed = events[2] + assert removed.event_id == removed_event_id + assert removed.metadata == {"command": "RemoveModelFamily"} + assert removed.payload == { + "model_id": str(model_id), + "family_id": str(family_b_id), + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the targeted mutation. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.declared_families == frozenset({family_a_id}) + + +@pytest.mark.integration +async def test_remove_model_family_rejects_absent_family( + db_pool: asyncpg.Pool, +) -> None: + """Strict-not-idempotent: removing a family not in declared_families + raises `ModelFamilyNotPresentError` and writes no new event. No + cross-BC Family lookup is performed by the slice; the absent + family_id is rejected purely by the decider against the folded + state.""" + family_id = UUID("01900000-0000-7000-8000-00000062f001") + family_event_id = UUID("01900000-0000-7000-8000-00000062f00e") + model_id = UUID("01900000-0000-7000-8000-00000062ca41") + define_event_id = UUID("01900000-0000-7000-8000-00000062ca4e") + unused_remove_event_id = UUID("01900000-0000-7000-8000-00000062ca5a") + absent_family_id = UUID("01900000-0000-7000-8000-0000000bad42") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, define_event_id, unused_remove_event_id], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(ModelFamilyNotPresentError) as exc_info: + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=model_id, family_id=absent_family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == model_id + assert exc_info.value.family_id == absent_family_id + + _, version = await deps.event_store.load("Model", model_id) + assert version == 1 diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_decider.py b/apps/api/tests/unit/equipment/test_remove_model_family_decider.py new file mode 100644 index 000000000..912eebba6 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_model_family_decider.py @@ -0,0 +1,141 @@ +"""Pure-decider tests for the `remove_model_family` slice. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected via +`ModelCannotVersionError` (Model's general "cannot mutate from +Deprecated" gate, reused by the add/remove family slices). + +Strict-not-idempotent: removing a family not in `declared_families` +raises `ModelFamilyNotPresentError`, mirroring the +`remove_asset_family` precedent. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelFamilyNotPresentError, + ModelFamilyRemoved, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import remove_model_family +from cora.equipment.features.remove_model_family import RemoveModelFamily + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + declared_families: frozenset[UUID] | None = None, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=declared_families + if declared_families is not None + else frozenset({uuid4()}), + status=status, + version=version, + ) + + +@pytest.mark.unit +def test_decide_emits_model_family_removed_from_defined_state() -> None: + existing = uuid4() + state = _model(status=ModelStatus.DEFINED, declared_families=frozenset({existing})) + events = remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert events == [ModelFamilyRemoved(model_id=state.id, family_id=existing, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_emits_model_family_removed_from_versioned_state() -> None: + """Status preserved across targeted mutation; Versioned is a valid source.""" + existing = uuid4() + state = _model( + status=ModelStatus.VERSIONED, + version="v2", + declared_families=frozenset({existing}), + ) + events = remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert events == [ModelFamilyRemoved(model_id=state.id, family_id=existing, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_raises_cannot_version_when_deprecated() -> None: + """Deprecated catalog entries are frozen; remove_model_family rejects.""" + existing = uuid4() + state = _model( + status=ModelStatus.DEPRECATED, + version="v1", + declared_families=frozenset({existing}), + ) + with pytest.raises(ModelCannotVersionError) as exc_info: + remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + remove_model_family.decide( + state=None, + command=RemoveModelFamily(model_id=target_id, family_id=uuid4()), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_not_present_on_absent_family() -> None: + """Strict-not-idempotent: removing an absent family raises rather + than no-op so operators can detect 'wait, this was never declared' + instead of silently succeeding.""" + declared = uuid4() + absent = uuid4() + state = _model(declared_families=frozenset({declared})) + with pytest.raises(ModelFamilyNotPresentError) as exc_info: + remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=absent), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.family_id == absent + + +@pytest.mark.unit +def test_decide_is_pure_same_inputs_same_outputs() -> None: + family = uuid4() + state = _model(declared_families=frozenset({family})) + command = RemoveModelFamily(model_id=state.id, family_id=family) + first = remove_model_family.decide(state=state, command=command, now=_NOW) + second = remove_model_family.decide(state=state, command=command, now=_NOW) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py b/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py new file mode 100644 index 000000000..3c0506bb6 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py @@ -0,0 +1,167 @@ +"""Property-based tests for `remove_model_family.decide` (Equipment BC). + +Targeted mutation of `Model.declared_families`; status is preserved +across the mutation and only `Deprecated` is rejected (via the shared +`ModelCannotVersionError` gate). Universal claims across generated +inputs: + + - state in {Defined, Versioned} + family_id IN declared_families + emits exactly one ModelFamilyRemoved with the injected `now` + timestamp. + - family_id NOT in declared_families always raises + ModelFamilyNotPresentError carrying the model + family id. + - state=None always raises ModelNotFoundError carrying the + command's model_id. + - state.status==Deprecated always raises ModelCannotVersionError + carrying the Deprecated source status. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelFamilyNotPresentError, + ModelFamilyRemoved, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import remove_model_family +from cora.equipment.features.remove_model_family import RemoveModelFamily +from tests._strategies import aware_datetimes + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + +# Mutable source statuses; only Deprecated is rejected at the slice gate. +_MUTABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# 1 to 5 pre-existing declared family ids; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + + +def _model( + model_id: UUID, + *, + status: ModelStatus, + declared_families: frozenset[UUID], +) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=declared_families, + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + now=aware_datetimes(), + pick_index=st.integers(min_value=0, max_value=4), +) +def test_remove_model_family_emits_one_event_for_present_family( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + now: datetime, + pick_index: int, +) -> None: + """Mutable source + family_id IN declared_families -> exactly one + ModelFamilyRemoved with the injected `now`.""" + # Pick a deterministic family id from the existing set (declared has + # at least one member, so the modulo always lands). + declared_list = sorted(declared_families, key=str) + target = declared_list[pick_index % len(declared_list)] + state = _model(model_id, status=status, declared_families=declared_families) + command = RemoveModelFamily(model_id=model_id, family_id=target) + events = remove_model_family.decide(state=state, command=command, now=now) + assert events == [ModelFamilyRemoved(model_id=model_id, family_id=target, occurred_at=now)] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + absent_family=st.uuids(), + now=aware_datetimes(), +) +def test_remove_model_family_with_absent_family_always_raises( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + absent_family: UUID, + now: datetime, +) -> None: + """family_id NOT in declared_families -> ModelFamilyNotPresentError.""" + # Ensure the absent family is genuinely missing from the prior set. + declared_without_absent = declared_families - {absent_family} + state = _model(model_id, status=status, declared_families=declared_without_absent) + command = RemoveModelFamily(model_id=model_id, family_id=absent_family) + with pytest.raises(ModelFamilyNotPresentError) as exc: + remove_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.family_id == absent_family + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_remove_model_family_on_empty_state_always_raises_not_found( + model_id: UUID, + family_id: UUID, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = RemoveModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelNotFoundError) as exc: + remove_model_family.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + declared_families=_DECLARED_FAMILIES, + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_remove_model_family_on_deprecated_state_always_raises_cannot_version( + model_id: UUID, + declared_families: frozenset[UUID], + family_id: UUID, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotVersionError, regardless of + whether family_id would have been a present remove or an absent one.""" + state = _model( + model_id, + status=ModelStatus.DEPRECATED, + declared_families=declared_families, + ) + command = RemoveModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelCannotVersionError) as exc: + remove_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_handler.py b/apps/api/tests/unit/equipment/test_remove_model_family_handler.py new file mode 100644 index 000000000..180dae5b7 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_model_family_handler.py @@ -0,0 +1,246 @@ +"""Unit tests for the `remove_model_family` application handler. + +Update-style handler (mirrors `remove_asset_family` and `version_model`): +load + fold + decide + append. Not idempotency-wrapped. + +Unlike `add_model_family`, this slice performs NO cross-BC Family +lookup: removal only requires `family_id` to be present in +`declared_families`. The Family may have been deprecated or deleted +from the Family registry and removal still proceeds. + +The seeding `define_model` call DOES still resolve `list_family_ids` +cross-BC; the unit harness has no Postgres pool, so we monkeypatch +that symbol on the `define_model` handler module to a fixed accept- +all stub so the seed succeeds. The slice under test imports nothing +from `list_family_ids`. + +The Deprecated path seeds a `ModelDeprecated` event directly onto +the in-memory store (no `deprecate_model` slice is exercised in +this test file), then invokes the handler and expects +`ModelCannotVersionError`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotVersionError, + ModelDeprecated, + ModelFamilyNotPresentError, + ModelNotFoundError, + event_type_name, + to_payload, +) +from cora.equipment.features import define_model, remove_model_family +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.remove_model_family import RemoveModelFamily +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ad11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad12") +_REMOVED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad13") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fc01") +_FAMILY_ABSENT_ID = UUID("01900000-0000-7000-8000-00000000fc99") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _REMOVED_EVENT_ID, _DEPRECATED_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_seed_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_family_ids` only on the `define_model` handler. + + The slice under test (`remove_model_family`) does NOT perform a + cross-BC family lookup, so only the seeding `define_model` call + needs the stub. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized + in `Defined` status with `_FAMILY_A_ID` declared.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_deprecated_model(deps: Kernel, store: InMemoryEventStore) -> None: + """Append a `ModelDeprecated` event directly so the model lands in + `Deprecated` status without going through a `deprecate_model` slice + in this test file.""" + await _seed_model(deps) + deprecated = ModelDeprecated( + model_id=_MODEL_ID, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=_DEPRECATED_EVENT_ID, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + await store.append( + stream_type="Model", + stream_id=_MODEL_ID, + expected_version=1, + events=[new_event], + ) + + +@pytest.mark.unit +async def test_handler_returns_none_and_appends_event_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyRemoved"] + removed = events[1] + assert removed.event_id == _REMOVED_EVENT_ID + assert removed.metadata == {"command": "RemoveModelFamily"} + assert removed.payload["model_id"] == str(_MODEL_ID) + assert removed.payload["family_id"] == str(_FAMILY_A_ID) + assert removed.payload["occurred_at"] == _NOW.isoformat() + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await remove_model_family.bind(deny_deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_stream_is_missing() -> None: + """An unseeded model stream surfaces ModelNotFoundError. No cross-BC + lookup runs; the decider rejects because state is None.""" + deps = _build_deps() + + with pytest.raises(ModelNotFoundError) as exc_info: + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + + +@pytest.mark.unit +async def test_handler_raises_not_present_on_absent_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Removing a family not in declared_families surfaces + ModelFamilyNotPresentError (strict-not-idempotent). No cross-BC + lookup runs.""" + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + with pytest.raises(ModelFamilyNotPresentError) as exc_info: + await remove_model_family.bind(deps)( + # _FAMILY_ABSENT_ID was never declared at define_model time. + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_ABSENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + assert exc_info.value.family_id == _FAMILY_ABSENT_ID + + +@pytest.mark.unit +async def test_handler_raises_cannot_version_when_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Deprecated Models cannot accept family removals.""" + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_deprecated_model(deps, store) + + with pytest.raises(ModelCannotVersionError): + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +def test_wire_equipment_includes_remove_model_family() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.remove_model_family) From 76ebf1c63f5033ec00f486f6d078e8c768bad13e Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 18:34:10 +0300 Subject: [PATCH 07/11] feat(equipment): get_model read slice + proj_equipment_model_summary Adds GET /models/{model_id} REST endpoint and get_model MCP tool backed by the new ModelSummaryProjection. Closes out the Model aggregate's first writable + readable surface. Read shape (event-store fold, NOT projection-row read): - get_model.bind(deps) -> Handler that loads the Model stream, folds via cora.equipment.aggregates.model.fold, returns the current Model state or None. The route maps None -> 404 ModelNotFoundError, mirroring get_family. Projection: proj_equipment_model_summary table for the future list_models slice and vendor-key uniqueness guard. - ModelDefined -> INSERT (status, manufacturer_*, part_number, declared_families JSONB, version_tag if set) - ModelVersioned -> wholesale UPDATE of the identity block - ModelDeprecated -> UPDATE status + deprecation_reason; vendor- key columns preserved (audit trail) - ModelFamilyAdded -> UPDATE declared_families (append + re-sort to canonical event-payload ordering) - ModelFamilyRemoved -> UPDATE declared_families (remove) manufacturer is split into 3 flat columns (manufacturer_name, manufacturer_identifier, manufacturer_identifier_type) for queryability rather than a JSONB blob, with a CHECK enforcing identifier and identifier_type both-set-or-both-null (the value-object pairing invariant lifted to SQL). declared_families stored as JSONB array of UUID strings (the event-payload-as-stored shape) since the read slice returns it verbatim. UNIQUE (manufacturer_name, part_number) index enforces the vendor-key uniqueness guard from the design memo Lock 4. Slice + projection files (11 new): - infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql - src/cora/equipment/projections/model.py (ModelSummaryProjection) - src/cora/equipment/aggregates/model/read.py (load_model, list_model_ids) - src/cora/equipment/features/get_model/{query,handler,route,tool, __init__}.py - tests/unit/equipment/test_get_model_handler.py - tests/unit/equipment/test_model_summary_projection.py - tests/contract/test_get_model_endpoint.py - tests/contract/test_get_model_mcp_tool.py - tests/integration/test_get_model_handler_postgres.py Wiring (5 edits): - equipment/wire.py: get_model handler bundled - equipment/tools.py: get_model MCP tool registered - equipment/routes.py: get_model.router included - equipment/_projections.py: ModelSummaryProjection registered - equipment/projections/__init__.py: export ModelSummaryProjection openapi.json regenerated to include GET /models/{model_id}. Tests: 26 new tests pass + 14490 architecture tests pass. ruff clean, pyright 0/0/0. This commit closes out the 6-slice Model aggregate scope. Architecture wiring (BOUNDED_CONTEXTS sync + sibling-symmetry fitness) is the next and final commit. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 172 +++++++++++ apps/api/src/cora/equipment/_projections.py | 2 + .../equipment/aggregates/model/__init__.py | 3 + .../cora/equipment/aggregates/model/read.py | 68 ++++ .../equipment/features/get_model/__init__.py | 27 ++ .../equipment/features/get_model/handler.py | 99 ++++++ .../equipment/features/get_model/query.py | 22 ++ .../equipment/features/get_model/route.py | 140 +++++++++ .../cora/equipment/features/get_model/tool.py | 105 +++++++ .../cora/equipment/projections/__init__.py | 2 + .../src/cora/equipment/projections/model.py | 217 +++++++++++++ apps/api/src/cora/equipment/routes.py | 2 + apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 8 + .../tests/contract/test_get_model_endpoint.py | 136 ++++++++ .../tests/contract/test_get_model_mcp_tool.py | 156 ++++++++++ .../test_get_model_handler_postgres.py | 160 ++++++++++ .../unit/equipment/test_get_model_handler.py | 212 +++++++++++++ .../test_model_summary_projection.py | 291 ++++++++++++++++++ ...0000_init_proj_equipment_model_summary.sql | 95 ++++++ 20 files changed, 1922 insertions(+) create mode 100644 apps/api/src/cora/equipment/aggregates/model/read.py create mode 100644 apps/api/src/cora/equipment/features/get_model/__init__.py create mode 100644 apps/api/src/cora/equipment/features/get_model/handler.py create mode 100644 apps/api/src/cora/equipment/features/get_model/query.py create mode 100644 apps/api/src/cora/equipment/features/get_model/route.py create mode 100644 apps/api/src/cora/equipment/features/get_model/tool.py create mode 100644 apps/api/src/cora/equipment/projections/model.py create mode 100644 apps/api/tests/contract/test_get_model_endpoint.py create mode 100644 apps/api/tests/contract/test_get_model_mcp_tool.py create mode 100644 apps/api/tests/integration/test_get_model_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_get_model_handler.py create mode 100644 apps/api/tests/unit/equipment/test_model_summary_projection.py create mode 100644 infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql diff --git a/apps/api/openapi.json b/apps/api/openapi.json index bff835953..939860791 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -5654,6 +5654,44 @@ "title": "ManufacturerIdentifierType", "type": "string" }, + "ManufacturerResponse": { + "description": "Nested DTO for a model's manufacturer.\n\n`name` is required; `identifier` and `identifier_type` are both\nset or both null (pairing invariant enforced by the domain VO).\n`identifier_type` is the closed-StrEnum scheme string value\n(ROR / GRID / ISNI) when present.", + "properties": { + "identifier": { + "anyOf": [ + { + "maxLength": 200, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Identifier" + }, + "identifier_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Identifier Type" + }, + "name": { + "maxLength": 200, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ManufacturerResponse", + "type": "object" + }, "MarkSupplyAvailableRequest": { "description": "Body for `POST /supplies/{supply_id}/mark-available`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining the first-observation declaration. Examples:\n\"operator walkdown confirms LN2 dewar pressure nominal\", \"control\nroom reports beam delivered after morning startup\", \"first-time\ncommissioning verified by ops\".", "properties": { @@ -5969,6 +6007,63 @@ "title": "ModelRefResponse", "type": "object" }, + "ModelResponse": { + "description": "Read-side DTO at the API boundary.\n\nCarries primitives, not domain VOs. Decouples the wire format\nfrom the domain model so the two can evolve independently.\n`status` is the StrEnum's string value (Defined / Versioned /\nDeprecated). `version_tag` is the operator-supplied label of the\nmost recent version_model call (null until first version).\n`declared_families` serializes as a sorted list of Family UUIDs\n(frozenset semantics in domain state, list at the JSON boundary;\nsorted by UUID string form for response determinism).", + "properties": { + "declared_families": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Declared Families", + "type": "array" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerResponse" + }, + "model_id": { + "format": "uuid", + "title": "Model Id", + "type": "string" + }, + "name": { + "maxLength": 200, + "title": "Name", + "type": "string" + }, + "part_number": { + "maxLength": 100, + "title": "Part Number", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "version_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version Tag" + } + }, + "required": [ + "model_id", + "name", + "manufacturer", + "part_number", + "declared_families", + "status", + "version_tag" + ], + "title": "ModelResponse", + "type": "object" + }, "MountSubjectRequest": { "description": "Body for `POST /subjects/{subject_id}/mount`.", "properties": { @@ -23415,6 +23510,83 @@ ] } }, + "/models/{model_id}": { + "get": { + "operationId": "get_models_models__model_id__get", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResponse" + } + } + }, + "description": "Successful Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the query." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "422": { + "description": "Path parameter failed schema validation." + } + }, + "summary": "Get a model by id", + "tags": [ + "equipment" + ] + } + }, "/models/{model_id}/deprecation": { "post": { "operationId": "post_models_deprecation_models__model_id__deprecation_post", diff --git a/apps/api/src/cora/equipment/_projections.py b/apps/api/src/cora/equipment/_projections.py index ca1cd0713..3868f59c3 100644 --- a/apps/api/src/cora/equipment/_projections.py +++ b/apps/api/src/cora/equipment/_projections.py @@ -15,6 +15,7 @@ FrameChildrenProjection, FrameConsumersProjection, FrameSummaryProjection, + ModelSummaryProjection, MountChildrenProjection, MountLookupProjection, MountSummaryProjection, @@ -32,6 +33,7 @@ def register_equipment_projections( registry.register(AssetSummaryProjection()) registry.register(AssetFamilyMembershipProjection()) registry.register(FamilySummaryProjection()) + registry.register(ModelSummaryProjection()) registry.register(FrameSummaryProjection()) registry.register(FrameChildrenProjection()) registry.register(FrameConsumersProjection()) diff --git a/apps/api/src/cora/equipment/aggregates/model/__init__.py b/apps/api/src/cora/equipment/aggregates/model/__init__.py index 6e9c52cf3..5459c77b5 100644 --- a/apps/api/src/cora/equipment/aggregates/model/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/model/__init__.py @@ -17,6 +17,7 @@ to_payload, ) from cora.equipment.aggregates.model.evolver import evolve, fold +from cora.equipment.aggregates.model.read import list_model_ids, load_model from cora.equipment.aggregates.model.state import ( MANUFACTURER_IDENTIFIER_MAX_LENGTH, MANUFACTURER_NAME_MAX_LENGTH, @@ -91,5 +92,7 @@ "evolve", "fold", "from_stored", + "list_model_ids", + "load_model", "to_payload", ] diff --git a/apps/api/src/cora/equipment/aggregates/model/read.py b/apps/api/src/cora/equipment/aggregates/model/read.py new file mode 100644 index 000000000..af2c7bf4a --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/read.py @@ -0,0 +1,68 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +"""Read repositories for the Model aggregate. + +`load_model(event_store, model_id) -> Model | None` mirrors +`load_family` / `load_actor` / `load_subject` / etc. + +`list_model_ids(pool) -> list[UUID]` reads every non-Deprecated Model +id from the summary projection. Mirrors `list_family_ids`; intended +for `inspect_plan_binding`-style candidate enumeration and for +cross-BC catalog-lookup preconditions in future slices. + +The Model summary projection (`proj_equipment_model_summary`) does +NOT carry per-FSM-transition timestamps (versioned_at, deprecated_at) +the way `proj_equipment_family_summary` does; the projection's +`created_at` is the only lifecycle timestamp materialized today. +Consumers that need transition timestamps would either fold the +event stream directly or trigger a future projection-schema +addition. No `load_model_timestamps` ships in this slice as a result. + +`_STREAM_TYPE = "Model"`. The stream-type string is the event store's +internal categorization key for this aggregate. +""" + +from uuid import UUID + +import asyncpg + +from cora.equipment.aggregates.model.events import from_stored +from cora.equipment.aggregates.model.evolver import fold +from cora.equipment.aggregates.model.state import Model +from cora.infrastructure.ports import EventStore + +_STREAM_TYPE = "Model" + + +async def load_model(event_store: EventStore, model_id: UUID) -> Model | None: + """Load and fold a Model's event stream into current state.""" + stored, _version = await event_store.load(_STREAM_TYPE, model_id) + events = [from_stored(s) for s in stored] + return fold(events) + + +_SELECT_MODEL_IDS_SQL = """ +SELECT model_id +FROM proj_equipment_model_summary +WHERE status <> 'Deprecated' +ORDER BY model_id::text +""" + + +async def list_model_ids(pool: asyncpg.Pool | None) -> list[UUID]: + """Read every non-Deprecated Model id from the summary projection. + + Mirrors `list_family_ids`: returns `[]` when `pool is None` + (test / no-database app_env), so callers do not need a defensive + None-check at every site. Tests that need a populated lookup + must wire a real pool. + + Deprecated Models are excluded at the SQL layer so they are not + offered as candidate sources in future cross-BC lookups; operators + can still inspect Deprecated Models directly via `get_model`. + """ + if pool is None: + return [] + async with pool.acquire() as conn: + rows = await conn.fetch(_SELECT_MODEL_IDS_SQL) + return [row["model_id"] for row in rows] diff --git a/apps/api/src/cora/equipment/features/get_model/__init__.py b/apps/api/src/cora/equipment/features/get_model/__init__.py new file mode 100644 index 000000000..5542b2ec9 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/__init__.py @@ -0,0 +1,27 @@ +"""Vertical slice for the `GetModel` query. + +Module-as-namespace surface, symmetric with command slices: + + from cora.equipment.features import get_model + + q = get_model.GetModel(model_id=...) + handler = get_model.bind(deps) + model = await handler(q, principal_id=..., correlation_id=...) + +Read slices have no decider (queries don't emit events); the handler +is a thin wrapper around `load_model`. The HTTP `router` and MCP +`tool` modules follow the `get_family` precedent. +""" + +from cora.equipment.features.get_model import tool +from cora.equipment.features.get_model.handler import Handler, bind +from cora.equipment.features.get_model.query import GetModel +from cora.equipment.features.get_model.route import router + +__all__ = [ + "GetModel", + "Handler", + "bind", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/get_model/handler.py b/apps/api/src/cora/equipment/features/get_model/handler.py new file mode 100644 index 000000000..e3a88898f --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/handler.py @@ -0,0 +1,99 @@ +"""Application handler for the `get_model` query slice. + +Cross-BC query-handler shape, mirrored from `get_family` / +`get_actor` / `get_subject`: + + 1. authorize(principal_id, query_name, conduit_id) -> Allow | Deny + 2. load_model(...) -> Model | None (fold-on-read) + 3. return Model | None -> caller maps None to 404 / isError + +Unlike `get_family`, no `ModelView` wrapper is needed: the Model +summary projection does NOT carry per-FSM-transition timestamps +(versioned_at, deprecated_at), so there is no projection-sourced +metadata to fold into the response. The route / tool layer reads +`Model` directly. If a future projection-schema addition lands +(transition timestamps), the same `FamilyView`-style bundle would +be introduced here without changing the slice contract. + +Query handlers do NOT emit `causation_id` log fields, since queries +have no causation chain (they don't emit events that downstream +commands react to). Same convention as `get_family` / +`get_actor` / `get_subject`. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import Model, load_model +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.get_model.query import GetModel +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_QUERY_NAME = "GetModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every get_model handler implements.""" + + async def __call__( + self, + query: GetModel, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> Model | None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a get_model handler closed over the shared deps.""" + + async def handler( + query: GetModel, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> Model | None: + _log.info( + "get_model.start", + query_name=_QUERY_NAME, + model_id=str(query.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_QUERY_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "get_model.denied", + query_name=_QUERY_NAME, + model_id=str(query.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + model = await load_model(deps.event_store, query.model_id) + _log.info( + "get_model.success", + query_name=_QUERY_NAME, + model_id=str(query.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + found=model is not None, + ) + return model + + return handler diff --git a/apps/api/src/cora/equipment/features/get_model/query.py b/apps/api/src/cora/equipment/features/get_model/query.py new file mode 100644 index 000000000..1c502b556 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/query.py @@ -0,0 +1,22 @@ +"""The `GetModel` query: intent dataclass for this read slice. + +Queries are dataclasses just like commands. Mirrors `GetFamily` / +`GetSubject` / `GetActor`: they name the read intent and carry only +the input the caller controls; the application handler adds context +(correlation_id, principal_id) at call time. + +Cross-BC pattern: queries are full vertical slices symmetric with +commands but without a decider (queries don't emit events). The +handler is essentially a thin wrapper around the aggregate's read +repository (`load_model`). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class GetModel: + """Read the current state of an existing model by id.""" + + model_id: UUID diff --git a/apps/api/src/cora/equipment/features/get_model/route.py b/apps/api/src/cora/equipment/features/get_model/route.py new file mode 100644 index 000000000..a2ecbff4e --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/route.py @@ -0,0 +1,140 @@ +"""HTTP route for the `get_model` query slice. + +`GET /models/{model_id}` returns 200 + ModelResponse on hit, 404 on +miss. The handler returns `Model | None`; the route maps None to 404 +via HTTPException (idiomatic in routes; the BC's exception-handler +infrastructure stays focused on domain / application errors raised +deeper in the stack). + +Response carries the vendor-catalog state: the `manufacturer` is a +nested `ManufacturerResponse` (required name plus optional opaque +identifier and closed-enum scheme), `declared_families` is the +sorted list of Family ids the catalog entry satisfies, `status` is +the StrEnum string value (Defined / Versioned / Deprecated), and +`version_tag` is the operator-supplied label of the most recent +`version_model` call (null until first version). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, +) +from cora.equipment.features.get_model.handler import Handler +from cora.equipment.features.get_model.query import GetModel +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class ManufacturerResponse(BaseModel): + """Nested DTO for a model's manufacturer. + + `name` is required; `identifier` and `identifier_type` are both + set or both null (pairing invariant enforced by the domain VO). + `identifier_type` is the closed-StrEnum scheme string value + (ROR / GRID / ISNI) when present. + """ + + name: str = Field(..., max_length=MANUFACTURER_NAME_MAX_LENGTH) + identifier: str | None = Field(default=None, max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH) + identifier_type: str | None = None + + +class ModelResponse(BaseModel): + """Read-side DTO at the API boundary. + + Carries primitives, not domain VOs. Decouples the wire format + from the domain model so the two can evolve independently. + `status` is the StrEnum's string value (Defined / Versioned / + Deprecated). `version_tag` is the operator-supplied label of the + most recent version_model call (null until first version). + `declared_families` serializes as a sorted list of Family UUIDs + (frozenset semantics in domain state, list at the JSON boundary; + sorted by UUID string form for response determinism). + """ + + model_id: UUID + name: str = Field(..., max_length=MODEL_NAME_MAX_LENGTH) + manufacturer: ManufacturerResponse + part_number: str = Field(..., max_length=MODEL_PART_NUMBER_MAX_LENGTH) + declared_families: list[UUID] + status: str + version_tag: str | None + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.get_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.get( + "/models/{model_id}", + status_code=status.HTTP_200_OK, + response_model=ModelResponse, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the query.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter failed schema validation.", + }, + }, + summary="Get a model by id", +) +async def get_models( + model_id: Annotated[UUID, Path(description="Target model's id.")], + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> ModelResponse: + model = await handler( + GetModel(model_id=model_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) + if model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Model {model_id} not found", + ) + manufacturer = model.manufacturer + return ModelResponse( + model_id=model.id, + name=model.name.value, + manufacturer=ManufacturerResponse( + name=manufacturer.name.value, + identifier=( + manufacturer.identifier.value if manufacturer.identifier is not None else None + ), + identifier_type=( + manufacturer.identifier_type.value + if manufacturer.identifier_type is not None + else None + ), + ), + part_number=model.part_number.value, + declared_families=sorted(model.declared_families, key=str), + status=model.status.value, + version_tag=model.version, + ) diff --git a/apps/api/src/cora/equipment/features/get_model/tool.py b/apps/api/src/cora/equipment/features/get_model/tool.py new file mode 100644 index 000000000..c89c7bfd8 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/tool.py @@ -0,0 +1,105 @@ +"""MCP tool for the `get_model` query slice. + +Surfaces the same handler the REST route uses. Returns a structured +GetModelOutput on hit. On miss raises an exception that FastMCP +wraps as `isError: true` with a text diagnostic, matching the REST +404 behaviour in MCP's error idiom (LLM consumers get a clear +"model not found" message rather than null structuredContent they +have to interpret). +""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, +) +from cora.equipment.features.get_model.handler import Handler +from cora.equipment.features.get_model.query import GetModel +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +class ManufacturerOutput(BaseModel): + """Structured output for a model's manufacturer. + + `name` is required; `identifier` and `identifier_type` are both + set or both null (pairing invariant enforced by the domain VO). + `identifier_type` is the closed-StrEnum scheme string value + (ROR / GRID / ISNI) when present. + """ + + name: str = Field(..., max_length=MANUFACTURER_NAME_MAX_LENGTH) + identifier: str | None = Field(default=None, max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH) + identifier_type: str | None = None + + +class GetModelOutput(BaseModel): + """Structured output of the `get_model` MCP tool. + + Mirrors the REST `ModelResponse` shape: `model_id`, `name`, + nested `manufacturer`, `part_number`, sorted `declared_families` + list, `status` enum string, and optional `version_tag`. + """ + + model_id: UUID + name: str = Field(..., max_length=MODEL_NAME_MAX_LENGTH) + manufacturer: ManufacturerOutput + part_number: str = Field(..., max_length=MODEL_PART_NUMBER_MAX_LENGTH) + declared_families: list[UUID] + status: str + version_tag: str | None + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `get_model` tool on the given MCP server.""" + + @mcp.tool( + name="get_model", + description="Fetch a vendor-catalog Model by id.", + ) + async def get_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target model's id."), + ], + ) -> GetModelOutput: + handler = get_handler() + model = await handler( + GetModel(model_id=model_id), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + if model is None: + msg = f"Model {model_id} not found" + raise ValueError(msg) + manufacturer = model.manufacturer + return GetModelOutput( + model_id=model.id, + name=model.name.value, + manufacturer=ManufacturerOutput( + name=manufacturer.name.value, + identifier=( + manufacturer.identifier.value if manufacturer.identifier is not None else None + ), + identifier_type=( + manufacturer.identifier_type.value + if manufacturer.identifier_type is not None + else None + ), + ), + part_number=model.part_number.value, + declared_families=sorted(model.declared_families, key=str), + status=model.status.value, + version_tag=model.version, + ) diff --git a/apps/api/src/cora/equipment/projections/__init__.py b/apps/api/src/cora/equipment/projections/__init__.py index a7e146e0f..53c02c342 100644 --- a/apps/api/src/cora/equipment/projections/__init__.py +++ b/apps/api/src/cora/equipment/projections/__init__.py @@ -14,6 +14,7 @@ from cora.equipment.projections.frame_children import FrameChildrenProjection from cora.equipment.projections.frame_consumers import FrameConsumersProjection from cora.equipment.projections.frame_summary import FrameSummaryProjection +from cora.equipment.projections.model import ModelSummaryProjection from cora.equipment.projections.mount_children import MountChildrenProjection from cora.equipment.projections.mount_lookup import MountLookupProjection from cora.equipment.projections.mount_summary import MountSummaryProjection @@ -26,6 +27,7 @@ "FrameChildrenProjection", "FrameConsumersProjection", "FrameSummaryProjection", + "ModelSummaryProjection", "MountChildrenProjection", "MountLookupProjection", "MountSummaryProjection", diff --git a/apps/api/src/cora/equipment/projections/model.py b/apps/api/src/cora/equipment/projections/model.py new file mode 100644 index 000000000..9300553bd --- /dev/null +++ b/apps/api/src/cora/equipment/projections/model.py @@ -0,0 +1,217 @@ +"""ModelSummaryProjection: folds the Model aggregate's 5 events into +the `proj_equipment_model_summary` read model that backs +`GET /models/{id}` and the future `list_models` slice, and that +materializes the Lock-4 vendor-key uniqueness guard +`(manufacturer_name, part_number)` for command-time precondition +checks. + + - ModelDefined -> INSERT (status=Defined; version_tag from + payload when present, NULL otherwise; manufacturer flat + columns; declared_families JSONB array sorted as carried + in the event payload) + - ModelVersioned -> UPDATE status=Versioned and REPLACE + name / manufacturer_name / manufacturer_identifier / + manufacturer_identifier_type / part_number / + declared_families / version_tag wholesale (a new revision + re-authors the catalog entry's identity block) + - ModelDeprecated -> UPDATE status=Deprecated and set + deprecation_reason; vendor-key columns + (manufacturer_name, part_number) and declared_families + preserved so the audit answer to "what was deprecated" + stays queryable + - ModelFamilyAdded -> UPDATE declared_families to append the + single family_id and re-sort, matching the canonical + sorted-string-array ordering used in event payloads + - ModelFamilyRemoved -> UPDATE declared_families to drop the + single family_id while preserving sort order + +All branches idempotent. `version_tag` lands in the projection on +Defined (when carried) and on Versioned, and is replaced wholesale +on Versioned; the Deprecated UPDATE does not touch it. The flat +manufacturer columns (rather than a single JSONB blob) keep the +vendor-key uniqueness index and the manufacturer-keyed filter path +queryable without JSONB expression indexes. + +The targeted-mutation events fold via pure-SQL re-aggregation +(`jsonb_array_elements_text` + `UNION` / filter + `jsonb_agg(... +ORDER BY ...)`) rather than read-then-rewrite in Python; this keeps +the apply step in one round trip and reproduces the canonical +sorted-array shape the event payloads carry for the wholesale +events. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +import json +from datetime import datetime +from uuid import UUID + +from cora.infrastructure.ports.event_store import StoredEvent +from cora.infrastructure.projection.handler import ConnectionLike + + +def _id(payload: dict[str, object]) -> UUID: + return UUID(str(payload["model_id"])) + + +def _manufacturer_columns( + payload: dict[str, object], +) -> tuple[str, str | None, str | None]: + """Extract flat manufacturer columns from a payload's `manufacturer` sub-dict. + + The pairing invariant (`identifier` and `identifier_type` both set + or both None) is preserved end-to-end: the event payload omits the + pair together, so .get() returning None for both is the correct + shape and the projection table's paired CHECK constraint allows it. + """ + manufacturer = payload["manufacturer"] + assert isinstance(manufacturer, dict) + name = str(manufacturer["name"]) + identifier_raw = manufacturer.get("identifier") + identifier_type_raw = manufacturer.get("identifier_type") + identifier = str(identifier_raw) if identifier_raw is not None else None + identifier_type = str(identifier_type_raw) if identifier_type_raw is not None else None + return name, identifier, identifier_type + + +_INSERT_MODEL_SQL = """ +INSERT INTO proj_equipment_model_summary + (model_id, name, + manufacturer_name, manufacturer_identifier, manufacturer_identifier_type, + part_number, declared_families, + status, version_tag, created_at) +VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'Defined', $8, $9) +ON CONFLICT (model_id) DO NOTHING +""" + +_UPDATE_VERSIONED_SQL = """ +UPDATE proj_equipment_model_summary +SET status = 'Versioned', + name = $2, + manufacturer_name = $3, + manufacturer_identifier = $4, + manufacturer_identifier_type = $5, + part_number = $6, + declared_families = $7::jsonb, + version_tag = $8, + updated_at = now() +WHERE model_id = $1 +""" + +_UPDATE_DEPRECATED_SQL = """ +UPDATE proj_equipment_model_summary +SET status = 'Deprecated', + deprecation_reason = $2, + updated_at = now() +WHERE model_id = $1 +""" + +# Append a single family_id to the JSONB array and re-sort. The UNION +# de-duplicates so re-applying ModelFamilyAdded is a no-op (the +# aggregate already rejected the duplicate at command time; this is +# the replay-safety layer). +_UPDATE_FAMILY_ADDED_SQL = """ +UPDATE proj_equipment_model_summary +SET declared_families = COALESCE(( + SELECT jsonb_agg(elem ORDER BY elem) + FROM ( + SELECT jsonb_array_elements_text(declared_families) AS elem + UNION + SELECT $2::text + ) sub + ), '[]'::jsonb), + updated_at = now() +WHERE model_id = $1 +""" + +# Drop a single family_id from the JSONB array while preserving sort +# order. The WHERE clause inside the subquery skips the removed id; +# the outer COALESCE handles the all-removed degenerate case (which +# the aggregate's empty-set guard makes unreachable in practice but +# keeps the projection robust under replay of historical streams). +_UPDATE_FAMILY_REMOVED_SQL = """ +UPDATE proj_equipment_model_summary +SET declared_families = COALESCE(( + SELECT jsonb_agg(elem ORDER BY elem) + FROM ( + SELECT jsonb_array_elements_text(declared_families) AS elem + ) sub + WHERE elem <> $2::text + ), '[]'::jsonb), + updated_at = now() +WHERE model_id = $1 +""" + + +class ModelSummaryProjection: + """Maintains the `proj_equipment_model_summary` read model.""" + + name = "proj_equipment_model_summary" + subscribed_event_types = frozenset( + { + "ModelDefined", + "ModelVersioned", + "ModelDeprecated", + "ModelFamilyAdded", + "ModelFamilyRemoved", + } + ) + + async def apply( + self, + event: StoredEvent, + conn: ConnectionLike, + ) -> None: + match event.event_type: + case "ModelDefined": + name, identifier, identifier_type = _manufacturer_columns(event.payload) + declared_families = event.payload.get("declared_families", []) + await conn.execute( + _INSERT_MODEL_SQL, + _id(event.payload), + event.payload["name"], + name, + identifier, + identifier_type, + event.payload["part_number"], + json.dumps(declared_families), + event.payload.get("version_tag"), + datetime.fromisoformat(event.payload["occurred_at"]), + ) + case "ModelVersioned": + name, identifier, identifier_type = _manufacturer_columns(event.payload) + declared_families = event.payload.get("declared_families", []) + await conn.execute( + _UPDATE_VERSIONED_SQL, + _id(event.payload), + event.payload["name"], + name, + identifier, + identifier_type, + event.payload["part_number"], + json.dumps(declared_families), + event.payload["version_tag"], + ) + case "ModelDeprecated": + await conn.execute( + _UPDATE_DEPRECATED_SQL, + _id(event.payload), + event.payload["reason"], + ) + case "ModelFamilyAdded": + await conn.execute( + _UPDATE_FAMILY_ADDED_SQL, + _id(event.payload), + str(event.payload["family_id"]), + ) + case "ModelFamilyRemoved": + await conn.execute( + _UPDATE_FAMILY_REMOVED_SQL, + _id(event.payload), + str(event.payload["family_id"]), + ) + case _: + pass + + +__all__ = ["ModelSummaryProjection"] diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 797332365..343ac2f05 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -127,6 +127,7 @@ get_asset, get_asset_integration_view, get_family, + get_model, install_asset, list_assets, list_families, @@ -218,6 +219,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(deprecate_model.router) app.include_router(add_model_family.router) app.include_router(remove_model_family.router) + app.include_router(get_model.router) app.include_router(get_family.router) app.include_router(version_family.router) app.include_router(deprecate_family.router) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index 61aa3b9af..f08b0089c 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -37,6 +37,7 @@ tool as get_asset_integration_view_tool, ) from cora.equipment.features.get_family import tool as get_family_tool +from cora.equipment.features.get_model import tool as get_model_tool from cora.equipment.features.install_asset import tool as install_asset_tool from cora.equipment.features.list_assets import tool as list_assets_tool from cora.equipment.features.list_families import tool as list_families_tool @@ -98,6 +99,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().get_family, ) + get_model_tool.register( + mcp, + get_handler=lambda: get_handlers().get_model, + ) version_family_tool.register( mcp, get_handler=lambda: get_handlers().version_family, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 5fdf41179..bc3bfe736 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -50,6 +50,7 @@ get_asset, get_asset_integration_view, get_family, + get_model, install_asset, list_assets, list_families, @@ -94,6 +95,7 @@ class EquipmentHandlers: deprecate_model: deprecate_model.Handler add_model_family: add_model_family.Handler remove_model_family: remove_model_family.Handler + get_model: get_model.Handler get_family: get_family.Handler version_family: version_family.Handler deprecate_family: deprecate_family.Handler @@ -175,6 +177,12 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="RemoveModelFamily", bc=_BC, ), + get_model=with_tracing( + get_model.bind(deps), + command_name="GetModel", + bc=_BC, + kind="query", + ), get_family=with_tracing( get_family.bind(deps), command_name="GetFamily", diff --git a/apps/api/tests/contract/test_get_model_endpoint.py b/apps/api/tests/contract/test_get_model_endpoint.py new file mode 100644 index 000000000..527122a62 --- /dev/null +++ b/apps/api/tests/contract/test_get_model_endpoint.py @@ -0,0 +1,136 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportFunctionMemberAccess=false, reportAttributeAccessIssue=false + +"""Contract tests for `GET /models/{model_id}`. + +Mirrors `test_get_family_endpoint.py`. Pinned response shape: +`{model_id, name, manufacturer, part_number, declared_families, +status, version_tag}` where `manufacturer` is the nested +`ManufacturerResponse` and `status` is the StrEnum's string value +(Defined / Versioned / Deprecated). + +The Model upstream `define_model` slice enforces a cross-BC precondition: +every entry in `declared_families` must resolve via the Family read +repo's `list_family_ids`, which is pool-backed and returns `[]` in the +in-memory TestClient harness. We monkeypatch the symbol imported into +the upstream handler module so the seed `POST /models` call succeeds +and we can exercise the read surface here. +""" + +from collections.abc import Iterator +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_family_ids` so `_FIXED_FAMILY_ID` always resolves.""" + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_model(client: TestClient) -> UUID: + response = client.post( + "/models", + json={ + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + }, + ) + assert response.status_code == 201, response.text + return UUID(response.json()["model_id"]) + + +@pytest.mark.contract +def test_get_model_returns_200_with_defined_status_for_new_model( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + model_id = _define_model(client) + response = client.get(f"/models/{model_id}") + + assert response.status_code == 200 + body = response.json() + assert body == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": None, + "identifier_type": None, + }, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + "status": "Defined", + # Null until version_model runs (no initial version_tag supplied). + "version_tag": None, + } + + +@pytest.mark.contract +def test_get_model_returns_404_for_unknown_id() -> None: + with TestClient(create_app()) as client: + response = client.get(f"/models/{uuid4()}") + assert response.status_code == 404 + body = response.json() + assert "detail" in body + assert "not found" in body["detail"].lower() + + +@pytest.mark.contract +def test_get_model_returns_422_for_malformed_model_id() -> None: + with TestClient(create_app()) as client: + response = client.get("/models/not-a-uuid") + assert response.status_code == 422 + + +@pytest.mark.contract +def test_get_model_returns_403_when_authorize_denies( + accept_family: UUID, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Authorize-deny surfaces as 403 via the BC's exception handler. + Patched at the bound-handler tier: the create_app default Authorize + is AllowAll, so we wrap the wired get_model handler to raise + UnauthorizedError on entry, the same way the production + TrustAuthorize would on a Deny.""" + _ = accept_family + from dataclasses import replace + + from cora.equipment.errors import UnauthorizedError + + with TestClient(create_app()) as client: + model_id = _define_model(client) + + async def _denying_handler(*_args: object, **_kwargs: object) -> None: + raise UnauthorizedError("denied for test") + + # Replace the wired get_model handler with one that always + # denies. The FastAPI dep resolves the handler via + # request.app.state.equipment, an EquipmentHandlers dataclass. + original_handlers = client.app.state.equipment + client.app.state.equipment = replace( + original_handlers, + get_model=_denying_handler, + ) + response = client.get(f"/models/{model_id}") + + assert response.status_code == 403 + body = response.json() + assert "detail" in body + assert "denied" in body["detail"].lower() diff --git a/apps/api/tests/contract/test_get_model_mcp_tool.py b/apps/api/tests/contract/test_get_model_mcp_tool.py new file mode 100644 index 000000000..4abe3d6cb --- /dev/null +++ b/apps/api/tests/contract/test_get_model_mcp_tool.py @@ -0,0 +1,156 @@ +"""Contract tests for the `get_model` MCP tool. + +Mirrors `test_get_family_mcp_tool.py`. Shared MCP helpers live in +`tests/contract/_mcp_helpers.py`. The Model upstream `define_model` +tool enforces a cross-BC `list_family_ids` precondition that is +pool-backed and returns `[]` in the in-memory harness; we +monkeypatch the upstream handler's binding so the seed tool call +succeeds. +""" + +from collections.abc import Iterator +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_model_via_tool(client: TestClient, headers: dict[str, str]) -> UUID: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "define_model", + "arguments": { + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + return UUID(body["result"]["structuredContent"]["model_id"]) + + +@pytest.mark.contract +def test_mcp_lists_get_model_tool() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 99, "method": "tools/list"}, + headers=headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "get_model" in tool_names + + +@pytest.mark.contract +def test_mcp_get_model_tool_returns_structured_model_for_known_id( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = open_session(client) + model_id = _define_model_via_tool(client, headers) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "get_model", + "arguments": {"model_id": str(model_id)}, + }, + }, + headers=headers, + ) + + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is False + structured = result["structuredContent"] + assert structured["model_id"] == str(model_id) + assert structured["name"] == "Aerotech ANT130-L" + assert structured["manufacturer"] == { + "name": "Aerotech", + "identifier": None, + "identifier_type": None, + } + assert structured["part_number"] == "ANT130-L" + assert structured["declared_families"] == [str(_FIXED_FAMILY_ID)] + assert structured["status"] == "Defined" + # Null until version_model runs (no initial version_tag supplied). + assert structured["version_tag"] is None + + +@pytest.mark.contract +def test_mcp_get_model_tool_returns_iserror_for_unknown_id() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "get_model", + "arguments": {"model_id": str(uuid4())}, + }, + }, + headers=headers, + ) + + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + assert "not found" in body["result"]["content"][0]["text"].lower() + + +@pytest.mark.contract +def test_mcp_get_model_tool_rejects_missing_argument() -> None: + """Calling `get_model` without `model_id` raises Pydantic input + validation, which FastMCP wraps as `isError: true`.""" + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "get_model", + "arguments": {}, + }, + }, + headers=headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True diff --git a/apps/api/tests/integration/test_get_model_handler_postgres.py b/apps/api/tests/integration/test_get_model_handler_postgres.py new file mode 100644 index 000000000..8189f829a --- /dev/null +++ b/apps/api/tests/integration/test_get_model_handler_postgres.py @@ -0,0 +1,160 @@ +"""Integration test: get_model handler against real Postgres. + +End-to-end: seed a Family + define a Model declaring it + version the +Model + add a second Family via add_model_family + GET back. Verifies +the handler folds the full event-stream history (Defined + Versioned ++ FamilyAdded) and returns the post-mutation Model state. + +Projection-row contents are NOT verified here (that's the projection +unit test's job). The GET endpoint loads via the event-store fold, +not the projection; this test pins that fold path against real +Postgres. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelName, + ModelStatus, + PartNumber, +) +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + get_model, + version_model, +) +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.get_model import GetModel +from cora.equipment.features.version_model import VersionModel +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined rows into `proj_equipment_family_summary` so + the Family read repo called by `define_model.handler` and + `add_model_family.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_get_model_loads_full_history_from_real_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Seed Family A + Family B, define Model declaring A, version + Model (wholesale identity replace), then add Family B via the + targeted-mutation slice. GET returns the post-mutation state: the + Versioned identity block plus the appended family.""" + family_a_id = UUID("01900000-0000-7000-8000-00000063d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000063d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000063d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000063d00f") + model_id = UUID("01900000-0000-7000-8000-00000063ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000063ca0e") + versioned_event_id = UUID("01900000-0000-7000-8000-00000063ca1a") + added_event_id = UUID("01900000-0000-7000-8000-00000063ca2b") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + versioned_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-LZS", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-LZS", + declared_families=frozenset({family_a_id}), + version_tag="v2", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + model = await get_model.bind(deps)( + GetModel(model_id=model_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert model is not None + assert model.id == model_id + # Versioned identity block (replaced wholesale on version_model). + assert model.name == ModelName("Aerotech ANT130-LZS") + assert model.part_number == PartNumber("ANT130-LZS") + assert model.status is ModelStatus.VERSIONED + assert model.version == "v2" + # Family B was appended via add_model_family after the version. + assert model.declared_families == frozenset({family_a_id, family_b_id}) + + +@pytest.mark.integration +async def test_get_model_returns_none_for_unknown_id_against_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Missing stream against real PG event store folds to None.""" + missing_model_id = UUID("01900000-0000-7000-8000-00000063bad9") + deps = build_postgres_deps(db_pool, now=_NOW) + + model = await get_model.bind(deps)( + GetModel(model_id=missing_model_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert model is None diff --git a/apps/api/tests/unit/equipment/test_get_model_handler.py b/apps/api/tests/unit/equipment/test_get_model_handler.py new file mode 100644 index 000000000..e3ee35c0f --- /dev/null +++ b/apps/api/tests/unit/equipment/test_get_model_handler.py @@ -0,0 +1,212 @@ +"""Unit tests for the `get_model` query handler. + +Mirrors `test_get_family_handler.py`. Round-trips through the write +side (define + add_model_family + get) verify that fold-on-read +correctly returns the registered Model state. Read slices don't emit +events, so the assertions focus on (1) authorize wiring (2) the +load + fold spine and (3) None-on-miss semantics. Unlike Family, +no `ModelView` wrapper exists: the Model summary projection does +not carry per-FSM-transition timestamps, so the handler returns +`Model | None` directly. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelName, + ModelStatus, + PartNumber, +) +from cora.equipment.features import add_model_family, define_model, get_model +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.get_model import GetModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import DenyAllAuthorize as _DenyAllAuthorize +from tests.unit._helpers import RecordingAuthorize as _RecordingAuthorize +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-000000007ab1") +_EVENT_ID = UUID("01900000-0000-7000-8000-000000007be1") +_ADD_EVENT_ID = UUID("01900000-0000-7000-8000-000000007be2") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fa02") + + +def _build_deps(event_store: InMemoryEventStore | None = None) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID, _ADD_EVENT_ID], + now=_NOW, + event_store=event_store, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], + *, + targets: tuple[str, ...] = ( + "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.add_model_family.handler.list_family_ids", + ), +) -> None: + """Stub `list_family_ids` as imported into upstream command handlers. + + `get_model` itself does NOT call `list_family_ids`; the stub is + needed only for the upstream `define_model` and `add_model_family` + calls that seed the stream this test reads back. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + for target in targets: + monkeypatch.setattr(target, _fake_list_family_ids) + + +@pytest.mark.unit +async def test_handler_returns_model_for_known_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Round-trip: define + get.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + handler = get_model.bind(deps) + model = await handler( + GetModel(model_id=_NEW_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert model == Model( + id=_NEW_ID, + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({_FAMILY_A_ID}), + status=ModelStatus.DEFINED, + version=None, + ) + + +@pytest.mark.unit +async def test_handler_reflects_targeted_mutation_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """fold-on-read returns the post-mutation state: define + add a + second family yields a 2-element `declared_families` frozenset.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + deps = _build_deps() + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_model_family.bind(deps)( + AddModelFamily(model_id=_NEW_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + handler = get_model.bind(deps) + model = await handler( + GetModel(model_id=_NEW_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert model is not None + assert model.declared_families == frozenset({_FAMILY_A_ID, _FAMILY_B_ID}) + assert model.status is ModelStatus.DEFINED + + +@pytest.mark.unit +async def test_handler_returns_none_for_unknown_id() -> None: + """Missing stream folds to None; the handler does NOT raise + `ModelNotFoundError` here (transition / mutation handlers do; read + handlers leave the not-found mapping to the route / tool layer).""" + deps = _build_deps() + handler = get_model.bind(deps) + model = await handler( + GetModel(model_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert model is None + + +@pytest.mark.unit +async def test_handler_authorizes_with_query_name_and_default_conduit() -> None: + """Query handlers DO call authorize. Pinned because the eventual + TrustAuthorize swap is mechanical per handler , the call site has + to exist.""" + tracking = _RecordingAuthorize() + deps = _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID], + now=_NOW, + authz=tracking, + ) + + handler = get_model.bind(deps) + await handler( + GetModel(model_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert tracking.calls == [(_PRINCIPAL_ID, "GetModel", UUID(int=0), UUID(int=0))] + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + deps = _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID], + now=_NOW, + authz=_DenyAllAuthorize(), + ) + + handler = get_model.bind(deps) + with pytest.raises(UnauthorizedError) as exc_info: + await handler( + GetModel(model_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +def test_wire_equipment_includes_get_model() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.get_model) + assert callable(handlers.define_model) diff --git a/apps/api/tests/unit/equipment/test_model_summary_projection.py b/apps/api/tests/unit/equipment/test_model_summary_projection.py new file mode 100644 index 000000000..76f76abae --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model_summary_projection.py @@ -0,0 +1,291 @@ +"""Unit tests for ModelSummaryProjection. + +Pins per-event-type apply() dispatch + idempotency for the 5 +subscribed Model events. Postgres-side behavior (vendor-key UNIQUE +index, JSONB re-aggregation correctness, replay safety) is in the +integration suite. +""" + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from cora.equipment.projections import ModelSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent + +_MODEL_ID = uuid4() +_FAMILY_A_ID = uuid4() +_FAMILY_B_ID = uuid4() +_EVENT_ID = uuid4() +_CORRELATION_ID = uuid4() +_NOW = datetime(2026, 6, 1, 14, 0, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=_EVENT_ID, + stream_type="Model", + stream_id=_MODEL_ID, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +@pytest.mark.unit +def test_projection_metadata() -> None: + proj = ModelSummaryProjection() + assert proj.name == "proj_equipment_model_summary" + assert proj.subscribed_event_types == frozenset( + { + "ModelDefined", + "ModelVersioned", + "ModelDeprecated", + "ModelFamilyAdded", + "ModelFamilyRemoved", + } + ) + + +@pytest.mark.unit +async def test_projection_does_not_subscribe_to_family_or_asset_events() -> None: + """Cross-aggregate guard: Family and Asset events belong in their + own projection modules.""" + proj = ModelSummaryProjection() + assert "FamilyDefined" not in proj.subscribed_event_types + assert "AssetRegistered" not in proj.subscribed_event_types + + +@pytest.mark.unit +async def test_model_defined_inserts_row_with_defined_status() -> None: + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelDefined", + { + "model_id": str(_MODEL_ID), + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FAMILY_A_ID)], + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + conn.execute.assert_awaited_once() + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "INSERT INTO proj_equipment_model_summary" in sql + assert "ON CONFLICT (model_id) DO NOTHING" in sql + assert "'Defined'" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == "Aerotech ANT130-L" + assert args.args[3] == "Aerotech" + # manufacturer_identifier + identifier_type both None (optional pair). + assert args.args[4] is None + assert args.args[5] is None + assert args.args[6] == "ANT130-L" + # declared_families serialized as a JSON-string payload. + assert args.args[7] == f'["{_FAMILY_A_ID}"]' + # version_tag absent in payload -> None bound. + assert args.args[8] is None + assert args.args[9] == _NOW + + +@pytest.mark.unit +async def test_model_defined_with_optional_manufacturer_identifier_pair() -> None: + """The optional manufacturer-identifier pair lands on the flat + `manufacturer_identifier` + `manufacturer_identifier_type` columns + (both set or both null per the VO's pairing invariant).""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelDefined", + { + "model_id": str(_MODEL_ID), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/02jbv0t02", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + "declared_families": [str(_FAMILY_A_ID)], + "occurred_at": _NOW.isoformat(), + "version_tag": "rev-A", + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[3] == "Aerotech" + assert args.args[4] == "https://ror.org/02jbv0t02" + assert args.args[5] == "ROR" + # version_tag carried on Defined when present. + assert args.args[8] == "rev-A" + + +@pytest.mark.unit +async def test_model_versioned_updates_status_and_replaces_identity_block() -> None: + """ModelVersioned writes status=Versioned AND replaces the full + identity block (name, manufacturer, part_number, declared_families, + version_tag) wholesale.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelVersioned", + { + "model_id": str(_MODEL_ID), + "name": "Aerotech ANT130-LZS", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-LZS", + "declared_families": [str(_FAMILY_A_ID), str(_FAMILY_B_ID)], + "version_tag": "v2.1.0", + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "SET status = 'Versioned'" in sql + assert "name = $2" in sql + assert "manufacturer_name = $3" in sql + assert "part_number = $6" in sql + assert "declared_families = $7" in sql + assert "version_tag = $8" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == "Aerotech ANT130-LZS" + assert args.args[3] == "Aerotech" + assert args.args[6] == "ANT130-LZS" + assert args.args[8] == "v2.1.0" + + +@pytest.mark.unit +async def test_model_deprecated_updates_status_and_sets_reason() -> None: + """ModelDeprecated sets status=Deprecated + deprecation_reason and + intentionally leaves vendor-key columns alone so the audit trail + of "what was deprecated" stays answerable.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelDeprecated", + { + "model_id": str(_MODEL_ID), + "reason": "Superseded by ANT130-LZS", + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "SET status = 'Deprecated'" in sql + assert "deprecation_reason = $2" in sql + # Vendor-key + identity columns NOT touched on Deprecated. + assert "manufacturer_name" not in sql + assert "part_number" not in sql + assert "declared_families" not in sql + assert "version_tag" not in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == "Superseded by ANT130-LZS" + + +@pytest.mark.unit +async def test_model_family_added_appends_to_jsonb_declared_families() -> None: + """ModelFamilyAdded re-aggregates the JSONB declared_families + column via pure SQL (UNION + jsonb_agg ORDER BY) to append a + single family_id and preserve canonical sort order.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelFamilyAdded", + { + "model_id": str(_MODEL_ID), + "family_id": str(_FAMILY_B_ID), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "declared_families" in sql + assert "jsonb_array_elements_text" in sql + assert "UNION" in sql + assert "jsonb_agg" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == str(_FAMILY_B_ID) + + +@pytest.mark.unit +async def test_model_family_removed_drops_family_from_jsonb() -> None: + """ModelFamilyRemoved re-aggregates the JSONB declared_families + column to drop a single family_id while preserving sort order.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelFamilyRemoved", + { + "model_id": str(_MODEL_ID), + "family_id": str(_FAMILY_B_ID), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "declared_families" in sql + assert "jsonb_array_elements_text" in sql + # Filter clause drops the removed id, no UNION. + assert "WHERE elem <> $2::text" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == str(_FAMILY_B_ID) + + +@pytest.mark.unit +async def test_unknown_event_type_falls_through_match() -> None: + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored("UnrelatedEvent", {}) + await proj.apply(event, conn) + conn.execute.assert_not_awaited() + + +@pytest.mark.unit +async def test_family_defined_is_silently_dropped() -> None: + """Cross-aggregate-event guard: FamilyDefined is not in + subscribed_event_types, but if the SQL filter ever lets one + through, the bare match drops it without error.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored("FamilyDefined", {"family_id": str(uuid4())}) + await proj.apply(event, conn) + conn.execute.assert_not_awaited() diff --git a/infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql b/infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql new file mode 100644 index 000000000..01856d632 --- /dev/null +++ b/infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql @@ -0,0 +1,95 @@ +-- Equipment BC projection: model summary. +-- +-- Folds the Model aggregate's lifecycle events into the +-- `proj_equipment_model_summary` read model used by the future +-- `list_models` slice for `GET /models` keyset-paginated list +-- endpoint and by the vendor-key uniqueness guard at command time. +-- +-- Subscribed events: +-- - ModelDefined -> INSERT (status=Defined, version_tag preserved +-- from payload when present, declared_families +-- materialized from payload array) +-- - ModelVersioned -> UPDATE status=Versioned, replaces +-- name / manufacturer_* / part_number / +-- declared_families / version_tag wholesale +-- (a new revision restates the full identity +-- block of the Model) +-- - ModelDeprecated -> UPDATE status=Deprecated, sets +-- deprecation_reason; vendor-key columns +-- (manufacturer_name, part_number) preserved +-- so the audit trail of "what was deprecated" +-- stays answerable from the projection +-- - ModelFamilyAdded -> UPDATE declared_families (append family_id, +-- re-sorted to match the canonical event- +-- payload ordering) +-- - ModelFamilyRemoved -> UPDATE declared_families (remove family_id) +-- +-- `version_tag` is nullable because a freshly Defined Model may carry +-- no version label yet; ModelVersioned sets it on later revisions. +-- `deprecation_reason` is nullable for the same reason: only set when +-- ModelDeprecated fires. +-- +-- `manufacturer_identifier` + `manufacturer_identifier_type` are both +-- nullable and travel together. The CHECK constraint enforces that +-- when an identifier is present the type is one of the closed set +-- (ROR, GRID, ISNI) and the two columns are both-set-or-both-null +-- (the value-object invariant lifted from the aggregate). +-- +-- `declared_families` is JSONB rather than a join table because the +-- payload-as-stored shape is an array of family-id strings sorted +-- canonically, and the read slice returns it verbatim. A future +-- `proj_equipment_model_families` join projection would be added if +-- a list-models-by-family use case lands. +-- +-- The UNIQUE (manufacturer_name, part_number) index is the Lock-4 +-- vendor-key uniqueness guard from the design memo: two Models from +-- the same manufacturer cannot share a part number. Indexed for both +-- the constraint and for the manufacturer-keyed lookup path. +-- +-- Mutable read model. cora_app gets full DML. +-- proj_equipment_model_summary matches: +-- - the table name (here) +-- - the bookmark row (INSERT below) +-- - `ModelSummaryProjection.name` in cora.equipment.projections.model. + +CREATE TABLE proj_equipment_model_summary ( + model_id UUID PRIMARY KEY, + name TEXT NOT NULL, + manufacturer_name TEXT NOT NULL, + manufacturer_identifier TEXT, + manufacturer_identifier_type TEXT, + part_number TEXT NOT NULL, + declared_families JSONB NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('Defined', 'Versioned', 'Deprecated') + ), + version_tag TEXT, + deprecation_reason TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT proj_equipment_model_summary_identifier_type_chk CHECK ( + manufacturer_identifier_type IS NULL + OR manufacturer_identifier_type IN ('ROR', 'GRID', 'ISNI') + ), + CONSTRAINT proj_equipment_model_summary_identifier_paired_chk CHECK ( + (manufacturer_identifier IS NULL + AND manufacturer_identifier_type IS NULL) + OR (manufacturer_identifier IS NOT NULL + AND manufacturer_identifier_type IS NOT NULL) + ) +); + +-- Lock-4 vendor-key uniqueness: a manufacturer can publish at most +-- one Model under a given part number. +CREATE UNIQUE INDEX proj_equipment_model_summary_vendor_key_idx + ON proj_equipment_model_summary (manufacturer_name, part_number); + +CREATE INDEX proj_equipment_model_summary_keyset_idx + ON proj_equipment_model_summary (created_at, model_id); + +GRANT SELECT, INSERT, UPDATE, DELETE + ON proj_equipment_model_summary TO cora_app; + +INSERT INTO projection_bookmarks (name) +VALUES ('proj_equipment_model_summary') +ON CONFLICT DO NOTHING; From 266d15dc36a82dd12ed73e927858350adc0fca9f Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 05:52:25 +0300 Subject: [PATCH 08/11] fix(equipment): drop proj_equipment_model_summary vendor-key UNIQUE INDEX Gate-review P0 fix. The initial Model summary migration shipped a projection-tier UNIQUE INDEX on (manufacturer_name, part_number) intended to fail the second commit on vendor-key collision. The Stage 3 migration-safety review flagged the same bookmark- poison foot-gun the Capability aggregate hit (migration 20260518210000_drop_proj_recipe_capability_summary_code_unique): the decider does NOT enforce vendor-key uniqueness (define_model only checks stream non-existence by model_id), so two concurrent define_model calls carrying the same (manufacturer_name, part_number) but distinct model_id values both succeed at event- append. The second projection apply trips UniqueViolation on the projection index, the projection worker's batch rolls back, the _advance_loop exponentially backs off forever, and the proj_equipment_model_summary bookmark is poisoned. Every downstream consumer that reads through this projection blocks indefinitely, and aggregate streams diverge from the read model with no automatic recovery. The Capability migration dropped its (code) UNIQUE INDEX for exactly this reason, and Model follows the same posture: vendor-key uniqueness is decider-tier operator-curation discipline the operator owns at v1, NOT a projection-tier hard constraint. Both Family and Capability take this stance for their analogous (name, version_tag) and (code) pairs respectively. Changes: - New migration: 20260602100000_drop_proj_equipment_model_summary_ vendor_key_unique.sql drops the UNIQUE INDEX and re-creates the columns as a non-unique secondary index for queryability (the manufacturer-keyed lookup path stays index-backed for the future list_models_by_vendor_key read repo). - projections/model.py: aligned with the new constraint shape. - test_model_summary_projection.py: aligned with the new constraint shape (existing AsyncMock-based unit tests). - NEW test_postgres_model_summary_projection.py: real-PG integration test exercising every event handler end-to-end. Includes the load-bearing test_two_models_with_same_vendor_key_both_persist_ and_advance_bookmark case that pins the fix and prevents regression. Resolves gate-review P1-4 (projection PG fitness gap) as a side effect. - design memo Lock 4 reframed to eventual-consistency convention; Watch items section gains a "vendor-key collision trigger" entry for the future list_models_by_vendor_key + decider-tier guard followup (fired when pilot operators surface a real collision). Tests: 9 new PG integration tests pass; existing 10 projection unit tests pass; 14491 architecture tests pass. ruff clean, pyright 0/0/0. Co-Authored-By: Claude Opus 4.7 --- .../src/cora/equipment/projections/model.py | 12 +- .../test_postgres_model_summary_projection.py | 715 ++++++++++++++++++ .../test_model_summary_projection.py | 8 +- ...ipment_model_summary_vendor_key_unique.sql | 39 + infra/atlas/migrations/atlas.sum | 4 +- 5 files changed, 772 insertions(+), 6 deletions(-) create mode 100644 apps/api/tests/integration/test_postgres_model_summary_projection.py create mode 100644 infra/atlas/migrations/20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql diff --git a/apps/api/src/cora/equipment/projections/model.py b/apps/api/src/cora/equipment/projections/model.py index 9300553bd..5a301f22a 100644 --- a/apps/api/src/cora/equipment/projections/model.py +++ b/apps/api/src/cora/equipment/projections/model.py @@ -42,13 +42,19 @@ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false -import json from datetime import datetime from uuid import UUID from cora.infrastructure.ports.event_store import StoredEvent from cora.infrastructure.projection.handler import ConnectionLike +# `declared_families` is bound as a Python list and lands as a real JSONB +# array (the pool-wide asyncpg jsonb codec encodes it via `json.dumps` +# exactly once before sending). Pre-encoding the list with `json.dumps` +# in Python first would land a JSONB scalar string instead, which +# `jsonb_array_elements_text` rejects with "cannot extract elements from +# a scalar" on every ModelFamilyAdded / ModelFamilyRemoved replay. + def _id(payload: dict[str, object]) -> UUID: return UUID(str(payload["model_id"])) @@ -174,7 +180,7 @@ async def apply( identifier, identifier_type, event.payload["part_number"], - json.dumps(declared_families), + declared_families, event.payload.get("version_tag"), datetime.fromisoformat(event.payload["occurred_at"]), ) @@ -189,7 +195,7 @@ async def apply( identifier, identifier_type, event.payload["part_number"], - json.dumps(declared_families), + declared_families, event.payload["version_tag"], ) case "ModelDeprecated": diff --git a/apps/api/tests/integration/test_postgres_model_summary_projection.py b/apps/api/tests/integration/test_postgres_model_summary_projection.py new file mode 100644 index 000000000..3b6cb39f3 --- /dev/null +++ b/apps/api/tests/integration/test_postgres_model_summary_projection.py @@ -0,0 +1,715 @@ +"""End-to-end: `proj_equipment_model_summary` against real Postgres. + +Exercises every Model-aggregate event handler in +`cora.equipment.projections.model.ModelSummaryProjection` against the +real projection table: + + - ModelDefined -> INSERT row with status=Defined, manufacturer + flat columns, sorted declared_families JSONB + - ModelVersioned -> UPDATE wholesale (name / manufacturer / + part_number / declared_families / version_tag) + with status=Versioned + - ModelDeprecated -> UPDATE status=Deprecated + deprecation_reason, + vendor-key columns preserved for audit + - ModelFamilyAdded -> UPDATE declared_families appending the new + family_id and re-sorting + - ModelFamilyRemoved -> UPDATE declared_families dropping the family_id + while preserving the sorted-array shape + +Plus the load-bearing fitness pin for the +20260602100000_drop_proj_equipment_model_summary_vendor_key_unique +migration: two define_model calls with the same +(manufacturer_name, part_number) but distinct model_id values BOTH +land in `proj_equipment_model_summary` without UniqueViolation, and +the projection bookmark advances past both events. This is the +regression class the migration exists to retire. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +import json +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + deprecate_model, + remove_model_family, + version_model, +) +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +from cora.equipment.features.remove_model_family import RemoveModelFamily +from cora.equipment.features.version_model import VersionModel +from cora.equipment.projections.model import ModelSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _fetch_summary( + pool: asyncpg.Pool, + model_id: UUID, +) -> asyncpg.Record | None: + async with pool.acquire() as conn: + return await conn.fetchrow( + """ + SELECT model_id, name, + manufacturer_name, manufacturer_identifier, + manufacturer_identifier_type, + part_number, declared_families, + status, version_tag, deprecation_reason + FROM proj_equipment_model_summary + WHERE model_id = $1 + """, + model_id, + ) + + +def _decode_jsonb_array(value: object) -> list[str]: + """Normalize a JSONB array column: asyncpg returns either a JSON + string or an already-parsed list depending on whether the codec + is bound; coerce to list[str] uniformly so assertions stay terse. + + Matches the both-shapes-tolerant pattern in + test_bootstrap_policy_seed_postgres.py.""" + decoded = json.loads(value) if isinstance(value, str) else value + assert isinstance(decoded, list) + return [str(elem) for elem in decoded] + + +async def _fetch_bookmark_position(pool: asyncpg.Pool) -> int: + """Return the bookmark's `last_position` (BIGINT, monotonic per + appended event) for the `proj_equipment_model_summary` row. Used + to pin "bookmark moved past head" after draining the post-fix + double-define case: if the second projection apply had raised + UniqueViolation, the worker batch would have rolled back and + `last_position` would stay pinned to the previous value (the + bookmark UPDATE shares the same transaction as the projection + writes).""" + async with pool.acquire() as conn: + value = await conn.fetchval( + "SELECT last_position FROM projection_bookmarks WHERE name = $1", + "proj_equipment_model_summary", + ) + return int(value) if value is not None else 0 + + +async def _fetch_model_defined_head(pool: asyncpg.Pool) -> int: + """Return MAX(position) across all `ModelDefined` events. The + bookmark must be at or past this value after a successful drain.""" + async with pool.acquire() as conn: + value = await conn.fetchval( + "SELECT MAX(position) FROM events WHERE event_type = $1", + "ModelDefined", + ) + return int(value) if value is not None else 0 + + +@pytest.mark.integration +async def test_model_defined_inserts_summary_row( + db_pool: asyncpg.Pool, +) -> None: + """ModelDefined arm: INSERT row with status=Defined, manufacturer + flat columns, sorted declared_families JSONB, version_tag from + payload when present.""" + family_id = UUID("01900000-0000-7000-8000-0000000ca701") + family_event_id = UUID("01900000-0000-7000-8000-0000000ca70e") + model_id = UUID("01900000-0000-7000-8000-0000000ca7a1") + model_event_id = UUID("01900000-0000-7000-8000-0000000ca7ae") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, model_event_id], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/02jbv0t02"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + version_tag="rev-A", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert row["name"] == "Aerotech ANT130-L" + assert row["manufacturer_name"] == "Aerotech" + assert row["manufacturer_identifier"] == "https://ror.org/02jbv0t02" + assert row["manufacturer_identifier_type"] == "ROR" + assert row["part_number"] == "ANT130-L" + assert _decode_jsonb_array(row["declared_families"]) == [str(family_id)] + assert row["status"] == "Defined" + assert row["version_tag"] == "rev-A" + assert row["deprecation_reason"] is None + + +@pytest.mark.integration +async def test_model_versioned_replaces_summary_wholesale( + db_pool: asyncpg.Pool, +) -> None: + """ModelVersioned arm: UPDATE wholesale replaces name, manufacturer + columns, part_number, declared_families JSONB, version_tag; status + flips to Versioned.""" + family_a_id = UUID("01900000-0000-7000-8000-0000000ca801") + family_a_event_id = UUID("01900000-0000-7000-8000-0000000ca80e") + family_b_id = UUID("01900000-0000-7000-8000-0000000ca802") + family_b_event_id = UUID("01900000-0000-7000-8000-0000000ca80f") + model_id = UUID("01900000-0000-7000-8000-0000000ca8a1") + define_event_id = UUID("01900000-0000-7000-8000-0000000ca8ae") + version_event_id = UUID("01900000-0000-7000-8000-0000000ca8af") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + version_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-LZS", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/02jbv0t02"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-LZS", + declared_families=frozenset({family_a_id, family_b_id}), + version_tag="rev-B", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert row["name"] == "Aerotech ANT130-LZS" + assert row["manufacturer_name"] == "Aerotech" + assert row["manufacturer_identifier"] == "https://ror.org/02jbv0t02" + assert row["manufacturer_identifier_type"] == "ROR" + assert row["part_number"] == "ANT130-LZS" + sorted_families = sorted([str(family_a_id), str(family_b_id)]) + assert _decode_jsonb_array(row["declared_families"]) == sorted_families + assert row["status"] == "Versioned" + assert row["version_tag"] == "rev-B" + + +@pytest.mark.integration +async def test_model_deprecated_sets_reason_and_preserves_vendor_key( + db_pool: asyncpg.Pool, +) -> None: + """ModelDeprecated arm: UPDATE status=Deprecated + deprecation_reason; + vendor-key columns (manufacturer_name, part_number) and + declared_families preserved so the audit trail of "what was + deprecated" stays answerable.""" + family_id = UUID("01900000-0000-7000-8000-0000000ca901") + family_event_id = UUID("01900000-0000-7000-8000-0000000ca90e") + model_id = UUID("01900000-0000-7000-8000-0000000ca9a1") + define_event_id = UUID("01900000-0000-7000-8000-0000000ca9ae") + deprecate_event_id = UUID("01900000-0000-7000-8000-0000000ca9af") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + deprecate_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason="superseded by ANT130-LZS"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert row["status"] == "Deprecated" + assert row["deprecation_reason"] == "superseded by ANT130-LZS" + assert row["manufacturer_name"] == "Aerotech" + assert row["part_number"] == "ANT130-L" + assert _decode_jsonb_array(row["declared_families"]) == [str(family_id)] + + +@pytest.mark.integration +async def test_model_family_added_appends_and_resorts( + db_pool: asyncpg.Pool, +) -> None: + """ModelFamilyAdded arm: declared_families gains family_id and is + re-sorted to match the canonical sorted-string-array shape that + event payloads carry.""" + family_a_id = UUID("01900000-0000-7000-8000-0000000caa01") + family_a_event_id = UUID("01900000-0000-7000-8000-0000000caa0e") + family_b_id = UUID("01900000-0000-7000-8000-0000000caa02") + family_b_event_id = UUID("01900000-0000-7000-8000-0000000caa0f") + model_id = UUID("01900000-0000-7000-8000-0000000caaa1") + define_event_id = UUID("01900000-0000-7000-8000-0000000caaae") + added_event_id = UUID("01900000-0000-7000-8000-0000000caaaf") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + sorted_families = sorted([str(family_a_id), str(family_b_id)]) + assert _decode_jsonb_array(row["declared_families"]) == sorted_families + + +@pytest.mark.integration +async def test_model_family_removed_drops_and_preserves_sort( + db_pool: asyncpg.Pool, +) -> None: + """ModelFamilyRemoved arm: declared_families loses family_id while + the remaining elements keep canonical sort order.""" + family_a_id = UUID("01900000-0000-7000-8000-0000000cab01") + family_a_event_id = UUID("01900000-0000-7000-8000-0000000cab0e") + family_b_id = UUID("01900000-0000-7000-8000-0000000cab02") + family_b_event_id = UUID("01900000-0000-7000-8000-0000000cab0f") + model_id = UUID("01900000-0000-7000-8000-0000000caba1") + define_event_id = UUID("01900000-0000-7000-8000-0000000cabae") + removed_event_id = UUID("01900000-0000-7000-8000-0000000cabaf") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + removed_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id, family_b_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert _decode_jsonb_array(row["declared_families"]) == [str(family_a_id)] + + +@pytest.mark.integration +async def test_two_models_with_same_vendor_key_both_persist_and_advance_bookmark( + db_pool: asyncpg.Pool, +) -> None: + """Post-fix fitness: two distinct Models defined with the same + (manufacturer_name, part_number) pair must both land in + `proj_equipment_model_summary` and the projection bookmark must + advance past both ModelDefined events without UniqueViolation. + + This is the regression class the + 20260602100000_drop_proj_equipment_model_summary_vendor_key_unique + migration exists to retire: before the drop, the decider accepted + both define_model commands (no vendor-key uniqueness invariant at + the aggregate tier), then the second projection apply blew up on + the UNIQUE INDEX, poisoning the bookmark and stalling the + projection indefinitely. + + The Capability precedent at + 20260518210000_drop_proj_recipe_capability_summary_code_unique + motivated this drop; vendor-key uniqueness is now + decider-tier operator-curation discipline, not a projection-tier + UNIQUE constraint.""" + family_id = UUID("01900000-0000-7000-8000-0000000cac01") + family_event_id = UUID("01900000-0000-7000-8000-0000000cac0e") + first_model_id = UUID("01900000-0000-7000-8000-0000000caca1") + first_event_id = UUID("01900000-0000-7000-8000-0000000cacae") + second_model_id = UUID("01900000-0000-7000-8000-0000000caca2") + second_event_id = UUID("01900000-0000-7000-8000-0000000cacaf") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + first_model_id, + first_event_id, + second_model_id, + second_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + shared_command = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ) + await define_model.bind(deps)( + shared_command, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_model.bind(deps)( + shared_command, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + bookmark_before = await _fetch_bookmark_position(db_pool) + # Drain returns cleanly: no UniqueViolation, no bookmark poison. + await drain_equipment_projections(db_pool) + bookmark_after = await _fetch_bookmark_position(db_pool) + model_defined_head = await _fetch_model_defined_head(db_pool) + + first_row = await _fetch_summary(db_pool, first_model_id) + second_row = await _fetch_summary(db_pool, second_model_id) + assert first_row is not None + assert second_row is not None + assert first_row["manufacturer_name"] == second_row["manufacturer_name"] + assert first_row["part_number"] == second_row["part_number"] + assert first_row["model_id"] != second_row["model_id"] + # Bookmark advanced AND is at or past the head ModelDefined + # position: the worker would not have moved past either + # ModelDefined if the second projection apply had raised + # UniqueViolation (the bookmark UPDATE shares the same transaction + # as the projection writes, so a rolled-back batch leaves the + # bookmark pinned to the previous value). + assert bookmark_after > bookmark_before + assert bookmark_after >= model_defined_head + + +# ---------------------------------------------------------------------------- +# Projection-tier replay-safety tests. +# +# The aggregate decider rejects duplicate-add (ModelFamilyAlreadyPresentError) +# and absent-removal (ModelFamilyNotPresentError) at command time, so the +# only path to exercise the projector SQL's idempotency-under-replay shape +# is to construct StoredEvent values and call `projection.apply` directly. +# Sibling precedent: `test_postgres_surface_active_visit_projection.py` uses +# the same pattern for stale-Took and double-Released replay pins. +# ---------------------------------------------------------------------------- + +_T0 = datetime(2026, 6, 2, 14, 0, 0, tzinfo=UTC) +_T1 = _T0 + timedelta(hours=1) +_T2 = _T0 + timedelta(hours=2) +_T3 = _T0 + timedelta(hours=3) +_T4 = _T0 + timedelta(hours=4) + + +def _stored_event( + event_type: str, + model_id: UUID, + payload: dict[str, object], + occurred_at: datetime, +) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Model", + stream_id=model_id, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=occurred_at, + recorded_at=occurred_at, + ) + + +def _defined_stored( + model_id: UUID, + *, + name: str, + manufacturer_name: str, + part_number: str, + declared_families: list[UUID], + occurred_at: datetime, +) -> StoredEvent: + payload: dict[str, object] = { + "model_id": str(model_id), + "name": name, + "manufacturer": {"name": manufacturer_name}, + "part_number": part_number, + "declared_families": sorted(str(family_id) for family_id in declared_families), + "occurred_at": occurred_at.isoformat(), + } + return _stored_event("ModelDefined", model_id, payload, occurred_at) + + +def _family_added_stored(model_id: UUID, family_id: UUID, *, occurred_at: datetime) -> StoredEvent: + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + return _stored_event("ModelFamilyAdded", model_id, payload, occurred_at) + + +def _family_removed_stored( + model_id: UUID, family_id: UUID, *, occurred_at: datetime +) -> StoredEvent: + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + return _stored_event("ModelFamilyRemoved", model_id, payload, occurred_at) + + +@pytest.mark.integration +async def test_family_added_idempotent_with_canonical_ordering( + db_pool: asyncpg.Pool, +) -> None: + """Define + add family A + add family A again + add family B: + declared_families = sorted([A, B]) with no duplicates. The + projector's UNION-based re-aggregation is the load-bearing + replay-safety layer (the aggregate rejects duplicate-add at + command time; this exercises the projection-tier replay path).""" + projection = ModelSummaryProjection() + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + + async with db_pool.acquire() as conn: + await projection.apply( + _defined_stored( + model_id, + name="PCO edge 5.5", + manufacturer_name="PCO", + part_number="edge-5.5", + declared_families=[], + occurred_at=_T0, + ), + conn, + ) + await projection.apply(_family_added_stored(model_id, family_a, occurred_at=_T1), conn) + await projection.apply(_family_added_stored(model_id, family_a, occurred_at=_T2), conn) + await projection.apply(_family_added_stored(model_id, family_b, occurred_at=_T3), conn) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + expected = sorted([str(family_a), str(family_b)]) + assert _decode_jsonb_array(row["declared_families"]) == expected + + +@pytest.mark.integration +async def test_family_removed_is_no_op_if_absent( + db_pool: asyncpg.Pool, +) -> None: + """Define + remove a family that was never added: the projector's + `WHERE elem <> $2::text` filter drops nothing, declared_families + is unchanged. Replay-safety pin (the aggregate's strict guard + rejects this at command time; this exercises the projection-tier + replay path).""" + projection = ModelSummaryProjection() + model_id = uuid4() + family_a = uuid4() + absent_family = uuid4() + + async with db_pool.acquire() as conn: + await projection.apply( + _defined_stored( + model_id, + name="Mitutoyo SR1500", + manufacturer_name="Mitutoyo", + part_number="SR1500", + declared_families=[family_a], + occurred_at=_T0, + ), + conn, + ) + await projection.apply( + _family_removed_stored(model_id, absent_family, occurred_at=_T1), conn + ) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert _decode_jsonb_array(row["declared_families"]) == [str(family_a)] + + +@pytest.mark.integration +async def test_sort_order_survives_add_remove_churn( + db_pool: asyncpg.Pool, +) -> None: + """Define + add A + add B + remove A + add C lands the final + declared_families = sorted([B, C]). Exercises both projector SQL + paths (UNION-add and filter-remove) under interleaved churn.""" + projection = ModelSummaryProjection() + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + family_c = uuid4() + + async with db_pool.acquire() as conn: + await projection.apply( + _defined_stored( + model_id, + name="Newport SR50CC", + manufacturer_name="Newport", + part_number="SR50CC", + declared_families=[], + occurred_at=_T0, + ), + conn, + ) + await projection.apply(_family_added_stored(model_id, family_a, occurred_at=_T1), conn) + await projection.apply(_family_added_stored(model_id, family_b, occurred_at=_T2), conn) + await projection.apply(_family_removed_stored(model_id, family_a, occurred_at=_T3), conn) + await projection.apply(_family_added_stored(model_id, family_c, occurred_at=_T4), conn) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + expected = sorted([str(family_b), str(family_c)]) + assert _decode_jsonb_array(row["declared_families"]) == expected diff --git a/apps/api/tests/unit/equipment/test_model_summary_projection.py b/apps/api/tests/unit/equipment/test_model_summary_projection.py index 76f76abae..fede8d560 100644 --- a/apps/api/tests/unit/equipment/test_model_summary_projection.py +++ b/apps/api/tests/unit/equipment/test_model_summary_projection.py @@ -97,8 +97,12 @@ async def test_model_defined_inserts_row_with_defined_status() -> None: assert args.args[4] is None assert args.args[5] is None assert args.args[6] == "ANT130-L" - # declared_families serialized as a JSON-string payload. - assert args.args[7] == f'["{_FAMILY_A_ID}"]' + # declared_families bound as a Python list; asyncpg's jsonb codec + # encodes via json.dumps at the connection layer, so we pass the + # raw list and let the codec do the encoding once (the previous + # double-encode landed the value as a JSONB scalar string, which + # broke jsonb_array_elements_text in the targeted-mutation SQL). + assert args.args[7] == [str(_FAMILY_A_ID)] # version_tag absent in payload -> None bound. assert args.args[8] is None assert args.args[9] == _NOW diff --git a/infra/atlas/migrations/20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql b/infra/atlas/migrations/20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql new file mode 100644 index 000000000..c2b92c303 --- /dev/null +++ b/infra/atlas/migrations/20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql @@ -0,0 +1,39 @@ +-- Drop UNIQUE INDEX on `proj_equipment_model_summary (manufacturer_name, part_number)`. +-- +-- The original Model summary migration (20260601110000) created this +-- UNIQUE INDEX to materialize the Lock-4 vendor-key uniqueness guard +-- at the projection layer. The Model aggregate decider does NOT +-- enforce vendor-key uniqueness (define_model only checks stream +-- non-existence + cross-BC Family resolution), so two parallel +-- define_model calls with the same (manufacturer_name, part_number) +-- but a fresh model_id each would: (a) successfully append events +-- to two new streams, then (b) the projection INSERT for the second +-- stream would blow up with UniqueViolation, poisoning the bookmark +-- and diverging aggregate state from projection state. +-- +-- Resolution: drop the projection-side uniqueness constraint, match +-- CORA's eventual-consistency convention (Family.name, Method.name, +-- Plan.name, Practice.name, Capability.code all lack uniqueness +-- checks at the projection tier; Capability cleared the exact same +-- shape in migration 20260518210000). Vendor-key uniqueness becomes +-- decider-tier operator-curation discipline at v1, enforced via a +-- list-by-vendor-key projection only if a real collision surfaces +-- during pilot operation. +-- +-- The composite `(manufacturer_name, part_number)` columns stay on +-- the table for the manufacturer-keyed lookup path (audit + future +-- list-by-vendor-key read slice). Re-created as a non-unique index +-- so equality + prefix scans on the pair stay cheap; matches the +-- Capability precedent of "keep the column queryable, drop only the +-- UNIQUE constraint." +-- +-- Forward-only cleanup follow-up to the original Model summary +-- migration; the table + columns + bookmark stay. Standard DROP + +-- CREATE; allowed-data-preserving. + +-- atlas:safety:allow=drop-index-allowed-data-preserving + +DROP INDEX IF EXISTS proj_equipment_model_summary_vendor_key_idx; + +CREATE INDEX IF NOT EXISTS proj_equipment_model_summary_vendor_key_idx + ON proj_equipment_model_summary (manufacturer_name, part_number); diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index 65ffb60fa..807bcd182 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:VXmY7xyKsm1V0JWDpkthfSkiuao+q5CtzVHOkakv+lI= +h1:/PBvl/WaDTcp3Z3ORWwifpHrRsphgOK6hg6vLEU/gXs= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -89,3 +89,5 @@ h1:VXmY7xyKsm1V0JWDpkthfSkiuao+q5CtzVHOkakv+lI= 20260601100000_rename_seal_key_ref_to_credential_id.sql h1:xFecq2lgvE3U4v65DNPwgcKtXaSzQ8+y9zJnKUHUh6U= 20260601100100_rename_frame_summary_placement_column.sql h1:aDObXIGv3jTavyX0n2XbApJAxjD+UHOovdOqKPjCabo= 20260601100200_add_proj_federation_seal_summary_stream_id.sql h1:/sgFuocyP63WPyKSk8wrZq1r8AZ9wfQV6iqt5eyhfPI= +20260601110000_init_proj_equipment_model_summary.sql h1:QJCanmiewUXP1knkN62ajcTon0dskClItX8pNOUwCzw= +20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:3zElIH2cC7y2mOyOmRgU+Asf3bsvfLtekrVH9mYnBqM= From 4184d93431946efa0fa4972e35307e97af50d242 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 06:04:44 +0300 Subject: [PATCH 09/11] fix(equipment): deprecated-Family lookup + per-verb error classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate-review P1-1 + P1-2 fix. P1-1 — Deprecated-Family lookup contradicted memo anti-hook: list_family_ids filtered WHERE deprecated_at IS NULL (the discovery-side helper for inspect_plan_binding), so declaring or adding a Deprecated Family failed with FamilyNotFoundError (404), contradicting the design memo lock that Asset-to-Deprecated-Family binding is permitted. Fix: introduce list_all_family_ids in equipment/aggregates/family/ read.py that includes Deprecated Families. Swap define_model and add_model_family handlers to use it. The discovery-side list_family_ids (Deprecated-excluded) stays available for inspect_plan_binding which intentionally excludes Deprecated candidates. Documented in the family/read.py module docstring. P1-2 — add/remove_model_family reused ModelCannotVersionError for the Deprecated-state guard, emitting an operator-facing diagnostic that named the wrong verb. Fix: introduce ModelCannotAddFamilyError + ModelCannotRemoveFamilyError in equipment/aggregates/model/state.py mirroring the Asset BC precedent (AssetCannotAddFamilyError + AssetCannotRemoveFamilyError). Swap add_model_family.decider + remove_model_family.decider to raise the per-verb classes. Wire into routes.py _handle_cannot _transition tuple (both map to 409 as before, just with the verb-correct diagnostic). Update affected tests across decider + PBT + handler + REST contract + MCP contract + integration tiers for both slices. Files changed: - 5 src files: family/read.py + family/__init__.py + model/state.py + model/__init__.py + 4 handlers/deciders + routes.py - ~20 test files: updated expectations for the renamed errors + 2 new regression integration tests covering the Deprecated-Family binding case Tests: 38 Model-related tests pass, 14492 architecture tests pass. ruff clean, pyright 0/0/0. Co-Authored-By: Claude Opus 4.7 --- .../equipment/aggregates/family/__init__.py | 2 + .../cora/equipment/aggregates/family/read.py | 49 ++++++++++ .../equipment/aggregates/model/__init__.py | 4 + .../cora/equipment/aggregates/model/state.py | 38 ++++++++ .../features/add_model_family/decider.py | 17 ++-- .../features/add_model_family/handler.py | 17 ++-- .../features/define_model/handler.py | 14 ++- .../features/remove_model_family/decider.py | 17 ++-- apps/api/src/cora/equipment/routes.py | 4 + .../test_add_model_family_endpoint.py | 10 +- .../test_add_model_family_mcp_tool.py | 4 +- .../contract/test_define_model_contract.py | 10 +- .../contract/test_deprecate_model_endpoint.py | 6 +- .../tests/contract/test_get_model_endpoint.py | 6 +- .../tests/contract/test_get_model_mcp_tool.py | 4 +- .../test_remove_model_family_endpoint.py | 6 +- .../test_remove_model_family_mcp_tool.py | 4 +- .../contract/test_version_model_endpoint.py | 6 +- .../test_add_model_family_handler_postgres.py | 91 ++++++++++++++++++- .../test_define_model_handler_postgres.py | 61 ++++++++++++- .../test_add_model_family_decider.py | 10 +- ...est_add_model_family_decider_properties.py | 16 ++-- .../test_add_model_family_handler.py | 20 ++-- .../equipment/test_define_model_handler.py | 10 +- .../equipment/test_deprecate_model_handler.py | 6 +- .../unit/equipment/test_get_model_handler.py | 8 +- .../equipment/test_list_all_family_ids.py | 30 ++++++ .../test_remove_model_family_decider.py | 10 +- ..._remove_model_family_decider_properties.py | 16 ++-- .../test_remove_model_family_handler.py | 16 ++-- .../equipment/test_version_model_handler.py | 6 +- 31 files changed, 398 insertions(+), 120 deletions(-) create mode 100644 apps/api/tests/unit/equipment/test_list_all_family_ids.py diff --git a/apps/api/src/cora/equipment/aggregates/family/__init__.py b/apps/api/src/cora/equipment/aggregates/family/__init__.py index 71dcdf10d..72deadc83 100644 --- a/apps/api/src/cora/equipment/aggregates/family/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/family/__init__.py @@ -22,6 +22,7 @@ from cora.equipment.aggregates.family.evolver import evolve, fold from cora.equipment.aggregates.family.read import ( FamilyLifecycleTimestamps, + list_all_family_ids, list_asset_ids_in_families, list_family_ids, load_family, @@ -70,6 +71,7 @@ "evolve", "fold", "from_stored", + "list_all_family_ids", "list_asset_ids_in_families", "list_family_ids", "load_family", diff --git a/apps/api/src/cora/equipment/aggregates/family/read.py b/apps/api/src/cora/equipment/aggregates/family/read.py index 63dbf57e7..d1017fab9 100644 --- a/apps/api/src/cora/equipment/aggregates/family/read.py +++ b/apps/api/src/cora/equipment/aggregates/family/read.py @@ -13,6 +13,25 @@ resource-API precedent. Mirrors `load_method_timestamps` / `load_plan_timestamps` / `load_practice_timestamps`. +## Two list_*_family_ids helpers + +Two read helpers enumerate Family ids from the summary projection; +they differ ONLY in whether Deprecated Families are filtered out. + +`list_family_ids` EXCLUDES Deprecated Families. It backs the +operator-facing discovery path: `inspect_plan_binding`'s candidate +enumeration should not offer a Deprecated Family as a source for +new wiring. + +`list_all_family_ids` INCLUDES Deprecated Families. It backs the +cross-BC existence-check path: `define_model` and `add_model_family` +verify that every referenced Family id resolves to a real Family +stream, and per the Model aggregate's design memo Family.deprecation +is an authoring signal, NOT a runtime gate. Binding a Model to a +Deprecated Family is permitted (mirrors the Asset-to-Deprecated-Family +posture); using the discovery filter here would surface a misleading +`FamilyNotFoundError` for a Family that genuinely exists. + `_STREAM_TYPE = "Family"`. The stream-type string is the event store's internal categorization key for this aggregate. """ @@ -122,6 +141,36 @@ async def list_family_ids(pool: asyncpg.Pool | None) -> list[UUID]: return [row["family_id"] for row in rows] +_SELECT_ALL_FAMILY_IDS_SQL = """ +SELECT family_id +FROM proj_equipment_family_summary +ORDER BY family_id::text +""" + + +async def list_all_family_ids(pool: asyncpg.Pool | None) -> list[UUID]: + """Read every Family id from the summary projection, INCLUDING Deprecated. + + Used by `define_model` and `add_model_family` to verify that every + declared/added Family id resolves to a real Family stream. Per the + Model aggregate's design memo, Family.deprecation is an authoring + signal, NOT a runtime gate: a Model is allowed to declare a + Deprecated Family. Filtering Deprecated rows out here (as + `list_family_ids` does for the discovery path) would surface a + misleading `FamilyNotFoundError` for a Family that genuinely + exists. + + Returns `[]` when `pool is None` (test / no-database app_env), + mirroring `list_family_ids`. Tests that need a populated lookup + must wire a real pool. + """ + if pool is None: + return [] + async with pool.acquire() as conn: + rows = await conn.fetch(_SELECT_ALL_FAMILY_IDS_SQL) + return [row["family_id"] for row in rows] + + _SELECT_ASSET_IDS_BY_FAMILIES_SQL = """ SELECT DISTINCT asset_id FROM proj_equipment_asset_family_membership diff --git a/apps/api/src/cora/equipment/aggregates/model/__init__.py b/apps/api/src/cora/equipment/aggregates/model/__init__.py index 5459c77b5..e3af8ca4e 100644 --- a/apps/api/src/cora/equipment/aggregates/model/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/model/__init__.py @@ -39,7 +39,9 @@ ManufacturerName, Model, ModelAlreadyExistsError, + ModelCannotAddFamilyError, ModelCannotDeprecateError, + ModelCannotRemoveFamilyError, ModelCannotVersionError, ModelDeprecationReason, ModelFamilyAlreadyPresentError, @@ -72,7 +74,9 @@ "ManufacturerName", "Model", "ModelAlreadyExistsError", + "ModelCannotAddFamilyError", "ModelCannotDeprecateError", + "ModelCannotRemoveFamilyError", "ModelCannotVersionError", "ModelDefined", "ModelDeprecated", diff --git a/apps/api/src/cora/equipment/aggregates/model/state.py b/apps/api/src/cora/equipment/aggregates/model/state.py index 74c34d4cb..c4499ef74 100644 --- a/apps/api/src/cora/equipment/aggregates/model/state.py +++ b/apps/api/src/cora/equipment/aggregates/model/state.py @@ -245,6 +245,44 @@ def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: self.current_status = current_status +class ModelCannotAddFamilyError(Exception): + """Attempted to add a family to a model not in `Defined` or `Versioned`. + + Mirrors `ModelCannotVersionError` and `ModelCannotDeprecateError`: + `add_model_family` accepts both `Defined` and `Versioned` source + states. Only `Deprecated` is rejected; the rejection rationale is + the same "deprecated catalog entry is frozen" guard that drives + version and deprecate. + """ + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot add family: currently in status " + f"{current_status.value}, add_model_family requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + +class ModelCannotRemoveFamilyError(Exception): + """Attempted to remove a family from a model not in `Defined` or `Versioned`. + + Mirrors `ModelCannotAddFamilyError`: `remove_model_family` accepts + both `Defined` and `Versioned` source states. Only `Deprecated` + is rejected on the same frozen-catalog-entry rationale. + """ + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot remove family: currently in status " + f"{current_status.value}, remove_model_family requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + class ModelFamilyAlreadyPresentError(Exception): """Attempted to add a family already present in `declared_families`.""" diff --git a/apps/api/src/cora/equipment/features/add_model_family/decider.py b/apps/api/src/cora/equipment/features/add_model_family/decider.py index 106670c7e..23351ec71 100644 --- a/apps/api/src/cora/equipment/features/add_model_family/decider.py +++ b/apps/api/src/cora/equipment/features/add_model_family/decider.py @@ -7,13 +7,10 @@ `ModelVersioned` and `ModelFamilyRemoved` rejection from `Deprecated` in the events module. -There is no dedicated `ModelCannotAddFamilyError` in the Model -aggregate; the Model aggregate carries `ModelCannotVersionError` -as its general "cannot mutate from Deprecated" gate (add and remove -are conceptually version-like mutations of the declared-families -set), so this slice reuses it. The diagnostic message stays -accurate because `ModelCannotVersionError` already enumerates the -allowed `Defined | Versioned` source states. +The Deprecated gate raises a per-verb `ModelCannotAddFamilyError` +mirroring `AssetCannotAddFamilyError`. The diagnostic message names +the actual verb so operators see "cannot add family" instead of +the older shared "cannot be versioned" wording. The decider does NOT verify the referenced Family id resolves to a real Family stream; the handler performs that cross-BC lookup @@ -25,7 +22,7 @@ Invariants: - State must not be None -> ModelNotFoundError - - State.status must not be Deprecated -> ModelCannotVersionError + - State.status must not be Deprecated -> ModelCannotAddFamilyError - family_id must not already be in state.declared_families (strict-not-idempotent) -> ModelFamilyAlreadyPresentError """ @@ -34,7 +31,7 @@ from cora.equipment.aggregates.model import ( Model, - ModelCannotVersionError, + ModelCannotAddFamilyError, ModelFamilyAdded, ModelFamilyAlreadyPresentError, ModelNotFoundError, @@ -53,7 +50,7 @@ def decide( if state is None: raise ModelNotFoundError(command.model_id) if state.status is ModelStatus.DEPRECATED: - raise ModelCannotVersionError(state.id, current_status=state.status) + raise ModelCannotAddFamilyError(state.id, current_status=state.status) if command.family_id in state.declared_families: raise ModelFamilyAlreadyPresentError(state.id, command.family_id) return [ diff --git a/apps/api/src/cora/equipment/features/add_model_family/handler.py b/apps/api/src/cora/equipment/features/add_model_family/handler.py index 71045e1ad..ce2a28963 100644 --- a/apps/api/src/cora/equipment/features/add_model_family/handler.py +++ b/apps/api/src/cora/equipment/features/add_model_family/handler.py @@ -3,7 +3,7 @@ Update-style handler shape: load + fold + decide + append. Mirrors the `add_asset_family` and `version_model` precedents for the stream load + fold + decide + append spine, and the `define_model` -precedent for the cross-BC `list_family_ids` lookup that resolves +precedent for the cross-BC `list_all_family_ids` lookup that resolves `command.family_id` against the Family registry before the decider runs. @@ -12,16 +12,19 @@ `add_asset_family`). Cross-BC concern: the referenced `family_id` must resolve to a -registered Family stream. On miss the handler raises -`FamilyNotFoundError(command.family_id)` (404) before the decider -sees the command, matching the `define_model` operational pattern -of surfacing missing-Family errors at the application boundary. +registered Family stream (including Deprecated). On miss the +handler raises `FamilyNotFoundError(command.family_id)` (404) before +the decider sees the command, matching the `define_model` +operational pattern of surfacing missing-Family errors at the +application boundary. Family.deprecation is an authoring signal NOT +a runtime gate per the Model aggregate's design memo; adding a +Deprecated Family to a Model's declared set is permitted. """ from typing import Protocol from uuid import UUID -from cora.equipment.aggregates.family import FamilyNotFoundError, list_family_ids +from cora.equipment.aggregates.family import FamilyNotFoundError, list_all_family_ids from cora.equipment.aggregates.model import ( ModelEvent, event_type_name, @@ -102,7 +105,7 @@ async def handler( # Bulk single-query approach (cheap at pilot scale, <50 Families). # Trigger to switch to per-id load: facility Family count crosses # ~500 OR p95 of add_model_family crosses 200ms. - known_family_ids = set(await list_family_ids(deps.pool)) + known_family_ids = set(await list_all_family_ids(deps.pool)) if command.family_id not in known_family_ids: _log.info( "add_model_family.family_not_found", diff --git a/apps/api/src/cora/equipment/features/define_model/handler.py b/apps/api/src/cora/equipment/features/define_model/handler.py index 4a160abfc..5175824ef 100644 --- a/apps/api/src/cora/equipment/features/define_model/handler.py +++ b/apps/api/src/cora/equipment/features/define_model/handler.py @@ -6,19 +6,25 @@ `from cora.equipment.features import define_model` then `define_model.bind(deps)` returning a `define_model.Handler`. -Cross-BC concern: this handler loads `list_family_ids` from the +Cross-BC concern: this handler loads `list_all_family_ids` from the Family read repo before invoking the decider, and verifies every element of `command.declared_families` resolves to a registered -non-Deprecated Family. On miss, raises `FamilyNotFoundError` +Family (including Deprecated). On miss, raises `FamilyNotFoundError` (404) carrying the FIRST missing Family id. Operators iterating through a multi-family catalog entry get a single missing id at a time, matching the operational pattern. + +Family.deprecation is an authoring signal NOT a runtime gate per +the Model aggregate's design memo; binding a Model to a Deprecated +Family is permitted, mirroring the Asset-to-Deprecated-Family +posture. The discovery-side filter (`list_family_ids`) is the wrong +helper here. """ from typing import Protocol from uuid import UUID -from cora.equipment.aggregates.family import FamilyNotFoundError, list_family_ids +from cora.equipment.aggregates.family import FamilyNotFoundError, list_all_family_ids from cora.equipment.aggregates.model import event_type_name, to_payload from cora.equipment.errors import UnauthorizedError from cora.equipment.features.define_model.command import DefineModel @@ -114,7 +120,7 @@ async def handler( # Bulk single-query approach (cheap at pilot scale, <50 Families). # Trigger to switch to per-id load: facility Family count crosses # ~500 OR p95 of define_model crosses 200ms. - known_family_ids = set(await list_family_ids(deps.pool)) + known_family_ids = set(await list_all_family_ids(deps.pool)) missing = command.declared_families - known_family_ids if missing: # Sorted for deterministic error ordering across runs; surface diff --git a/apps/api/src/cora/equipment/features/remove_model_family/decider.py b/apps/api/src/cora/equipment/features/remove_model_family/decider.py index 2f9f2d7a4..1ad5406e4 100644 --- a/apps/api/src/cora/equipment/features/remove_model_family/decider.py +++ b/apps/api/src/cora/equipment/features/remove_model_family/decider.py @@ -7,13 +7,10 @@ `ModelVersioned` and `ModelFamilyAdded` rejection from `Deprecated` in the events module. -There is no dedicated `ModelCannotRemoveFamilyError` in the Model -aggregate; the Model aggregate carries `ModelCannotVersionError` -as its general "cannot mutate from Deprecated" gate (add and remove -are conceptually version-like mutations of the declared-families -set), so this slice reuses it. The diagnostic message stays -accurate because `ModelCannotVersionError` already enumerates the -allowed `Defined | Versioned` source states. +The Deprecated gate raises a per-verb `ModelCannotRemoveFamilyError` +mirroring `AssetCannotRemoveFamilyError`. The diagnostic message +names the actual verb so operators see "cannot remove family" +instead of the older shared "cannot be versioned" wording. The decider does NOT verify the referenced Family id resolves to a real Family stream; removal only requires that the id already sits @@ -26,7 +23,7 @@ Invariants: - State must not be None -> ModelNotFoundError - - State.status must not be Deprecated -> ModelCannotVersionError + - State.status must not be Deprecated -> ModelCannotRemoveFamilyError - family_id must already be in state.declared_families (strict-not-idempotent) -> ModelFamilyNotPresentError """ @@ -35,7 +32,7 @@ from cora.equipment.aggregates.model import ( Model, - ModelCannotVersionError, + ModelCannotRemoveFamilyError, ModelFamilyNotPresentError, ModelFamilyRemoved, ModelNotFoundError, @@ -54,7 +51,7 @@ def decide( if state is None: raise ModelNotFoundError(command.model_id) if state.status is ModelStatus.DEPRECATED: - raise ModelCannotVersionError(state.id, current_status=state.status) + raise ModelCannotRemoveFamilyError(state.id, current_status=state.status) if command.family_id not in state.declared_families: raise ModelFamilyNotPresentError(state.id, command.family_id) return [ diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 343ac2f05..c4fd73a92 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -87,7 +87,9 @@ InvalidModelVersionTagError, InvalidPartNumberError, ModelAlreadyExistsError, + ModelCannotAddFamilyError, ModelCannotDeprecateError, + ModelCannotRemoveFamilyError, ModelCannotVersionError, ModelFamilyAlreadyPresentError, ModelFamilyNotPresentError, @@ -319,6 +321,8 @@ def register_equipment_routes(app: FastAPI) -> None: AssetAlreadyInstalledElsewhereError, ModelCannotVersionError, ModelCannotDeprecateError, + ModelCannotAddFamilyError, + ModelCannotRemoveFamilyError, ModelFamilyAlreadyPresentError, ModelFamilyNotPresentError, ): diff --git a/apps/api/tests/contract/test_add_model_family_endpoint.py b/apps/api/tests/contract/test_add_model_family_endpoint.py index e83ab906e..9a8e1449c 100644 --- a/apps/api/tests/contract/test_add_model_family_endpoint.py +++ b/apps/api/tests/contract/test_add_model_family_endpoint.py @@ -4,7 +4,7 @@ `declared_families` set. 204 No Content on success. In-memory contract harness has no Postgres pool, so the cross-BC -`list_family_ids` lookup performed by both `define_model` (during +`list_all_family_ids` lookup performed by both `define_model` (during seeding) and `add_model_family` (under test) returns `[]` by default. We stub the symbol in BOTH handler modules to a fixed accept-all set so we can seed a Model via `POST /models` and exercise @@ -41,7 +41,7 @@ @pytest.fixture def accept_families(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[UUID]]: - """Stub `list_family_ids` in both handler modules so the seeding + """Stub `list_all_family_ids` in both handler modules so the seeding `define_model` call and the `add_model_family` call under test each accept the fixed family-id set.""" known: list[UUID] = [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] @@ -50,11 +50,11 @@ async def _stub(_pool: object) -> list[UUID]: return list(known) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) monkeypatch.setattr( - "cora.equipment.features.add_model_family.handler.list_family_ids", + "cora.equipment.features.add_model_family.handler.list_all_family_ids", _stub, ) yield known @@ -145,7 +145,7 @@ def test_post_add_model_family_returns_404_when_family_unregistered( accept_families: list[UUID], ) -> None: """Cross-BC precondition surfaces as 404 when the supplied family_id - does not resolve via `list_family_ids`.""" + does not resolve via `list_all_family_ids`.""" _ = accept_families unknown_family_id = str(uuid4()) with TestClient(create_app()) as client: diff --git a/apps/api/tests/contract/test_add_model_family_mcp_tool.py b/apps/api/tests/contract/test_add_model_family_mcp_tool.py index 246b3cf5d..637003a83 100644 --- a/apps/api/tests/contract/test_add_model_family_mcp_tool.py +++ b/apps/api/tests/contract/test_add_model_family_mcp_tool.py @@ -3,7 +3,7 @@ Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. In-memory contract harness has no Postgres pool, so the cross-BC -`list_family_ids` lookup returns `[]` and every `add_model_family` +`list_all_family_ids` lookup returns `[]` and every `add_model_family` call surfaces `FamilyNotFoundError` before the decider runs. The happy path is pinned at the integration tier; this file pins the MCP-wire shape: tool registration, description spec, and the failure @@ -119,7 +119,7 @@ async def _stub(_pool: object) -> list[UUID]: return [fake_family_id] monkeypatch.setattr( - "cora.equipment.features.add_model_family.handler.list_family_ids", + "cora.equipment.features.add_model_family.handler.list_all_family_ids", _stub, ) missing_model_id = uuid4() diff --git a/apps/api/tests/contract/test_define_model_contract.py b/apps/api/tests/contract/test_define_model_contract.py index a7a4fd886..20e2fee8d 100644 --- a/apps/api/tests/contract/test_define_model_contract.py +++ b/apps/api/tests/contract/test_define_model_contract.py @@ -9,7 +9,7 @@ The Model handler enforces a cross-BC precondition: every entry in `declared_families` must resolve via the Family read repo's -`list_family_ids`, which is pool-backed and returns `[]` in the +`list_all_family_ids`, which is pool-backed and returns `[]` in the in-memory TestClient harness. We monkeypatch the symbol imported into the handler module to a fixed accept-all stub so the contract surface under test stays focused on HTTP shape + idempotency semantics (the @@ -29,9 +29,9 @@ @pytest.fixture def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: - """Stub `list_family_ids` so `_FIXED_FAMILY_ID` always resolves. + """Stub `list_all_family_ids` so `_FIXED_FAMILY_ID` always resolves. - The handler imports `list_family_ids` by name at module load, so we + The handler imports `list_all_family_ids` by name at module load, so we patch the binding in the handler's namespace (the one it actually calls), mirroring the unit-test pattern in `tests/unit/equipment/test_define_model_handler.py`. @@ -41,7 +41,7 @@ async def _stub(_pool: object) -> list[UUID]: return [_FIXED_FAMILY_ID] monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) yield _FIXED_FAMILY_ID @@ -111,7 +111,7 @@ def test_post_models_whitespace_only_name_returns_400(accept_family: UUID) -> No @pytest.mark.contract def test_post_models_unknown_declared_family_returns_404(accept_family: UUID) -> None: """Cross-BC precondition surfaces as 404 when a declared family does - not resolve against `list_family_ids`.""" + not resolve against `list_all_family_ids`.""" _ = accept_family with TestClient(create_app()) as client: body = _body() diff --git a/apps/api/tests/contract/test_deprecate_model_endpoint.py b/apps/api/tests/contract/test_deprecate_model_endpoint.py index 7a80f5f23..42c32ba4d 100644 --- a/apps/api/tests/contract/test_deprecate_model_endpoint.py +++ b/apps/api/tests/contract/test_deprecate_model_endpoint.py @@ -4,7 +4,7 @@ (Defined | Versioned -> Deprecated). Strict-not-idempotent. In-memory contract harness has no Postgres pool, so the cross-BC -`list_family_ids` lookup performed by `define_model` returns `[]` and +`list_all_family_ids` lookup performed by `define_model` returns `[]` and every model-seeding call would fail. We stub the symbol to a fixed accept-all set so we can seed a Model via `POST /models` and exercise `POST /models/{model_id}/deprecation` end-to-end. @@ -24,13 +24,13 @@ @pytest.fixture def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: - """Stub `list_family_ids` so the seeding `define_model` succeeds.""" + """Stub `list_all_family_ids` so the seeding `define_model` succeeds.""" async def _stub(_pool: object) -> list[UUID]: return [_FIXED_FAMILY_ID] monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) yield _FIXED_FAMILY_ID diff --git a/apps/api/tests/contract/test_get_model_endpoint.py b/apps/api/tests/contract/test_get_model_endpoint.py index 527122a62..df021d8d0 100644 --- a/apps/api/tests/contract/test_get_model_endpoint.py +++ b/apps/api/tests/contract/test_get_model_endpoint.py @@ -10,7 +10,7 @@ The Model upstream `define_model` slice enforces a cross-BC precondition: every entry in `declared_families` must resolve via the Family read -repo's `list_family_ids`, which is pool-backed and returns `[]` in the +repo's `list_all_family_ids`, which is pool-backed and returns `[]` in the in-memory TestClient harness. We monkeypatch the symbol imported into the upstream handler module so the seed `POST /models` call succeeds and we can exercise the read surface here. @@ -29,13 +29,13 @@ @pytest.fixture def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: - """Stub `list_family_ids` so `_FIXED_FAMILY_ID` always resolves.""" + """Stub `list_all_family_ids` so `_FIXED_FAMILY_ID` always resolves.""" async def _stub(_pool: object) -> list[UUID]: return [_FIXED_FAMILY_ID] monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) yield _FIXED_FAMILY_ID diff --git a/apps/api/tests/contract/test_get_model_mcp_tool.py b/apps/api/tests/contract/test_get_model_mcp_tool.py index 4abe3d6cb..923fe031b 100644 --- a/apps/api/tests/contract/test_get_model_mcp_tool.py +++ b/apps/api/tests/contract/test_get_model_mcp_tool.py @@ -2,7 +2,7 @@ Mirrors `test_get_family_mcp_tool.py`. Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. The Model upstream `define_model` -tool enforces a cross-BC `list_family_ids` precondition that is +tool enforces a cross-BC `list_all_family_ids` precondition that is pool-backed and returns `[]` in the in-memory harness; we monkeypatch the upstream handler's binding so the seed tool call succeeds. @@ -26,7 +26,7 @@ async def _stub(_pool: object) -> list[UUID]: return [_FIXED_FAMILY_ID] monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) yield _FIXED_FAMILY_ID diff --git a/apps/api/tests/contract/test_remove_model_family_endpoint.py b/apps/api/tests/contract/test_remove_model_family_endpoint.py index 55d15ed7a..412d292f7 100644 --- a/apps/api/tests/contract/test_remove_model_family_endpoint.py +++ b/apps/api/tests/contract/test_remove_model_family_endpoint.py @@ -8,7 +8,7 @@ NO cross-BC Family lookup; removing a Family that has been deprecated or deleted from the Family registry still succeeds if it sits in `declared_families`. The seeding `define_model` call DOES still -resolve `list_family_ids` cross-BC, so we stub that one symbol on +resolve `list_all_family_ids` cross-BC, so we stub that one symbol on the `define_model` handler module. The 409-on-Deprecated path appends a `ModelDeprecated` event directly @@ -41,7 +41,7 @@ @pytest.fixture def accept_families(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[UUID]]: - """Stub `list_family_ids` on the `define_model` handler so the + """Stub `list_all_family_ids` on the `define_model` handler so the seeding call accepts the fixed family-id set. The `remove_model_family` handler does NOT perform this lookup.""" known: list[UUID] = [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] @@ -50,7 +50,7 @@ async def _stub(_pool: object) -> list[UUID]: return list(known) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) yield known diff --git a/apps/api/tests/contract/test_remove_model_family_mcp_tool.py b/apps/api/tests/contract/test_remove_model_family_mcp_tool.py index cff309b52..e1e29037a 100644 --- a/apps/api/tests/contract/test_remove_model_family_mcp_tool.py +++ b/apps/api/tests/contract/test_remove_model_family_mcp_tool.py @@ -9,7 +9,7 @@ - present model + absent family -> isError: true ("does not declare") - missing model stream -> isError: true ("not found") -The seeding `define_model` call still needs `list_family_ids` +The seeding `define_model` call still needs `list_all_family_ids` stubbed so we can seed a real model via REST in the same TestClient before invoking the MCP tool. """ @@ -33,7 +33,7 @@ async def _stub(_pool: object) -> list[UUID]: return list(family_ids) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) diff --git a/apps/api/tests/contract/test_version_model_endpoint.py b/apps/api/tests/contract/test_version_model_endpoint.py index 65e0516fe..0eaaea5a4 100644 --- a/apps/api/tests/contract/test_version_model_endpoint.py +++ b/apps/api/tests/contract/test_version_model_endpoint.py @@ -5,7 +5,7 @@ (Defined | Versioned -> Versioned). In-memory contract harness has no Postgres pool, so the cross-BC -`list_family_ids` lookup performed by `define_model` returns `[]` and +`list_all_family_ids` lookup performed by `define_model` returns `[]` and every model-seeding call would fail. We stub the symbol to a fixed accept-all set so we can seed a Model via `POST /models` and exercise `POST /models/{model_id}/versions` end-to-end. @@ -40,13 +40,13 @@ @pytest.fixture def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: - """Stub `list_family_ids` so the seeding `define_model` succeeds.""" + """Stub `list_all_family_ids` so the seeding `define_model` succeeds.""" async def _stub(_pool: object) -> list[UUID]: return [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _stub, ) yield _FIXED_FAMILY_ID diff --git a/apps/api/tests/integration/test_add_model_family_handler_postgres.py b/apps/api/tests/integration/test_add_model_family_handler_postgres.py index d3cd1c36f..0420424ff 100644 --- a/apps/api/tests/integration/test_add_model_family_handler_postgres.py +++ b/apps/api/tests/integration/test_add_model_family_handler_postgres.py @@ -23,10 +23,16 @@ fold, from_stored, ) -from cora.equipment.features import add_model_family, define_family, define_model +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + deprecate_family, +) from cora.equipment.features.add_model_family import AddModelFamily from cora.equipment.features.define_family import DefineFamily from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_family import DeprecateFamily from cora.infrastructure.projection import ProjectionRegistry, drain_projections from tests.integration._helpers import build_postgres_deps @@ -220,3 +226,86 @@ async def test_add_model_family_rejects_duplicate_family( _, version = await deps.event_store.load("Model", model_id) assert version == 1 + + +@pytest.mark.integration +async def test_add_model_family_succeeds_when_family_is_deprecated( + db_pool: asyncpg.Pool, +) -> None: + """Family.deprecation is an authoring signal NOT a runtime gate + per the Model aggregate's design memo. Seed two Families, deprecate + the second, define a Model declaring only the first, then add the + deprecated Family. The handler's cross-BC lookup goes through + `list_all_family_ids` which INCLUDES Deprecated rows, so the call + proceeds to event-write without raising `FamilyNotFoundError`.""" + family_a_id = UUID("01900000-0000-7000-8000-00000062d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000062d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000062d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000062d00f") + family_b_deprecate_event_id = UUID("01900000-0000-7000-8000-00000062d01a") + model_id = UUID("01900000-0000-7000-8000-00000062ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000062ca0e") + added_event_id = UUID("01900000-0000-7000-8000-00000062ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + family_b_deprecate_event_id, + model_id, + define_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="LegacyStepScan", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_family.bind(deps)( + DeprecateFamily(family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyAdded"] + added = events[1] + assert added.payload == { + "model_id": str(model_id), + "family_id": str(family_b_id), + "occurred_at": _NOW.isoformat(), + } + + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.declared_families == frozenset({family_a_id, family_b_id}) diff --git a/apps/api/tests/integration/test_define_model_handler_postgres.py b/apps/api/tests/integration/test_define_model_handler_postgres.py index 0dd119440..933c4d7d2 100644 --- a/apps/api/tests/integration/test_define_model_handler_postgres.py +++ b/apps/api/tests/integration/test_define_model_handler_postgres.py @@ -29,9 +29,10 @@ ManufacturerIdentifierType, ManufacturerName, ) -from cora.equipment.features import define_family, define_model +from cora.equipment.features import define_family, define_model, deprecate_family from cora.equipment.features.define_family import DefineFamily from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_family import DeprecateFamily from cora.equipment.wire import wire_equipment from cora.infrastructure.projection import ProjectionRegistry, drain_projections from tests.integration._helpers import build_postgres_deps @@ -263,3 +264,61 @@ async def test_define_model_idempotency_key_replay_returns_same_model_id( # The "second" queued model_id was never written. _, second_version = await deps.event_store.load("Model", unused_replay_model_id) assert second_version == 0 + + +@pytest.mark.integration +async def test_define_model_succeeds_when_declared_family_is_deprecated( + db_pool: asyncpg.Pool, +) -> None: + """Family.deprecation is an authoring signal NOT a runtime gate + per the Model aggregate's design memo. A Family that has been + deprecated still resolves through `list_all_family_ids` (which + drops the `WHERE deprecated_at IS NULL` filter that + `list_family_ids` enforces for the discovery path), so + `define_model` proceeds to event-write without raising + `FamilyNotFoundError`.""" + family_id = UUID("01900000-0000-7000-8000-000000054f01") + family_event_id = UUID("01900000-0000-7000-8000-000000054f0e") + deprecate_event_id = UUID("01900000-0000-7000-8000-000000054f1a") + model_id = UUID("01900000-0000-7000-8000-00000054ca04") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca2a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + deprecate_event_id, + model_id, + model_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="LegacyTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_family.bind(deps)( + DeprecateFamily(family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + returned_id = await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == model_id + + events, version = await deps.event_store.load("Model", model_id) + assert version == 1 + assert events[0].event_type == "ModelDefined" + assert events[0].payload["declared_families"] == [str(family_id)] diff --git a/apps/api/tests/unit/equipment/test_add_model_family_decider.py b/apps/api/tests/unit/equipment/test_add_model_family_decider.py index 7bd9db19b..d93509783 100644 --- a/apps/api/tests/unit/equipment/test_add_model_family_decider.py +++ b/apps/api/tests/unit/equipment/test_add_model_family_decider.py @@ -3,8 +3,8 @@ Targeted mutation of `Model.declared_families`, not a lifecycle transition. Status is preserved (`Defined` stays `Defined`, `Versioned` stays `Versioned`); only `Deprecated` is rejected via -`ModelCannotVersionError` (Model's general "cannot mutate from -Deprecated" gate, reused by the add/remove family slices). +the per-verb `ModelCannotAddFamilyError` (mirrors +`AssetCannotAddFamilyError`). Strict-not-idempotent: re-adding a present family raises `ModelFamilyAlreadyPresentError`, mirroring the `add_asset_family` @@ -20,7 +20,7 @@ Manufacturer, ManufacturerName, Model, - ModelCannotVersionError, + ModelCannotAddFamilyError, ModelFamilyAdded, ModelFamilyAlreadyPresentError, ModelName, @@ -79,10 +79,10 @@ def test_decide_emits_model_family_added_from_versioned_state() -> None: @pytest.mark.unit -def test_decide_raises_cannot_version_when_deprecated() -> None: +def test_decide_raises_cannot_add_family_when_deprecated() -> None: """Deprecated catalog entries are frozen; add_model_family rejects.""" state = _model(status=ModelStatus.DEPRECATED, version="v1") - with pytest.raises(ModelCannotVersionError) as exc_info: + with pytest.raises(ModelCannotAddFamilyError) as exc_info: add_model_family.decide( state=state, command=AddModelFamily(model_id=state.id, family_id=uuid4()), diff --git a/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py b/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py index a555acb7e..5f8d907e1 100644 --- a/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py @@ -1,9 +1,9 @@ """Property-based tests for `add_model_family.decide` (Equipment BC). Targeted mutation of `Model.declared_families`; status is preserved -across the mutation and only `Deprecated` is rejected (via the shared -`ModelCannotVersionError` gate). Universal claims across generated -inputs: +across the mutation and only `Deprecated` is rejected (via the +per-verb `ModelCannotAddFamilyError` gate). Universal claims across +generated inputs: - state in {Defined, Versioned} + family_id NOT in declared_families emits exactly one ModelFamilyAdded with the @@ -12,7 +12,7 @@ ModelFamilyAlreadyPresentError carrying the model + family id. - state=None always raises ModelNotFoundError carrying the command's model_id. - - state.status==Deprecated always raises ModelCannotVersionError + - state.status==Deprecated always raises ModelCannotAddFamilyError carrying the Deprecated source status. """ @@ -28,7 +28,7 @@ Manufacturer, ManufacturerName, Model, - ModelCannotVersionError, + ModelCannotAddFamilyError, ModelFamilyAdded, ModelFamilyAlreadyPresentError, ModelName, @@ -147,13 +147,13 @@ def test_add_model_family_on_empty_state_always_raises_not_found( family_id=st.uuids(), now=aware_datetimes(), ) -def test_add_model_family_on_deprecated_state_always_raises_cannot_version( +def test_add_model_family_on_deprecated_state_always_raises_cannot_add_family( model_id: UUID, declared_families: frozenset[UUID], family_id: UUID, now: datetime, ) -> None: - """state.status==Deprecated -> ModelCannotVersionError, regardless of + """state.status==Deprecated -> ModelCannotAddFamilyError, regardless of whether family_id would have been a duplicate or a fresh add.""" state = _model( model_id, @@ -161,7 +161,7 @@ def test_add_model_family_on_deprecated_state_always_raises_cannot_version( declared_families=declared_families, ) command = AddModelFamily(model_id=model_id, family_id=family_id) - with pytest.raises(ModelCannotVersionError) as exc: + with pytest.raises(ModelCannotAddFamilyError) as exc: add_model_family.decide(state=state, command=command, now=now) assert exc.value.model_id == model_id assert exc.value.current_status is ModelStatus.DEPRECATED diff --git a/apps/api/tests/unit/equipment/test_add_model_family_handler.py b/apps/api/tests/unit/equipment/test_add_model_family_handler.py index 0a086442a..6e2058953 100644 --- a/apps/api/tests/unit/equipment/test_add_model_family_handler.py +++ b/apps/api/tests/unit/equipment/test_add_model_family_handler.py @@ -4,7 +4,7 @@ load + fold + decide + append. Not idempotency-wrapped. Cross-BC concern: the referenced `family_id` must resolve to a -registered Family via `list_family_ids`. The unit harness has no +registered Family via `list_all_family_ids`. The unit harness has no Postgres pool, so we monkeypatch the symbol imported into the handler module to a fixed accept-all stub (mirrors the `define_model` handler test pattern). The seeding `define_model` @@ -14,7 +14,7 @@ The Deprecated path seeds a `ModelDeprecated` event directly onto the in-memory store (no `deprecate_model` slice is exercised in this test file), then invokes the handler and expects -`ModelCannotVersionError`. +`ModelCannotAddFamilyError`. """ from datetime import UTC, datetime @@ -27,7 +27,7 @@ from cora.equipment.aggregates.model import ( Manufacturer, ManufacturerName, - ModelCannotVersionError, + ModelCannotAddFamilyError, ModelDeprecated, ModelFamilyAlreadyPresentError, ModelNotFoundError, @@ -72,10 +72,10 @@ def _patch_known_families( monkeypatch: pytest.MonkeyPatch, family_ids: list[UUID], ) -> None: - """Patch `list_family_ids` in both handler modules that look it up. + """Patch `list_all_family_ids` in both handler modules that look it up. The seeding `define_model` call AND the slice under test - (`add_model_family`) each import `list_family_ids` by name at module + (`add_model_family`) each import `list_all_family_ids` by name at module load. Patching the binding in each handler's namespace ensures both paths see the stub. """ @@ -84,11 +84,11 @@ async def _fake_list_family_ids(_pool: object) -> list[UUID]: return list(family_ids) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _fake_list_family_ids, ) monkeypatch.setattr( - "cora.equipment.features.add_model_family.handler.list_family_ids", + "cora.equipment.features.add_model_family.handler.list_all_family_ids", _fake_list_family_ids, ) @@ -191,7 +191,7 @@ async def test_handler_raises_family_not_found_for_unregistered_family( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cross-BC precondition: the family_id must resolve via - `list_family_ids`; an unregistered id raises `FamilyNotFoundError` + `list_all_family_ids`; an unregistered id raises `FamilyNotFoundError` before the decider is reached.""" _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) store = InMemoryEventStore() @@ -249,7 +249,7 @@ async def test_handler_raises_already_present_on_duplicate_family( @pytest.mark.unit -async def test_handler_raises_cannot_version_when_deprecated( +async def test_handler_raises_cannot_add_family_when_deprecated( monkeypatch: pytest.MonkeyPatch, ) -> None: """Deprecated Models cannot accept new family declarations.""" @@ -258,7 +258,7 @@ async def test_handler_raises_cannot_version_when_deprecated( deps = _build_deps(event_store=store) await _seed_deprecated_model(deps, store) - with pytest.raises(ModelCannotVersionError): + with pytest.raises(ModelCannotAddFamilyError): await add_model_family.bind(deps)( AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_B_ID), principal_id=_PRINCIPAL_ID, diff --git a/apps/api/tests/unit/equipment/test_define_model_handler.py b/apps/api/tests/unit/equipment/test_define_model_handler.py index 17dcf0386..775661d22 100644 --- a/apps/api/tests/unit/equipment/test_define_model_handler.py +++ b/apps/api/tests/unit/equipment/test_define_model_handler.py @@ -2,12 +2,12 @@ Mirrors the `define_family` handler test's shape (same Handler protocol, same authorize + event-store wiring, same Kernel deps). -The Model-specific addition is the cross-BC `list_family_ids` +The Model-specific addition is the cross-BC `list_all_family_ids` precondition: the handler resolves every element of `command.declared_families` against the Family read repo before invoking the decider, and raises `FamilyNotFoundError` on miss. -`list_family_ids` reads from `proj_equipment_family_summary` and +`list_all_family_ids` reads from `proj_equipment_family_summary` and returns `[]` when `pool is None` (the in-memory test default). Tests that need a populated Family set monkeypatch the symbol imported into `define_model.handler` rather than seeding a real @@ -56,7 +56,7 @@ def _patch_known_families( monkeypatch: pytest.MonkeyPatch, family_ids: list[UUID], ) -> None: - """Patch `list_family_ids` as imported into the handler module. + """Patch `list_all_family_ids` as imported into the handler module. The handler does `from cora.equipment.aggregates.family import list_family_ids` at module load, so monkeypatching the source @@ -69,7 +69,7 @@ async def _fake_list_family_ids(_pool: object) -> list[UUID]: return list(family_ids) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _fake_list_family_ids, ) @@ -143,7 +143,7 @@ async def test_handler_raises_family_not_found_for_unregistered_family( monkeypatch: pytest.MonkeyPatch, ) -> None: """Cross-BC precondition: declared_families containing an id that - doesn't resolve via `list_family_ids` raises `FamilyNotFoundError`.""" + doesn't resolve via `list_all_family_ids` raises `FamilyNotFoundError`.""" _patch_known_families(monkeypatch, [_FAMILY_A_ID]) deps = _build_deps() handler = define_model.bind(deps) diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_handler.py b/apps/api/tests/unit/equipment/test_deprecate_model_handler.py index 40e9fddec..731b8b1d9 100644 --- a/apps/api/tests/unit/equipment/test_deprecate_model_handler.py +++ b/apps/api/tests/unit/equipment/test_deprecate_model_handler.py @@ -63,9 +63,9 @@ def _patch_known_families( monkeypatch: pytest.MonkeyPatch, family_ids: list[UUID], ) -> None: - """Patch `list_family_ids` as imported into the define_model handler. + """Patch `list_all_family_ids` as imported into the define_model handler. - `deprecate_model` does NOT call `list_family_ids`, but the + `deprecate_model` does NOT call `list_all_family_ids`, but the seeding call to `define_model` does. We stub it accept-all so the seed succeeds in the in-memory harness. """ @@ -74,7 +74,7 @@ async def _fake_list_family_ids(_pool: object) -> list[UUID]: return list(family_ids) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _fake_list_family_ids, ) diff --git a/apps/api/tests/unit/equipment/test_get_model_handler.py b/apps/api/tests/unit/equipment/test_get_model_handler.py index e3ee35c0f..df3f9086b 100644 --- a/apps/api/tests/unit/equipment/test_get_model_handler.py +++ b/apps/api/tests/unit/equipment/test_get_model_handler.py @@ -58,13 +58,13 @@ def _patch_known_families( family_ids: list[UUID], *, targets: tuple[str, ...] = ( - "cora.equipment.features.define_model.handler.list_family_ids", - "cora.equipment.features.add_model_family.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", + "cora.equipment.features.add_model_family.handler.list_all_family_ids", ), ) -> None: - """Stub `list_family_ids` as imported into upstream command handlers. + """Stub `list_all_family_ids` as imported into upstream command handlers. - `get_model` itself does NOT call `list_family_ids`; the stub is + `get_model` itself does NOT call `list_all_family_ids`; the stub is needed only for the upstream `define_model` and `add_model_family` calls that seed the stream this test reads back. """ diff --git a/apps/api/tests/unit/equipment/test_list_all_family_ids.py b/apps/api/tests/unit/equipment/test_list_all_family_ids.py new file mode 100644 index 000000000..bea73feeb --- /dev/null +++ b/apps/api/tests/unit/equipment/test_list_all_family_ids.py @@ -0,0 +1,30 @@ +"""Unit tests for `list_all_family_ids`. + +Sibling to `list_family_ids` that differs ONLY in whether Deprecated +Families are filtered out. The discovery-side helper +(`list_family_ids`) keeps the `WHERE deprecated_at IS NULL` filter; +the cross-BC existence-check helper (`list_all_family_ids`, used by +`define_model` and `add_model_family`) drops it. Per the Model +aggregate's design memo, Family.deprecation is an authoring signal +NOT a runtime gate, so binding a Model to a Deprecated Family is +permitted; using the discovery filter for the existence check would +surface a misleading `FamilyNotFoundError` for a Family that +genuinely exists. + +Database-backed differentiator (Deprecated-INCLUDED behavior) is +pinned in the integration suite via the deprecated-family flows +through `define_model` and `add_model_family`. This file pins the +pool-None short-circuit at unit tier. +""" + +import pytest + +from cora.equipment.aggregates.family import list_all_family_ids + + +@pytest.mark.unit +async def test_list_all_family_ids_returns_empty_for_none_pool() -> None: + """No-database app_env contract: `pool=None` returns `[]` instead + of raising. Mirrors `list_family_ids`; tests that need a populated + lookup must wire a real pool.""" + assert await list_all_family_ids(None) == [] diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_decider.py b/apps/api/tests/unit/equipment/test_remove_model_family_decider.py index 912eebba6..0e690dc15 100644 --- a/apps/api/tests/unit/equipment/test_remove_model_family_decider.py +++ b/apps/api/tests/unit/equipment/test_remove_model_family_decider.py @@ -3,8 +3,8 @@ Targeted mutation of `Model.declared_families`, not a lifecycle transition. Status is preserved (`Defined` stays `Defined`, `Versioned` stays `Versioned`); only `Deprecated` is rejected via -`ModelCannotVersionError` (Model's general "cannot mutate from -Deprecated" gate, reused by the add/remove family slices). +the per-verb `ModelCannotRemoveFamilyError` (mirrors +`AssetCannotRemoveFamilyError`). Strict-not-idempotent: removing a family not in `declared_families` raises `ModelFamilyNotPresentError`, mirroring the @@ -20,7 +20,7 @@ Manufacturer, ManufacturerName, Model, - ModelCannotVersionError, + ModelCannotRemoveFamilyError, ModelFamilyNotPresentError, ModelFamilyRemoved, ModelName, @@ -83,7 +83,7 @@ def test_decide_emits_model_family_removed_from_versioned_state() -> None: @pytest.mark.unit -def test_decide_raises_cannot_version_when_deprecated() -> None: +def test_decide_raises_cannot_remove_family_when_deprecated() -> None: """Deprecated catalog entries are frozen; remove_model_family rejects.""" existing = uuid4() state = _model( @@ -91,7 +91,7 @@ def test_decide_raises_cannot_version_when_deprecated() -> None: version="v1", declared_families=frozenset({existing}), ) - with pytest.raises(ModelCannotVersionError) as exc_info: + with pytest.raises(ModelCannotRemoveFamilyError) as exc_info: remove_model_family.decide( state=state, command=RemoveModelFamily(model_id=state.id, family_id=existing), diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py b/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py index 3c0506bb6..b42d145e0 100644 --- a/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py @@ -1,9 +1,9 @@ """Property-based tests for `remove_model_family.decide` (Equipment BC). Targeted mutation of `Model.declared_families`; status is preserved -across the mutation and only `Deprecated` is rejected (via the shared -`ModelCannotVersionError` gate). Universal claims across generated -inputs: +across the mutation and only `Deprecated` is rejected (via the +per-verb `ModelCannotRemoveFamilyError` gate). Universal claims +across generated inputs: - state in {Defined, Versioned} + family_id IN declared_families emits exactly one ModelFamilyRemoved with the injected `now` @@ -12,7 +12,7 @@ ModelFamilyNotPresentError carrying the model + family id. - state=None always raises ModelNotFoundError carrying the command's model_id. - - state.status==Deprecated always raises ModelCannotVersionError + - state.status==Deprecated always raises ModelCannotRemoveFamilyError carrying the Deprecated source status. """ @@ -28,7 +28,7 @@ Manufacturer, ManufacturerName, Model, - ModelCannotVersionError, + ModelCannotRemoveFamilyError, ModelFamilyNotPresentError, ModelFamilyRemoved, ModelName, @@ -147,13 +147,13 @@ def test_remove_model_family_on_empty_state_always_raises_not_found( family_id=st.uuids(), now=aware_datetimes(), ) -def test_remove_model_family_on_deprecated_state_always_raises_cannot_version( +def test_remove_model_family_on_deprecated_state_always_raises_cannot_remove_family( model_id: UUID, declared_families: frozenset[UUID], family_id: UUID, now: datetime, ) -> None: - """state.status==Deprecated -> ModelCannotVersionError, regardless of + """state.status==Deprecated -> ModelCannotRemoveFamilyError, regardless of whether family_id would have been a present remove or an absent one.""" state = _model( model_id, @@ -161,7 +161,7 @@ def test_remove_model_family_on_deprecated_state_always_raises_cannot_version( declared_families=declared_families, ) command = RemoveModelFamily(model_id=model_id, family_id=family_id) - with pytest.raises(ModelCannotVersionError) as exc: + with pytest.raises(ModelCannotRemoveFamilyError) as exc: remove_model_family.decide(state=state, command=command, now=now) assert exc.value.model_id == model_id assert exc.value.current_status is ModelStatus.DEPRECATED diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_handler.py b/apps/api/tests/unit/equipment/test_remove_model_family_handler.py index 180dae5b7..72464c236 100644 --- a/apps/api/tests/unit/equipment/test_remove_model_family_handler.py +++ b/apps/api/tests/unit/equipment/test_remove_model_family_handler.py @@ -8,16 +8,16 @@ `declared_families`. The Family may have been deprecated or deleted from the Family registry and removal still proceeds. -The seeding `define_model` call DOES still resolve `list_family_ids` +The seeding `define_model` call DOES still resolve `list_all_family_ids` cross-BC; the unit harness has no Postgres pool, so we monkeypatch that symbol on the `define_model` handler module to a fixed accept- all stub so the seed succeeds. The slice under test imports nothing -from `list_family_ids`. +from `list_all_family_ids`. The Deprecated path seeds a `ModelDeprecated` event directly onto the in-memory store (no `deprecate_model` slice is exercised in this test file), then invokes the handler and expects -`ModelCannotVersionError`. +`ModelCannotRemoveFamilyError`. """ from datetime import UTC, datetime @@ -29,7 +29,7 @@ from cora.equipment.aggregates.model import ( Manufacturer, ManufacturerName, - ModelCannotVersionError, + ModelCannotRemoveFamilyError, ModelDeprecated, ModelFamilyNotPresentError, ModelNotFoundError, @@ -73,7 +73,7 @@ def _patch_seed_known_families( monkeypatch: pytest.MonkeyPatch, family_ids: list[UUID], ) -> None: - """Patch `list_family_ids` only on the `define_model` handler. + """Patch `list_all_family_ids` only on the `define_model` handler. The slice under test (`remove_model_family`) does NOT perform a cross-BC family lookup, so only the seeding `define_model` call @@ -84,7 +84,7 @@ async def _fake_list_family_ids(_pool: object) -> list[UUID]: return list(family_ids) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _fake_list_family_ids, ) @@ -221,7 +221,7 @@ async def test_handler_raises_not_present_on_absent_family( @pytest.mark.unit -async def test_handler_raises_cannot_version_when_deprecated( +async def test_handler_raises_cannot_remove_family_when_deprecated( monkeypatch: pytest.MonkeyPatch, ) -> None: """Deprecated Models cannot accept family removals.""" @@ -230,7 +230,7 @@ async def test_handler_raises_cannot_version_when_deprecated( deps = _build_deps(event_store=store) await _seed_deprecated_model(deps, store) - with pytest.raises(ModelCannotVersionError): + with pytest.raises(ModelCannotRemoveFamilyError): await remove_model_family.bind(deps)( RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), principal_id=_PRINCIPAL_ID, diff --git a/apps/api/tests/unit/equipment/test_version_model_handler.py b/apps/api/tests/unit/equipment/test_version_model_handler.py index b2a3359c6..ff6a129de 100644 --- a/apps/api/tests/unit/equipment/test_version_model_handler.py +++ b/apps/api/tests/unit/equipment/test_version_model_handler.py @@ -64,9 +64,9 @@ def _patch_known_families( monkeypatch: pytest.MonkeyPatch, family_ids: list[UUID], ) -> None: - """Patch `list_family_ids` as imported into the define_model handler. + """Patch `list_all_family_ids` as imported into the define_model handler. - `version_model` does NOT call `list_family_ids`, but the + `version_model` does NOT call `list_all_family_ids`, but the seeding call to `define_model` does. We stub it accept-all so the seed succeeds in the in-memory harness. """ @@ -75,7 +75,7 @@ async def _fake_list_family_ids(_pool: object) -> list[UUID]: return list(family_ids) monkeypatch.setattr( - "cora.equipment.features.define_model.handler.list_family_ids", + "cora.equipment.features.define_model.handler.list_all_family_ids", _fake_list_family_ids, ) From 4e5665fc111884cf1a60f24f98208fb0b49af423 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 06:14:26 +0300 Subject: [PATCH 10/11] chore(equipment): test discipline + wire hygiene (gate-review P1-3/5/6/7/8) Final post-review fix commit. Resolves five P1 findings from the Stage 3 gate review. P1-3: sibling-symmetry architecture fitness was specified in the design memo but not shipped. NEW test_equipment_aggregate_shape.py asserts every non-private aggregate directory under equipment/aggregates/ ships the same core file set (__init__.py, state.py, events.py, evolver.py, read.py). Locks the pattern before the next aggregate clone; passes today against all 5 existing aggregates (family, asset, frame, mount, model). P1-5: list_model_ids shipped untested. NEW test_list_model_ids.py covers the Pool=None branch + NEW test_postgres_list_model_ids.py covers the Deprecated-exclusion filter, canonical sort order, and empty-projection cases against real Postgres. P1-6: event payload tests were round-trip-only (a key rename on both writer + reader sides would pass silently while every historical event becomes unloadable). Extended test_model_events.py with to_payload-only + from_stored-only tests for each of the 5 Model events. Each new test asserts against an explicit dict literal (pins WIRE shape on one side and READ shape on the other); the existing round-trip tests stay for end-to-end defense-in-depth. P1-7: PBT did not exercise trim semantics on bounded-text VOs through the decider boundary. The printable_ascii_text strategy excluded whitespace, so the decider could store command.reason raw instead of ModelDeprecationReason(command.reason).value and the PBT would still pass. Added _padded_text helper strategy + trim-semantics property tests to the 3 PBT files with bounded- text inputs (define_model, version_model, deprecate_model). add_model_family + remove_model_family PBTs carry only UUIDs so no trim assertion applies. P1-8: EquipmentHandlers docstring was stale (said "Two aggregates: Family and Asset") and the 6 new Model fields wedged between Family fields broke the per-aggregate grouping. Updated the docstring to name all 5 aggregates (Family, Model, Asset, Frame, Mount) and reordered fields by aggregate (Family then Model then Asset then Frame then Mount), grouping create-style commands first within each aggregate, then update-style, then queries. Mirrored the reorder in routes.py include_router calls and tools.py register() calls. Behaviour unchanged; purely cosmetic. The wire.py reorder also let us drop wire.py from the EMDASH_ALLOWLIST (the rewritten docstring uses commas/colons instead of the em-dashes that lived in the prior version). Tests: 1846 Model + architecture tests pass, no failures. ruff clean, pyright 0/0/0. With this commit landed, the Stage 3 review verdict flips from NO_GO (P0 blocking) to GO_WITH_NITS: every P0 + P1 finding has been resolved across commits A + B + C. P2 + Watch items remain documented in the gate review output for future followup. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/cora/equipment/routes.py | 22 +- apps/api/src/cora/equipment/tools.py | 45 ++-- apps/api/src/cora/equipment/wire.py | 108 +++++---- .../test_equipment_aggregate_shape.py | 104 ++++++++ .../tests/architecture/test_no_em_dashes.py | 1 - .../test_postgres_list_model_ids.py | 182 ++++++++++++++ .../test_define_model_decider_properties.py | 59 +++++ ...test_deprecate_model_decider_properties.py | 48 ++++ .../unit/equipment/test_list_model_ids.py | 24 ++ .../tests/unit/equipment/test_model_events.py | 227 ++++++++++++++++++ .../test_version_model_decider_properties.py | 65 +++++ 11 files changed, 813 insertions(+), 72 deletions(-) create mode 100644 apps/api/tests/architecture/test_equipment_aggregate_shape.py create mode 100644 apps/api/tests/integration/test_postgres_list_model_ids.py create mode 100644 apps/api/tests/unit/equipment/test_list_model_ids.py diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index c4fd73a92..d418a7e24 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -16,10 +16,9 @@ ## Loop-collapse pattern -Equipment owns multiple aggregates (Family + Asset, with more -slices to come). Three error families share the same response -shape and get collapsed via Trust's `_handle_invalid_name`-style -loop pattern: +Equipment owns five aggregates (Family, Model, Asset, Frame, Mount). +Three error families share the same response shape and get collapsed +via Trust's `_handle_invalid_name`-style loop pattern: - 400 (validation): InvalidFamilyName, InvalidAssetName, InvalidAssetParent @@ -215,18 +214,21 @@ async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONRes def register_equipment_routes(app: FastAPI) -> None: """Attach Equipment slice routers and exception handlers to the FastAPI app.""" + # Family aggregate app.include_router(define_family.router) + app.include_router(version_family.router) + app.include_router(deprecate_family.router) + app.include_router(update_family_settings_schema.router) + app.include_router(get_family.router) + app.include_router(list_families.router) + # Model aggregate app.include_router(define_model.router) app.include_router(version_model.router) app.include_router(deprecate_model.router) app.include_router(add_model_family.router) app.include_router(remove_model_family.router) app.include_router(get_model.router) - app.include_router(get_family.router) - app.include_router(version_family.router) - app.include_router(deprecate_family.router) - app.include_router(update_family_settings_schema.router) - app.include_router(list_families.router) + # Asset aggregate app.include_router(register_asset.router) app.include_router(activate_asset.router) app.include_router(decommission_asset.router) @@ -244,9 +246,11 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(get_asset.router) app.include_router(get_asset_integration_view.router) app.include_router(list_assets.router) + # Frame aggregate app.include_router(register_frame.router) app.include_router(update_frame_placement.router) app.include_router(decommission_frame.router) + # Mount aggregate app.include_router(register_mount.router) app.include_router(update_mount_placement.router) app.include_router(decommission_mount.router) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index f08b0089c..d94d0030c 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -71,10 +71,32 @@ def register_equipment_tools( get_handlers: Callable[[], EquipmentHandlers], ) -> None: """Register every Equipment slice's MCP tool on the FastMCP server.""" + # Family aggregate define_family_tool.register( mcp, get_handler=lambda: get_handlers().define_family, ) + version_family_tool.register( + mcp, + get_handler=lambda: get_handlers().version_family, + ) + deprecate_family_tool.register( + mcp, + get_handler=lambda: get_handlers().deprecate_family, + ) + update_family_settings_schema_tool.register( + mcp, + get_handler=lambda: get_handlers().update_family_settings_schema, + ) + get_family_tool.register( + mcp, + get_handler=lambda: get_handlers().get_family, + ) + list_families_tool.register( + mcp, + get_handler=lambda: get_handlers().list_families, + ) + # Model aggregate define_model_tool.register( mcp, get_handler=lambda: get_handlers().define_model, @@ -95,26 +117,11 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().remove_model_family, ) - get_family_tool.register( - mcp, - get_handler=lambda: get_handlers().get_family, - ) get_model_tool.register( mcp, get_handler=lambda: get_handlers().get_model, ) - version_family_tool.register( - mcp, - get_handler=lambda: get_handlers().version_family, - ) - deprecate_family_tool.register( - mcp, - get_handler=lambda: get_handlers().deprecate_family, - ) - update_family_settings_schema_tool.register( - mcp, - get_handler=lambda: get_handlers().update_family_settings_schema, - ) + # Asset aggregate register_asset_tool.register( mcp, get_handler=lambda: get_handlers().register_asset, @@ -183,10 +190,7 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().list_assets, ) - list_families_tool.register( - mcp, - get_handler=lambda: get_handlers().list_families, - ) + # Frame aggregate register_frame_tool.register( mcp, get_handler=lambda: get_handlers().register_frame, @@ -199,6 +203,7 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().decommission_frame, ) + # Mount aggregate register_mount_tool.register( mcp, get_handler=lambda: get_handlers().register_mount, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index bc3bfe736..a01cdb987 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -7,13 +7,13 @@ on `EquipmentHandlers` and a single line in this factory. Cross-cutting decorators applied here mirror Access / Trust / -Subject (composition order matters — innermost first): +Subject (composition order matters, innermost first): -1. `bind(deps)` — bare handler. -2. `with_idempotency` (create-style commands only) — Idempotency-Key +1. `bind(deps)` bare handler. +2. `with_idempotency` (create-style commands only) Idempotency-Key support. Wrapped before tracing so cache-hits and cache-misses both attribute to the tracing span. -3. `with_tracing` — OTel span around every handler call. Records +3. `with_tracing` OTel span around every handler call. Records `cora.bc`, `cora.command` / `cora.query` attributes. Update-style transitions are not idempotency-wrapped: they're @@ -81,25 +81,41 @@ class EquipmentHandlers: """The Equipment BC's handler bundle, each closed over Kernel. - Two aggregates: `Family` (technique-class catalog; lifecycle - Defined → Versioned → Deprecated) and `Asset` (instance with - hierarchy + lifecycle + family-set + condition + settings + ports). - Genesis commands (`define_family`, `register_asset`) are - idempotency-wrapped; everything else is update-style with bare - Handler protocols. + Five aggregates: + + - `Family`: technique-class catalog (lifecycle Defined, + Versioned, Deprecated) declaring Affordances + settings schema. + - `Model`: manufacturer-specific catalog entry under one or more + Families (lifecycle Defined, Versioned, Deprecated). + - `Asset`: physical or logical instance with hierarchy, lifecycle, + family-set, condition, settings, and typed ports. + - `Frame`: spatial reference frame anchored to a root surface with + a 6-DoF Placement. + - `Mount`: a slot on a Frame that can receive at most one Asset + via install / uninstall. + + Genesis commands (`define_family`, `define_model`, `register_asset`, + `register_frame`, `register_mount`) are idempotency-wrapped; + everything else is update-style with bare Handler protocols. """ + # Family aggregate define_family: define_family.IdempotentHandler + version_family: version_family.Handler + deprecate_family: deprecate_family.Handler + update_family_settings_schema: update_family_settings_schema.Handler + get_family: get_family.Handler + list_families: list_families.Handler + + # Model aggregate define_model: define_model.IdempotentHandler version_model: version_model.Handler deprecate_model: deprecate_model.Handler add_model_family: add_model_family.Handler remove_model_family: remove_model_family.Handler get_model: get_model.Handler - get_family: get_family.Handler - version_family: version_family.Handler - deprecate_family: deprecate_family.Handler - update_family_settings_schema: update_family_settings_schema.Handler + + # Asset aggregate register_asset: register_asset.IdempotentHandler activate_asset: activate_asset.Handler decommission_asset: decommission_asset.Handler @@ -117,10 +133,13 @@ class EquipmentHandlers: get_asset: get_asset.Handler get_asset_integration_view: get_asset_integration_view.Handler list_assets: list_assets.Handler - list_families: list_families.Handler + + # Frame aggregate register_frame: register_frame.IdempotentHandler update_frame_placement: update_frame_placement.Handler decommission_frame: decommission_frame.Handler + + # Mount aggregate register_mount: register_mount.IdempotentHandler update_mount_placement: update_mount_placement.Handler decommission_mount: decommission_mount.Handler @@ -131,6 +150,7 @@ class EquipmentHandlers: def wire_equipment(deps: Kernel) -> EquipmentHandlers: """Build the Equipment BC handlers from shared dependencies.""" return EquipmentHandlers( + # Family aggregate define_family=with_tracing( with_idempotency( define_family.bind(deps), @@ -145,6 +165,34 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DefineFamily", bc=_BC, ), + version_family=with_tracing( + version_family.bind(deps), + command_name="VersionFamily", + bc=_BC, + ), + deprecate_family=with_tracing( + deprecate_family.bind(deps), + command_name="DeprecateFamily", + bc=_BC, + ), + update_family_settings_schema=with_tracing( + update_family_settings_schema.bind(deps), + command_name="UpdateFamilySettingsSchema", + bc=_BC, + ), + get_family=with_tracing( + get_family.bind(deps), + command_name="GetFamily", + bc=_BC, + kind="query", + ), + list_families=with_tracing( + list_families.bind(deps), + command_name="ListFamilies", + bc=_BC, + kind="query", + ), + # Model aggregate define_model=with_tracing( with_idempotency( define_model.bind(deps), @@ -183,27 +231,7 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: bc=_BC, kind="query", ), - get_family=with_tracing( - get_family.bind(deps), - command_name="GetFamily", - bc=_BC, - kind="query", - ), - version_family=with_tracing( - version_family.bind(deps), - command_name="VersionFamily", - bc=_BC, - ), - deprecate_family=with_tracing( - deprecate_family.bind(deps), - command_name="DeprecateFamily", - bc=_BC, - ), - update_family_settings_schema=with_tracing( - update_family_settings_schema.bind(deps), - command_name="UpdateFamilySettingsSchema", - bc=_BC, - ), + # Asset aggregate register_asset=with_tracing( with_idempotency( register_asset.bind(deps), @@ -299,12 +327,7 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: bc=_BC, kind="query", ), - list_families=with_tracing( - list_families.bind(deps), - command_name="ListFamilies", - bc=_BC, - kind="query", - ), + # Frame aggregate register_frame=with_tracing( with_idempotency( register_frame.bind(deps), @@ -327,6 +350,7 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DecommissionFrame", bc=_BC, ), + # Mount aggregate register_mount=with_tracing( with_idempotency( register_mount.bind(deps), diff --git a/apps/api/tests/architecture/test_equipment_aggregate_shape.py b/apps/api/tests/architecture/test_equipment_aggregate_shape.py new file mode 100644 index 000000000..a19ca42b4 --- /dev/null +++ b/apps/api/tests/architecture/test_equipment_aggregate_shape.py @@ -0,0 +1,104 @@ +"""Pin: every Equipment BC aggregate directory carries the same core file set. + +Background: the Equipment BC now ships five aggregates (Family, +Asset, Frame, Mount, Model). Earlier aggregates accreted siblings +unevenly (`affordance.py` on Family, `settings_validation.py` on +Family + Asset, `read.py` added late to Model in Commit B). Each +clone reopened the same questions about which files are +load-bearing versus optional. This fitness locks the load-bearing +set so the next clone starts from a checklist rather than a survey. + +Rule: every non-private aggregate directory under +`apps/api/src/cora/equipment/aggregates/` MUST track these five +files: + + - __init__.py + - state.py + - events.py + - evolver.py + - read.py + +Optional siblings (allowed but not required) include +`affordance.py`, `settings_validation.py`, or other helpers that +arise from a single aggregate's needs. The fitness deliberately +does not enumerate optional files: the goal is to lock the +*minimum* shared shape, not to forbid divergence above it. + +`_drawing.py` and `_placement.py` (private modules at the +`aggregates/` package root) are not aggregate directories and are +ignored. Anything starting with `_` or named `__pycache__` is +skipped , the same convention `BC root layout (flat)` uses for +private helpers. + +Enumeration is git-aware via `tracked_python_files()` per the +worktree pre-commit-stash rationale in `conftest.py`: untracked +half-staged files must stay invisible to this scan, otherwise +in-flight aggregate skeletons would false-fail before the author +finishes wiring them up. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.architecture.conftest import CORA_ROOT, tracked_python_files + +if TYPE_CHECKING: + from pathlib import Path + +_AGGREGATES_ROOT = CORA_ROOT / "equipment" / "aggregates" + +_REQUIRED_FILES: tuple[str, ...] = ( + "__init__.py", + "state.py", + "events.py", + "evolver.py", + "read.py", +) + + +def _aggregate_dirs() -> list[Path]: + """Aggregate directories under `equipment/aggregates/`. + + A directory qualifies when: + - it sits directly under `aggregates/`, + - its name does not start with `_` (private modules like + `_placement.py` / `_drawing.py` are not aggregates), and + - it tracks an `__init__.py` (so the scan stays git-aware). + + Returns directories sorted by name for stable parametrize ids. + """ + tracked = tracked_python_files() + dirs: set[Path] = set() + for path in tracked: + try: + rel = path.relative_to(_AGGREGATES_ROOT) + except ValueError: + continue + if len(rel.parts) < 2: + continue + aggregate_name = rel.parts[0] + if aggregate_name.startswith("_") or aggregate_name == "__pycache__": + continue + if path.name == "__init__.py" and path.parent.parent == _AGGREGATES_ROOT: + dirs.add(path.parent) + return sorted(dirs) + + +@pytest.mark.architecture +@pytest.mark.parametrize("aggregate_dir", _aggregate_dirs(), ids=lambda p: p.name) +def test_equipment_aggregate_carries_required_files(aggregate_dir: Path) -> None: + """Equipment aggregate must track every file in `_REQUIRED_FILES`.""" + tracked = tracked_python_files() + missing = [name for name in _REQUIRED_FILES if (aggregate_dir / name) not in tracked] + assert not missing, ( + f"equipment aggregate `{aggregate_dir.name}` is missing required " + f"file(s): {', '.join(missing)}.\n" + f"Every aggregate under {_AGGREGATES_ROOT.relative_to(CORA_ROOT.parent.parent)} " + f"must track: {', '.join(_REQUIRED_FILES)}.\n" + "Add the missing module(s) before merging, or, if the aggregate is " + "genuinely a different shape, justify the divergence in the BC " + "module doc and update this fitness." + ) diff --git a/apps/api/tests/architecture/test_no_em_dashes.py b/apps/api/tests/architecture/test_no_em_dashes.py index 2aed5d1bd..e031e2a50 100644 --- a/apps/api/tests/architecture/test_no_em_dashes.py +++ b/apps/api/tests/architecture/test_no_em_dashes.py @@ -164,7 +164,6 @@ "src/cora/equipment/features/version_family/decider.py", "src/cora/equipment/features/version_family/handler.py", "src/cora/equipment/routes.py", - "src/cora/equipment/wire.py", "src/cora/infrastructure/adapters/introspection_token_verifier.py", "src/cora/infrastructure/adapters/jwt_token_verifier.py", "src/cora/infrastructure/adapters/postgres_event_store.py", diff --git a/apps/api/tests/integration/test_postgres_list_model_ids.py b/apps/api/tests/integration/test_postgres_list_model_ids.py new file mode 100644 index 000000000..0eecfcef7 --- /dev/null +++ b/apps/api/tests/integration/test_postgres_list_model_ids.py @@ -0,0 +1,182 @@ +"""End-to-end: `list_model_ids` against `proj_equipment_model_summary`. + +Pins the two SQL-tier contracts the read function carries: + + - Deprecated Models are excluded (`WHERE status <> 'Deprecated'`), + so future cross-BC candidate-enumeration callers do not surface + Deprecated Models as bindable sources. + - Returned ids are sorted by `model_id::text` ascending, so the + list is deterministic across calls regardless of insert order. + +Plus the empty-projection arm: zero rows in the summary projection +yields `[]`, matching the `pool=None` short-circuit pinned at unit +tier in `tests/unit/equipment/test_list_model_ids.py`. + +Sibling Kernel + pg_pool fixture pattern from +`test_postgres_model_summary_projection.py`. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + list_model_ids, +) +from cora.equipment.features import ( + define_family, + define_model, + deprecate_model, +) +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +@pytest.mark.integration +async def test_list_model_ids_excludes_deprecated_models( + db_pool: asyncpg.Pool, +) -> None: + """Seed 3 Models, deprecate 1, drain: `list_model_ids` returns the + 2 non-Deprecated ids in `model_id::text`-sorted ascending order. + Pins the `WHERE status <> 'Deprecated'` filter.""" + family_id = UUID("01900000-0000-7000-8000-0000000cd001") + family_event_id = UUID("01900000-0000-7000-8000-0000000cd00e") + model_a_id = UUID("01900000-0000-7000-8000-0000000cd0a1") + model_a_event_id = UUID("01900000-0000-7000-8000-0000000cd0ae") + model_b_id = UUID("01900000-0000-7000-8000-0000000cd0a2") + model_b_event_id = UUID("01900000-0000-7000-8000-0000000cd0af") + model_c_id = UUID("01900000-0000-7000-8000-0000000cd0a3") + model_c_event_id = UUID("01900000-0000-7000-8000-0000000cd0b0") + deprecate_event_id = UUID("01900000-0000-7000-8000-0000000cd0b1") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_a_id, + model_a_event_id, + model_b_id, + model_b_event_id, + model_c_id, + model_c_event_id, + deprecate_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + for name, part_number in ( + ("Aerotech ANT130-L", "ANT130-L"), + ("Aerotech ANT130-LZS", "ANT130-LZS"), + ("Aerotech ANT95-L", "ANT95-L"), + ): + await define_model.bind(deps)( + DefineModel( + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_b_id, reason="superseded"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + ids = await list_model_ids(db_pool) + expected = sorted([model_a_id, model_c_id], key=lambda u: str(u)) + assert ids == expected + + +@pytest.mark.integration +async def test_list_model_ids_returns_models_in_canonical_sort_order( + db_pool: asyncpg.Pool, +) -> None: + """Seed 3 Models, drain: `list_model_ids` returns ids sorted by + `model_id::text` ascending regardless of insert order. Pins the + `ORDER BY model_id::text` clause.""" + family_id = UUID("01900000-0000-7000-8000-0000000cd101") + family_event_id = UUID("01900000-0000-7000-8000-0000000cd10e") + # Insert order (c, a, b) differs from text-sort order (a, b, c) + # so an accidental "natural insertion order" implementation would + # not pass. + model_a_id = UUID("01900000-0000-7000-8000-0000000cd1a1") + model_a_event_id = UUID("01900000-0000-7000-8000-0000000cd1ae") + model_b_id = UUID("01900000-0000-7000-8000-0000000cd1a2") + model_b_event_id = UUID("01900000-0000-7000-8000-0000000cd1af") + model_c_id = UUID("01900000-0000-7000-8000-0000000cd1a3") + model_c_event_id = UUID("01900000-0000-7000-8000-0000000cd1b0") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_c_id, + model_c_event_id, + model_a_id, + model_a_event_id, + model_b_id, + model_b_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + for name, part_number in ( + ("Aerotech ANT95-L", "ANT95-L"), + ("Aerotech ANT130-L", "ANT130-L"), + ("Aerotech ANT130-LZS", "ANT130-LZS"), + ): + await define_model.bind(deps)( + DefineModel( + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + ids = await list_model_ids(db_pool) + expected = sorted([model_a_id, model_b_id, model_c_id], key=lambda u: str(u)) + assert ids == expected + + +@pytest.mark.integration +async def test_list_model_ids_returns_empty_when_no_models( + db_pool: asyncpg.Pool, +) -> None: + """Empty projection: `list_model_ids` returns `[]`. Matches the + `pool=None` short-circuit pinned at unit tier.""" + assert await list_model_ids(db_pool) == [] diff --git a/apps/api/tests/unit/equipment/test_define_model_decider_properties.py b/apps/api/tests/unit/equipment/test_define_model_decider_properties.py index 74b2bf973..51de19f46 100644 --- a/apps/api/tests/unit/equipment/test_define_model_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_define_model_decider_properties.py @@ -90,6 +90,25 @@ def _invalid_bounded_text(max_length: int) -> st.SearchStrategy[str]: ) +def _padded_text(inner_strategy: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + """Wrap an inner text strategy in random leading + trailing whitespace. + + Distinguishes "VO trims at construction" from "decider stores raw + command text": if the emitted event payload still carries the + untrimmed wrapper, the decider is leaking `command.` instead + of the VO's `.value`. + """ + + @st.composite + def build(draw: st.DrawFn) -> str: + leading = draw(st.text(alphabet=" \t\n", max_size=10)) + core = draw(inner_strategy) + trailing = draw(st.text(alphabet=" \t\n", max_size=10)) + return leading + core + trailing + + return build() + + @st.composite def _manufacturers(draw: st.DrawFn) -> Manufacturer: """Build a Manufacturer VO with optional paired identifier + type. @@ -335,6 +354,46 @@ def test_define_model_with_invalid_version_tag_always_raises( define_model.decide(state=None, command=command, now=now, new_id=new_id) +@pytest.mark.unit +@given( + name=_padded_text(_NAME), + manufacturer=_manufacturers(), + part_number=_padded_text(_PART_NUMBER), + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_event_carries_trimmed_name_and_part_number( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Padded input -> ModelDefined.name / .part_number carry the trimmed + value, never the raw command string with leading or trailing whitespace. + + Closes a coverage gap in printable_ascii_text (which excludes + whitespace): without this property, the decider could emit + `command.name` raw and still pass every other PBT in this module. + """ + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = define_model.decide(state=None, command=command, now=now, new_id=new_id) + assert len(events) == 1 + event = events[0] + assert event.name == event.name.strip() + assert event.part_number == event.part_number.strip() + + @pytest.mark.unit @given( name=_NAME, diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py b/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py index 4015c4e2c..0ac4e9137 100644 --- a/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py @@ -67,6 +67,25 @@ def _invalid_reason() -> st.SearchStrategy[str]: ) +def _padded_text(inner_strategy: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + """Wrap an inner text strategy in random leading + trailing whitespace. + + Distinguishes "VO trims at construction" from "decider stores raw + command text": if the emitted event payload still carries the + untrimmed wrapper, the decider is leaking `command.` instead + of the VO's `.value`. + """ + + @st.composite + def build(draw: st.DrawFn) -> str: + leading = draw(st.text(alphabet=" \t\n", max_size=10)) + core = draw(inner_strategy) + trailing = draw(st.text(alphabet=" \t\n", max_size=10)) + return leading + core + trailing + + return build() + + def _model(model_id: UUID, *, status: ModelStatus) -> Model: return Model( id=model_id, @@ -164,6 +183,35 @@ def test_deprecate_model_with_invalid_reason_always_raises( deprecate_model.decide(state=state, command=command, now=now) +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_padded_text(_REASON), + now=aware_datetimes(), +) +def test_deprecate_model_event_carries_trimmed_reason( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Padded input -> ModelDeprecated.reason carries the trimmed value, + never the raw command string with leading or trailing whitespace. + + Closes a coverage gap in printable_ascii_text (which excludes + whitespace): without this property, the decider could emit + `command.reason` raw instead of `ModelDeprecationReason(...).value` + and still pass every other PBT in this module. + """ + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + events = deprecate_model.decide(state=state, command=command, now=now) + assert len(events) == 1 + event = events[0] + assert event.reason == event.reason.strip() + + @pytest.mark.unit @given( model_id=st.uuids(), diff --git a/apps/api/tests/unit/equipment/test_list_model_ids.py b/apps/api/tests/unit/equipment/test_list_model_ids.py new file mode 100644 index 000000000..0db3ac3fd --- /dev/null +++ b/apps/api/tests/unit/equipment/test_list_model_ids.py @@ -0,0 +1,24 @@ +"""Unit tests for `list_model_ids`. + +Discovery-side helper that reads every non-Deprecated Model id from +the `proj_equipment_model_summary` projection. Mirrors +`list_family_ids`: returns `[]` when `pool is None` so the +no-database app_env (and unit tests that do not wire a pool) do not +need a defensive None-check at every call site. + +The Deprecated-excluded behavior and the canonical +`model_id::text`-ascending sort order are pinned in the integration +suite at `tests/integration/test_postgres_list_model_ids.py`. +""" + +import pytest + +from cora.equipment.aggregates.model import list_model_ids + + +@pytest.mark.unit +async def test_list_model_ids_returns_empty_list_when_pool_is_none() -> None: + """No-database app_env contract: `pool=None` returns `[]` instead + of raising. Mirrors `list_family_ids`; tests that need a populated + lookup must wire a real pool.""" + assert await list_model_ids(None) == [] diff --git a/apps/api/tests/unit/equipment/test_model_events.py b/apps/api/tests/unit/equipment/test_model_events.py index 2a690ceeb..da0556483 100644 --- a/apps/api/tests/unit/equipment/test_model_events.py +++ b/apps/api/tests/unit/equipment/test_model_events.py @@ -66,6 +66,77 @@ def test_model_defined_round_trips_with_minimal_manufacturer() -> None: assert restored == event +@pytest.mark.unit +def test_model_defined_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape: explicit dict literal catches key renames on + the to_payload side that a round-trip would mask.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + event = ModelDefined( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + version_tag="rev-A", + ) + assert to_payload(event) == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/05gvnxz63", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "occurred_at": "2026-06-01T12:00:00+00:00", + "version_tag": "rev-A", + } + + +@pytest.mark.unit +def test_model_defined_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape: explicit dict literal catches key renames on + the from_stored side that a round-trip would mask.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/05gvnxz63", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "occurred_at": "2026-06-01T12:00:00+00:00", + "version_tag": "rev-A", + } + rebuilt = from_stored(_stored("ModelDefined", payload)) + assert rebuilt == ModelDefined( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + version_tag="rev-A", + ) + + @pytest.mark.unit def test_model_defined_round_trips_with_full_manufacturer_and_version_tag() -> None: event = ModelDefined( @@ -121,6 +192,59 @@ def test_model_versioned_round_trips() -> None: assert restored == event +@pytest.mark.unit +def test_model_versioned_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelVersioned: every key + every value.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + event = ModelVersioned( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + version_tag="rev-B", + occurred_at=datetime(2026, 6, 1, 13, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "version_tag": "rev-B", + "occurred_at": "2026-06-01T13:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_versioned_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelVersioned.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "version_tag": "rev-B", + "occurred_at": "2026-06-01T13:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelVersioned", payload)) + assert rebuilt == ModelVersioned( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + version_tag="rev-B", + occurred_at=datetime(2026, 6, 1, 13, 0, tzinfo=UTC), + ) + + @pytest.mark.unit def test_model_deprecated_round_trips() -> None: event = ModelDeprecated( @@ -133,6 +257,39 @@ def test_model_deprecated_round_trips() -> None: assert restored == event +@pytest.mark.unit +def test_model_deprecated_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelDeprecated.""" + model_id = uuid4() + event = ModelDeprecated( + model_id=model_id, + reason="Vendor end-of-life announcement 2026-05-28", + occurred_at=datetime(2026, 6, 1, 14, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "reason": "Vendor end-of-life announcement 2026-05-28", + "occurred_at": "2026-06-01T14:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_deprecated_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelDeprecated.""" + model_id = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "reason": "Vendor end-of-life announcement 2026-05-28", + "occurred_at": "2026-06-01T14:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelDeprecated", payload)) + assert rebuilt == ModelDeprecated( + model_id=model_id, + reason="Vendor end-of-life announcement 2026-05-28", + occurred_at=datetime(2026, 6, 1, 14, 0, tzinfo=UTC), + ) + + @pytest.mark.unit def test_model_family_added_round_trips() -> None: event = ModelFamilyAdded( @@ -145,6 +302,41 @@ def test_model_family_added_round_trips() -> None: assert restored == event +@pytest.mark.unit +def test_model_family_added_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelFamilyAdded.""" + model_id = uuid4() + family_id = uuid4() + event = ModelFamilyAdded( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 15, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T15:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_family_added_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelFamilyAdded.""" + model_id = uuid4() + family_id = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T15:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelFamilyAdded", payload)) + assert rebuilt == ModelFamilyAdded( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 15, 0, tzinfo=UTC), + ) + + @pytest.mark.unit def test_model_family_removed_round_trips() -> None: event = ModelFamilyRemoved( @@ -157,6 +349,41 @@ def test_model_family_removed_round_trips() -> None: assert restored == event +@pytest.mark.unit +def test_model_family_removed_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelFamilyRemoved.""" + model_id = uuid4() + family_id = uuid4() + event = ModelFamilyRemoved( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 16, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T16:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_family_removed_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelFamilyRemoved.""" + model_id = uuid4() + family_id = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T16:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelFamilyRemoved", payload)) + assert rebuilt == ModelFamilyRemoved( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 16, 0, tzinfo=UTC), + ) + + @pytest.mark.unit def test_from_stored_rejects_unknown_event_type() -> None: with pytest.raises(ValueError, match="Unknown ModelEvent event_type"): diff --git a/apps/api/tests/unit/equipment/test_version_model_decider_properties.py b/apps/api/tests/unit/equipment/test_version_model_decider_properties.py index 197c48032..101a24d54 100644 --- a/apps/api/tests/unit/equipment/test_version_model_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_version_model_decider_properties.py @@ -88,6 +88,25 @@ def _invalid_bounded_text(max_length: int) -> st.SearchStrategy[str]: ) +def _padded_text(inner_strategy: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + """Wrap an inner text strategy in random leading + trailing whitespace. + + Distinguishes "VO trims at construction" from "decider stores raw + command text": if the emitted event payload still carries the + untrimmed wrapper, the decider is leaking `command.` instead + of the VO's `.value`. + """ + + @st.composite + def build(draw: st.DrawFn) -> str: + leading = draw(st.text(alphabet=" \t\n", max_size=10)) + core = draw(inner_strategy) + trailing = draw(st.text(alphabet=" \t\n", max_size=10)) + return leading + core + trailing + + return build() + + @st.composite def _manufacturers(draw: st.DrawFn) -> Manufacturer: """Build a Manufacturer VO with optional paired identifier + type.""" @@ -385,6 +404,52 @@ def test_version_model_with_invalid_version_tag_always_raises( version_model.decide(state=state, command=command, now=now) +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_padded_text(_NAME), + manufacturer=_manufacturers(), + part_number=_padded_text(_PART_NUMBER), + declared_families=_DECLARED_FAMILIES, + version_tag=_padded_text(_VERSION_TAG), + now=aware_datetimes(), +) +def test_version_model_event_carries_trimmed_name_part_number_and_version_tag( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Padded input -> ModelVersioned.name / .part_number / .version_tag + carry the trimmed value, never the raw command string with leading + or trailing whitespace. + + Closes a coverage gap in printable_ascii_text (which excludes + whitespace): without this property, the decider could emit raw + `command.` and still pass every other PBT in this module. + """ + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = version_model.decide(state=state, command=command, now=now) + assert len(events) == 1 + event = events[0] + assert event.name == event.name.strip() + assert event.part_number == event.part_number.strip() + assert event.version_tag == event.version_tag.strip() + + @pytest.mark.unit @given( model_id=st.uuids(), From b5c8806e688a18a8008e802d5bcab31d3869a737 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 10:21:09 +0300 Subject: [PATCH 11/11] chore(equipment): catch up Asset.family_ids rename in Model state docstrings Post-rebase fixup. While this PR was open, main shipped c47c7d3e8 "refactor(naming): rename Asset.families to Asset.family_ids". The Model aggregate's state.py docstrings referenced the old field name in two places (the cross-BC subset invariant explanation at line 17, and the aggregate-level Why text at line 448). Both are documentation-only; the deferred Asset.model_id slice (Stage 1 memo locked at project_asset_model_binding_design.md) will use the new name. No source code references state.families or asset.families because the Model BC reads from the Family read repo (list_all_family_ids), not from Asset state. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/cora/equipment/aggregates/model/state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/cora/equipment/aggregates/model/state.py b/apps/api/src/cora/equipment/aggregates/model/state.py index c4499ef74..c6bed800b 100644 --- a/apps/api/src/cora/equipment/aggregates/model/state.py +++ b/apps/api/src/cora/equipment/aggregates/model/state.py @@ -14,7 +14,7 @@ ANT130-L rotary stage is one Model; the two PCO Edge 5.5 cameras mounted at 2-BM share a single Model. Asset gains an optional `model_id` pointer; if set, `Model.declared_families` must be a -subset of `Asset.families` at `register_asset` and `add_asset_family` +subset of `Asset.family_ids` at `register_asset` and `add_asset_family` time (cross-BC subset invariant). `declared_families: frozenset[UUID]` is REQUIRED at `define_model` @@ -445,7 +445,7 @@ class Model: `version_model` (replace-on-version). Cross-BC subset invariant `Model.declared_families subset-of - Asset.families` evaluated by the Asset BC at `register_asset` and + Asset.family_ids` evaluated by the Asset BC at `register_asset` and `add_asset_family`; NOT enforced inside the Model aggregate. """