From 14f28d3c0db8b526f52468a3db72f36321122b3a Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 13:36:23 +0300 Subject: [PATCH 1/5] refactor(equipment): rename AssetLevel.ASSEMBLY to COMPONENT Frees the "Assembly" token for the incoming Assembly aggregate (Equipment BC's 5th aggregate, the composition blueprint). Renames the StrEnum member, rewrites the at-rest CHECK constraint via a forward-only Atlas migration (drop + UPDATE + add), and aligns the surface vocabulary across REST routes, MCP tool descriptions, contract tests, the Asset aggregate and decider docstrings, the 2-BM MCTOptics scenario prose, and the glossary. Adds an architecture fitness with three checks (symbolic-in-source, symbolic-in-tests, bare-literal-with-allow-list) so the freed token cannot silently re-land. UNIT was already taken as the ISA-88 tier above Assembly, so COMPONENT is the chosen target per the Assembly aggregate naming lock. Greenfield posture: no from_stored alias; pre-rename events do not exist outside local test runs. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 12 +- .../cora/equipment/aggregates/asset/state.py | 14 +- .../equipment/features/list_assets/query.py | 2 +- .../equipment/features/list_assets/tool.py | 2 +- .../features/register_asset/decider.py | 4 +- .../features/register_asset/route.py | 4 +- .../equipment/features/register_asset/tool.py | 2 +- .../test_no_assembly_asset_level_literal.py | 151 ++++++++++++++++++ .../tests/contract/test_assets_endpoint.py | 4 +- .../contract/test_list_assets_endpoint.py | 2 +- .../scenarios/test_2bm_mctoptics_setup.py | 2 +- apps/api/tests/unit/equipment/test_asset.py | 4 +- .../tests/unit/equipment/test_asset_events.py | 28 ++-- .../test_asset_summary_projection.py | 6 +- .../equipment/test_register_asset_decider.py | 6 +- .../test_register_asset_decider_properties.py | 2 +- docs/architecture/modules/equipment/index.md | 6 +- docs/architecture/standards.md | 2 +- docs/deployments/2-bm/assets.md | 6 +- docs/reference/glossary.md | 4 +- ...name_asset_level_assembly_to_component.sql | 52 ++++++ infra/atlas/migrations/atlas.sum | 13 +- 22 files changed, 266 insertions(+), 62 deletions(-) create mode 100644 apps/api/tests/architecture/test_no_assembly_asset_level_literal.py create mode 100644 infra/atlas/migrations/20260602000000_rename_asset_level_assembly_to_component.sql diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 298655cd9..ee5f46df2 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1098,13 +1098,13 @@ "type": "object" }, "AssetLevel": { - "description": "The hierarchical level of an Asset.\n\nPer the BC map (ISA-88-derived, single-word convention):\n - `Enterprise`: root; the institution itself\n - `Site`: a facility (for example, APS)\n - `Area`: a section of a site (for example, the experimental hall)\n - `Unit`: an operational unit (for example, a beamline)\n - `Assembly`: a composed component (ISA-88 \"Equipment Module\")\n - `Device`: an addressable control surface (ISA-88 \"Control Module\")\n\nCommon pattern is the strict ordering above, but Device-in-Device\nis allowed when reality demands it (smart instruments). Levels\nare conventional, not enforced: the decider does not check that\na Device's parent is an Assembly.", + "description": "The hierarchical level of an Asset.\n\nPer the BC map (ISA-88-derived, single-word convention):\n - `Enterprise`: root; the institution itself\n - `Site`: a facility (for example, APS)\n - `Area`: a section of a site (for example, the experimental hall)\n - `Unit`: an operational unit (for example, a beamline)\n - `Component`: a composed sub-system (ISA-88 \"Equipment Module\" tier)\n - `Device`: an addressable control surface (ISA-88 \"Control Module\")\n\nCommon pattern is the strict ordering above, but Device-in-Device\nis allowed when reality demands it (smart instruments). Levels\nare conventional, not enforced: the decider does not check that\na Device's parent is a Component.", "enum": [ "Enterprise", "Site", "Area", "Unit", - "Assembly", + "Component", "Device" ], "title": "AssetLevel", @@ -1253,7 +1253,7 @@ "Site", "Area", "Unit", - "Assembly", + "Component", "Device" ], "title": "Level", @@ -7783,7 +7783,7 @@ "type": "object" }, "RegisterAssetRequest": { - "description": "Body for `POST /assets`.\n\n`level` accepts the StrEnum's PascalCase string values\n(\"Enterprise\" / \"Site\" / \"Area\" / \"Unit\" / \"Assembly\" /\n\"Device\"); Pydantic rejects unknowns with 422.\n\n`parent_id` is required for non-Enterprise levels and must be\nnull for Enterprise; that's a domain invariant enforced by\nthe decider (raises InvalidAssetParentError \u2192 400), not by\nPydantic, since the rule is conditional on `level`.", + "description": "Body for `POST /assets`.\n\n`level` accepts the StrEnum's PascalCase string values\n(\"Enterprise\" / \"Site\" / \"Area\" / \"Unit\" / \"Component\" /\n\"Device\"); Pydantic rejects unknowns with 422.\n\n`parent_id` is required for non-Enterprise levels and must be\nnull for Enterprise; that's a domain invariant enforced by\nthe decider (raises InvalidAssetParentError \u2192 400), not by\nPydantic, since the rule is conditional on `level`.", "properties": { "alternate_identifiers": { "anyOf": [ @@ -7813,7 +7813,7 @@ }, "level": { "$ref": "#/components/schemas/AssetLevel", - "description": "Hierarchical level. One of: Enterprise (root, requires null parent_id), Site, Area, Unit, Assembly, Device." + "description": "Hierarchical level. One of: Enterprise (root, requires null parent_id), Site, Area, Unit, Component, Device." }, "model_id": { "anyOf": [ @@ -13492,7 +13492,7 @@ "Site", "Area", "Unit", - "Assembly", + "Component", "Device" ], "type": "string" diff --git a/apps/api/src/cora/equipment/aggregates/asset/state.py b/apps/api/src/cora/equipment/aggregates/asset/state.py index 05949ffd0..9542f03d6 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/state.py +++ b/apps/api/src/cora/equipment/aggregates/asset/state.py @@ -3,7 +3,7 @@ `Asset` is the physical equipment instance: a beamline, a detector, a sample changer, an HPC node. Hierarchical via `parent_id` (forms a tree, NOT a DAG — single-parent rule per BC map). Carries a -`level` discriminator (Enterprise / Site / Area / Unit / Assembly / +`level` discriminator (Enterprise / Site / Area / Unit / Component / Device, ISA-88-derived), a `lifecycle` FSM (Commissioned -> Active -> Maintenance -> Decommissioned), a `condition` enum (Nominal / Degraded / Faulted, 5g-b: orthogonal to @@ -26,7 +26,7 @@ Per the BC map: - `Enterprise` is the root level — `parent_id` MUST be null. - - All other levels (Site / Area / Unit / Assembly / Device) MUST + - All other levels (Site / Area / Unit / Component / Device) MUST have a `parent_id`. Eventual-consistency stance for the parent ref: the decider does @@ -38,7 +38,7 @@ structurally (one `parent_id` field, can't be a list). **Levels are conventional, not enforced** per the BC map: the -decider does NOT check that a Device's parent is an Assembly. +decider does NOT check that a Device's parent is a Component. `Device`-in-`Device` is allowed when reality demands it (smart instruments with addressable sub-modules). @@ -84,20 +84,20 @@ class AssetLevel(StrEnum): - `Site`: a facility (for example, APS) - `Area`: a section of a site (for example, the experimental hall) - `Unit`: an operational unit (for example, a beamline) - - `Assembly`: a composed component (ISA-88 "Equipment Module") + - `Component`: a composed sub-system (ISA-88 "Equipment Module" tier) - `Device`: an addressable control surface (ISA-88 "Control Module") Common pattern is the strict ordering above, but Device-in-Device is allowed when reality demands it (smart instruments). Levels are conventional, not enforced: the decider does not check that - a Device's parent is an Assembly. + a Device's parent is a Component. """ ENTERPRISE = "Enterprise" SITE = "Site" AREA = "Area" UNIT = "Unit" - ASSEMBLY = "Assembly" + COMPONENT = "Component" DEVICE = "Device" @@ -408,7 +408,7 @@ class InvalidAssetParentError(ValueError): - Enterprise-level Asset supplied a non-null `parent_id` (Enterprise is the root; cannot have a parent) - Non-Enterprise-level Asset supplied a null `parent_id` - (Site / Area / Unit / Assembly / Device must have a parent) + (Site / Area / Unit / Component / Device must have a parent) Eventual-consistency stance: this decider rule does NOT check that the referenced parent Asset exists. Cycle detection diff --git a/apps/api/src/cora/equipment/features/list_assets/query.py b/apps/api/src/cora/equipment/features/list_assets/query.py index 1c8c239d8..85be80565 100644 --- a/apps/api/src/cora/equipment/features/list_assets/query.py +++ b/apps/api/src/cora/equipment/features/list_assets/query.py @@ -15,7 +15,7 @@ "Site", "Area", "Unit", - "Assembly", + "Component", "Device", ] diff --git a/apps/api/src/cora/equipment/features/list_assets/tool.py b/apps/api/src/cora/equipment/features/list_assets/tool.py index 185bc6946..5f8ea8831 100644 --- a/apps/api/src/cora/equipment/features/list_assets/tool.py +++ b/apps/api/src/cora/equipment/features/list_assets/tool.py @@ -43,7 +43,7 @@ def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: name="list_assets", description=( "Cursor-paginated list of assets. Optional filters: " - "level (Enterprise/Site/Area/Unit/Assembly/Device), " + "level (Enterprise/Site/Area/Unit/Component/Device), " "lifecycle (Commissioned/Active/Maintenance/Decommissioned), " "parent_id (direct-children-of). Pass `cursor` from a " "previous page's `next_cursor` to fetch the next page." diff --git a/apps/api/src/cora/equipment/features/register_asset/decider.py b/apps/api/src/cora/equipment/features/register_asset/decider.py index 1699b37ff..1c52aad9c 100644 --- a/apps/api/src/cora/equipment/features/register_asset/decider.py +++ b/apps/api/src/cora/equipment/features/register_asset/decider.py @@ -11,7 +11,7 @@ Per the BC map's hierarchy semantics: - `Enterprise` is the root level — `parent_id` MUST be null. - - All other levels (Site / Area / Unit / Assembly / Device) + - All other levels (Site / Area / Unit / Component / Device) MUST have a non-null `parent_id`. Eventual-consistency stance: the decider does NOT verify the @@ -22,7 +22,7 @@ (one `parent_id` field, can't be a list). **Levels are conventional, not enforced**: the decider does NOT -check that a Device's parent is an Assembly (etc). Device-in- +check that a Device's parent is a Component (etc). Device-in- Device is allowed when reality demands it (smart instruments with addressable sub-modules). diff --git a/apps/api/src/cora/equipment/features/register_asset/route.py b/apps/api/src/cora/equipment/features/register_asset/route.py index e1bc6e7c2..e3a44509e 100644 --- a/apps/api/src/cora/equipment/features/register_asset/route.py +++ b/apps/api/src/cora/equipment/features/register_asset/route.py @@ -33,7 +33,7 @@ class RegisterAssetRequest(BaseModel): """Body for `POST /assets`. `level` accepts the StrEnum's PascalCase string values - ("Enterprise" / "Site" / "Area" / "Unit" / "Assembly" / + ("Enterprise" / "Site" / "Area" / "Unit" / "Component" / "Device"); Pydantic rejects unknowns with 422. `parent_id` is required for non-Enterprise levels and must be @@ -52,7 +52,7 @@ class RegisterAssetRequest(BaseModel): ..., description=( "Hierarchical level. One of: Enterprise (root, requires " - "null parent_id), Site, Area, Unit, Assembly, Device." + "null parent_id), Site, Area, Unit, Component, Device." ), ) parent_id: UUID | None = Field( diff --git a/apps/api/src/cora/equipment/features/register_asset/tool.py b/apps/api/src/cora/equipment/features/register_asset/tool.py index c17c455c0..867963fe3 100644 --- a/apps/api/src/cora/equipment/features/register_asset/tool.py +++ b/apps/api/src/cora/equipment/features/register_asset/tool.py @@ -56,7 +56,7 @@ async def register_asset_tool( # pyright: ignore[reportUnusedFunction] Field( description=( "Hierarchical level: Enterprise (root, requires " - "null parent_id), Site, Area, Unit, Assembly, Device." + "null parent_id), Site, Area, Unit, Component, Device." ), ), ], diff --git a/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py new file mode 100644 index 000000000..20b9674fc --- /dev/null +++ b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py @@ -0,0 +1,151 @@ +"""Pin the AssetLevel.ASSEMBLY -> AssetLevel.COMPONENT rename. + +The token `Assembly` was freed for the new Assembly aggregate in +Equipment BC (5th aggregate, designed in +[[project_assembly_aggregate_design]]). Re-introducing +`AssetLevel.ASSEMBLY` as a symbolic reference, or `"Assembly"` as a +string literal in a context that means the level value, would +collide with the new aggregate's name and break the rename. + +This fitness catches three regressions: + +1. Any symbolic reference to `AssetLevel.ASSEMBLY` under `src/cora`. +2. The same symbolic reference under `tests/`. +3. The bare `"Assembly"` / `'Assembly'` string literal anywhere + under `src/cora` or `tests/`, with a narrow allow-list. Catches + the level value leaking into Literal types, JSON-Schema enums, + MCP tool descriptions, route descriptions, contract docstrings, + or event-payload fixtures. + +All three checks scope to tracked files via `tracked_python_files()` +and `tracked_test_files()`, per the conftest convention. + +The membership of the `AssetLevel` enum itself is pinned in +`tests/unit/equipment/test_asset.py` +(`test_asset_level_has_all_six_isa88_levels`); this fitness adds +the symbolic and literal sweeps. +""" + +from pathlib import Path + +import pytest + +from tests.architecture.conftest import ( + tracked_python_files, + tracked_test_files, +) + +_SYMBOLIC_REFERENCE = "AssetLevel.ASSEMBLY" + +# Files allowed to contain the bare "Assembly" / 'Assembly' literal. +# Today the only legitimate carrier is this fitness file itself (the +# docstring and the patterns variable both reference the literal). +# When Sub-Stage B ships the Assembly aggregate, that aggregate's +# state.py / events.py / projector / slices each become candidates +# for this allow-list. Add them one at a time at gate review. +_ALLOW_RELATIVE_PATHS: frozenset[str] = frozenset( + { + "apps/api/tests/architecture/test_no_assembly_asset_level_literal.py", + } +) + +_LITERAL_PATTERNS: tuple[str, ...] = ('"Assembly"', "'Assembly'") + + +def _scan( + paths: frozenset[Path], + needle: str, + repo_root: Path, +) -> list[tuple[Path, int, str]]: + hits: list[tuple[Path, int, str]] = [] + for path in paths: + try: + relative = path.relative_to(repo_root).as_posix() + except ValueError: + relative = path.as_posix() + if relative in _ALLOW_RELATIVE_PATHS: + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for line_no, line in enumerate(text.splitlines(), start=1): + if needle in line: + hits.append((path, line_no, line.strip())) + return hits + + +def _scan_literals_with_allow_list( + paths: frozenset[Path], + repo_root: Path, +) -> list[tuple[Path, int, str]]: + hits: list[tuple[Path, int, str]] = [] + for path in paths: + try: + relative = path.relative_to(repo_root).as_posix() + except ValueError: + relative = path.as_posix() + if relative in _ALLOW_RELATIVE_PATHS: + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + for line_no, line in enumerate(text.splitlines(), start=1): + if any(pattern in line for pattern in _LITERAL_PATTERNS): + hits.append((path, line_no, line.strip())) + return hits + + +@pytest.mark.architecture +def test_no_asset_level_assembly_symbolic_reference_in_source() -> None: + """`AssetLevel.ASSEMBLY` must not appear anywhere under src/cora. + + The enum value was renamed to `AssetLevel.COMPONENT`; any + surviving symbolic reference would fail to import and signals a + bad merge. The fitness file itself is allow-listed because its + docstring names the renamed token by design. + """ + repo_root = Path(__file__).resolve().parents[4] + hits = _scan(tracked_python_files(), _SYMBOLIC_REFERENCE, repo_root) + assert hits == [], ( + f"Found {len(hits)} reference(s) to {_SYMBOLIC_REFERENCE} under " + f"src/cora; rename to AssetLevel.COMPONENT.\n" + + "\n".join(f" {p}:{n}: {line}" for p, n, line in hits) + ) + + +@pytest.mark.architecture +def test_no_asset_level_assembly_symbolic_reference_in_tests() -> None: + """Same sweep over tests/. Tests are tracked separately because + pre-commit handles them via a different stash window. The fitness + file itself is allow-listed.""" + repo_root = Path(__file__).resolve().parents[4] + hits = _scan(tracked_test_files(), _SYMBOLIC_REFERENCE, repo_root) + assert hits == [], ( + f"Found {len(hits)} reference(s) to {_SYMBOLIC_REFERENCE} under " + f"tests/; rename to AssetLevel.COMPONENT.\n" + + "\n".join(f" {p}:{n}: {line}" for p, n, line in hits) + ) + + +@pytest.mark.architecture +def test_no_assembly_string_literal_in_tracked_python() -> None: + """The bare `"Assembly"` / `'Assembly'` literal must not appear in + any tracked Python source or test file, except those on the + narrow allow-list. Catches the level value leaking into Literal + types, JSON-Schema enums, MCP tool descriptions, route + descriptions, contract docstrings, or event-payload fixtures. + + Allow-list candidates expand as Sub-Stage B and beyond add + legitimate carriers of the Assembly aggregate name; add at gate + review time, not preemptively. + """ + repo_root = Path(__file__).resolve().parents[4] + all_paths: frozenset[Path] = tracked_python_files() | tracked_test_files() + hits = _scan_literals_with_allow_list(all_paths, repo_root) + assert hits == [], ( + f"Found {len(hits)} bare 'Assembly' literal(s) outside the " + f"allow-list; rename to 'Component' or widen the allow-list at " + f"gate review.\n" + "\n".join(f" {p}:{n}: {line}" for p, n, line in hits) + ) diff --git a/apps/api/tests/contract/test_assets_endpoint.py b/apps/api/tests/contract/test_assets_endpoint.py index 3f14ba9e5..89987e684 100644 --- a/apps/api/tests/contract/test_assets_endpoint.py +++ b/apps/api/tests/contract/test_assets_endpoint.py @@ -108,7 +108,7 @@ def test_post_assets_uses_max_length_constant_from_domain() -> None: def test_post_assets_rejects_unknown_level_with_422() -> None: """Pydantic StrEnum validation rejects unknown level strings before the decider runs. Pinned because the level vocabulary is closed - (Enterprise/Site/Area/Unit/Assembly/Device per BC map); typos and + (Enterprise/Site/Area/Unit/Component/Device per BC map); typos and legacy values must surface at the API boundary.""" with TestClient(create_app()) as client: response = client.post( @@ -151,7 +151,7 @@ def test_post_assets_rejects_enterprise_with_non_null_parent_with_400() -> None: @pytest.mark.contract @pytest.mark.parametrize( "level", - ["Site", "Area", "Unit", "Assembly", "Device"], + ["Site", "Area", "Unit", "Component", "Device"], ) def test_post_assets_rejects_non_enterprise_with_null_parent_with_400( level: str, diff --git a/apps/api/tests/contract/test_list_assets_endpoint.py b/apps/api/tests/contract/test_list_assets_endpoint.py index 8b8758369..fac0f3c3d 100644 --- a/apps/api/tests/contract/test_list_assets_endpoint.py +++ b/apps/api/tests/contract/test_list_assets_endpoint.py @@ -23,7 +23,7 @@ def test_get_assets_returns_empty_page_with_no_data(client: TestClient) -> None: @pytest.mark.contract @pytest.mark.parametrize( "level", - ["Enterprise", "Site", "Area", "Unit", "Assembly", "Device"], + ["Enterprise", "Site", "Area", "Unit", "Component", "Device"], ) def test_get_assets_accepts_each_level(client: TestClient, level: str) -> None: with client: diff --git a/apps/api/tests/integration/scenarios/test_2bm_mctoptics_setup.py b/apps/api/tests/integration/scenarios/test_2bm_mctoptics_setup.py index b1fa5a12a..93a233773 100644 --- a/apps/api/tests/integration/scenarios/test_2bm_mctoptics_setup.py +++ b/apps/api/tests/integration/scenarios/test_2bm_mctoptics_setup.py @@ -572,7 +572,7 @@ def _id_queue() -> list[UUID]: # (asset_id, parent_id, asset_name, level) _NEW_ASSET_REGISTRATIONS: tuple[tuple[UUID, UUID, str, AssetLevel], ...] = ( - (_ASSET_MCTOPTICS_ID, _2BM_UNIT_ID, "MCTOptics", AssetLevel.ASSEMBLY), + (_ASSET_MCTOPTICS_ID, _2BM_UNIT_ID, "MCTOptics", AssetLevel.COMPONENT), ( _ASSET_MCTOPTICS_OBJECTIVE_0_ID, _ASSET_MCTOPTICS_ID, diff --git a/apps/api/tests/unit/equipment/test_asset.py b/apps/api/tests/unit/equipment/test_asset.py index 3a6816da4..960f53315 100644 --- a/apps/api/tests/unit/equipment/test_asset.py +++ b/apps/api/tests/unit/equipment/test_asset.py @@ -77,7 +77,7 @@ def test_asset_level_has_all_six_isa88_levels() -> None: "Site", "Area", "Unit", - "Assembly", + "Component", "Device", } @@ -88,7 +88,7 @@ def test_asset_level_values_are_pascal_case_strings() -> None: assert AssetLevel.SITE == "Site" assert AssetLevel.AREA == "Area" assert AssetLevel.UNIT == "Unit" - assert AssetLevel.ASSEMBLY == "Assembly" + assert AssetLevel.COMPONENT == "Component" assert AssetLevel.DEVICE == "Device" diff --git a/apps/api/tests/unit/equipment/test_asset_events.py b/apps/api/tests/unit/equipment/test_asset_events.py index 873a16005..1ddea28a3 100644 --- a/apps/api/tests/unit/equipment/test_asset_events.py +++ b/apps/api/tests/unit/equipment/test_asset_events.py @@ -203,7 +203,7 @@ def test_to_payload_includes_drawing_block_when_set() -> None: event = AssetRegistered( asset_id=uuid4(), name="Microscope-2BM-A", - level="Assembly", + level="Component", parent_id=uuid4(), occurred_at=_NOW, drawing=Drawing(system=DrawingSystem.ICMS, number="P4105", revision="A"), @@ -221,7 +221,7 @@ def test_from_stored_rebuilds_asset_registered_with_drawing() -> None: { "asset_id": str(asset_id), "name": "Microscope-2BM-A", - "level": "Assembly", + "level": "Component", "parent_id": str(parent_id), "occurred_at": _NOW.isoformat(), "drawing": {"system": "EDMS", "number": "9001", "revision": None}, @@ -231,7 +231,7 @@ def test_from_stored_rebuilds_asset_registered_with_drawing() -> None: assert rebuilt == AssetRegistered( asset_id=asset_id, name="Microscope-2BM-A", - level="Assembly", + level="Component", parent_id=parent_id, occurred_at=_NOW, drawing=Drawing(system=DrawingSystem.EDMS, number="9001", revision=None), @@ -283,7 +283,7 @@ def test_to_payload_then_from_stored_round_trips_with_drawing() -> None: original = AssetRegistered( asset_id=uuid4(), name="Microscope-2BM-A", - level="Assembly", + level="Component", parent_id=uuid4(), occurred_at=_NOW, drawing=Drawing(system=DrawingSystem.DOI, number="10.5281/zenodo.X", revision="v2"), @@ -320,7 +320,7 @@ def test_to_payload_includes_model_id_when_set() -> None: event = AssetRegistered( asset_id=asset_id, name="Microscope-2BM-A", - level="Assembly", + level="Component", parent_id=parent_id, occurred_at=_NOW, model_id=model_id, @@ -339,7 +339,7 @@ def test_from_stored_rebuilds_asset_registered_with_model_id() -> None: { "asset_id": str(asset_id), "name": "Microscope-2BM-A", - "level": "Assembly", + "level": "Component", "parent_id": str(parent_id), "occurred_at": _NOW.isoformat(), "model_id": str(model_id), @@ -349,7 +349,7 @@ def test_from_stored_rebuilds_asset_registered_with_model_id() -> None: assert rebuilt == AssetRegistered( asset_id=asset_id, name="Microscope-2BM-A", - level="Assembly", + level="Component", parent_id=parent_id, occurred_at=_NOW, model_id=model_id, @@ -402,7 +402,7 @@ def test_to_payload_then_from_stored_round_trips_with_model_id() -> None: original = AssetRegistered( asset_id=uuid4(), name="Microscope-2BM-A", - level="Assembly", + level="Component", parent_id=uuid4(), occurred_at=_NOW, model_id=uuid4(), @@ -418,7 +418,7 @@ def test_to_payload_then_from_stored_round_trips_with_drawing_and_model_id() -> original = AssetRegistered( asset_id=uuid4(), name="Microscope-2BM-A", - level="Assembly", + level="Component", parent_id=uuid4(), occurred_at=_NOW, drawing=Drawing(system=DrawingSystem.ICMS, number="P4105", revision="A"), @@ -1127,7 +1127,7 @@ def test_to_payload_includes_alternate_identifiers_when_set() -> None: event = AssetRegistered( asset_id=uuid4(), name="X", - level="Assembly", + level="Component", parent_id=uuid4(), occurred_at=_NOW, alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A}), @@ -1147,7 +1147,7 @@ def test_to_payload_emits_alternate_identifiers_sorted_for_stable_bytes() -> Non event = AssetRegistered( asset_id=uuid4(), name="X", - level="Assembly", + level="Component", parent_id=uuid4(), occurred_at=_NOW, alternate_identifiers=frozenset({_SAMPLE_ALT_ID_B, _SAMPLE_ALT_ID_A}), @@ -1168,7 +1168,7 @@ def test_from_stored_rebuilds_asset_registered_with_alternate_identifiers() -> N { "asset_id": str(asset_id), "name": "X", - "level": "Assembly", + "level": "Component", "parent_id": str(parent_id), "occurred_at": _NOW.isoformat(), "alternate_identifiers": [ @@ -1181,7 +1181,7 @@ def test_from_stored_rebuilds_asset_registered_with_alternate_identifiers() -> N assert rebuilt == AssetRegistered( asset_id=asset_id, name="X", - level="Assembly", + level="Component", parent_id=parent_id, occurred_at=_NOW, alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}), @@ -1234,7 +1234,7 @@ def test_to_payload_then_from_stored_round_trips_with_alternate_identifiers() -> original = AssetRegistered( asset_id=uuid4(), name="X", - level="Assembly", + level="Component", parent_id=uuid4(), occurred_at=_NOW, alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}), diff --git a/apps/api/tests/unit/equipment/test_asset_summary_projection.py b/apps/api/tests/unit/equipment/test_asset_summary_projection.py index c880afee3..203cf6127 100644 --- a/apps/api/tests/unit/equipment/test_asset_summary_projection.py +++ b/apps/api/tests/unit/equipment/test_asset_summary_projection.py @@ -126,7 +126,7 @@ async def test_asset_registered_with_drawing_backfills_three_columns() -> None: { "asset_id": str(_ASSET_ID), "name": "Microscope-2BM-A", - "level": "Assembly", + "level": "Component", "parent_id": str(_PARENT_ID), "occurred_at": _NOW.isoformat(), "drawing": { @@ -155,7 +155,7 @@ async def test_asset_registered_with_drawing_no_revision_keeps_revision_null() - { "asset_id": str(_ASSET_ID), "name": "Microscope-2BM-A", - "level": "Assembly", + "level": "Component", "parent_id": str(_PARENT_ID), "occurred_at": _NOW.isoformat(), "drawing": {"system": "EDMS", "number": "9001"}, @@ -182,7 +182,7 @@ async def test_asset_registered_with_model_id_populates_model_column() -> None: { "asset_id": str(_ASSET_ID), "name": "Microscope-2BM-A", - "level": "Assembly", + "level": "Component", "parent_id": str(_PARENT_ID), "model_id": str(_MODEL_ID), "occurred_at": _NOW.isoformat(), diff --git a/apps/api/tests/unit/equipment/test_register_asset_decider.py b/apps/api/tests/unit/equipment/test_register_asset_decider.py index 2376862a9..834833426 100644 --- a/apps/api/tests/unit/equipment/test_register_asset_decider.py +++ b/apps/api/tests/unit/equipment/test_register_asset_decider.py @@ -142,7 +142,7 @@ def test_decide_rejects_enterprise_with_non_null_parent() -> None: AssetLevel.SITE, AssetLevel.AREA, AssetLevel.UNIT, - AssetLevel.ASSEMBLY, + AssetLevel.COMPONENT, AssetLevel.DEVICE, ], ) @@ -169,7 +169,7 @@ def test_decide_carries_drawing_through_to_emitted_event() -> None: state=None, command=RegisterAsset( name="Microscope-2BM-A", - level=AssetLevel.ASSEMBLY, + level=AssetLevel.COMPONENT, parent_id=uuid4(), drawing=drawing, ), @@ -205,7 +205,7 @@ def test_decide_propagates_model_id_to_emitted_event() -> None: state=None, command=RegisterAsset( name="Microscope-2BM-A", - level=AssetLevel.ASSEMBLY, + level=AssetLevel.COMPONENT, parent_id=uuid4(), model_id=model_id, ), diff --git a/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py b/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py index d324d105f..2e8cca4b8 100644 --- a/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py @@ -49,7 +49,7 @@ AssetLevel.SITE, AssetLevel.AREA, AssetLevel.UNIT, - AssetLevel.ASSEMBLY, + AssetLevel.COMPONENT, AssetLevel.DEVICE, ] ) diff --git a/docs/architecture/modules/equipment/index.md b/docs/architecture/modules/equipment/index.md index 672d30c55..424c81336 100644 --- a/docs/architecture/modules/equipment/index.md +++ b/docs/architecture/modules/equipment/index.md @@ -36,12 +36,12 @@ A `Family` is the device-class abstraction: "RotaryStage", "Camera", "Hexapod", | `FamilyStatus` | closed StrEnum: `Defined` \| `Versioned` \| `Deprecated` | `Family.status` | | `Affordance` | closed StrEnum, 28 values in 3 patterns (motion, signal, lifecycle) | members of `Family.affordances` | | `AssetName` | trimmed string, 1-200 chars | `Asset.name` | -| `AssetLevel` | closed StrEnum: `Enterprise` \| `Site` \| `Area` \| `Unit` \| `Assembly` \| `Device` | `Asset.level` | +| `AssetLevel` | closed StrEnum: `Enterprise` \| `Site` \| `Area` \| `Unit` \| `Component` \| `Device` | `Asset.level` | | `AssetLifecycle` | closed StrEnum: `Commissioned` \| `Active` \| `Maintenance` \| `Decommissioned` | `Asset.lifecycle` | | `AssetCondition` | closed StrEnum: `Nominal` \| `Degraded` \| `Faulted` | `Asset.condition` | | `AssetPort` | `(name, direction, signal_type)` triple; direction is `Input` \| `Output`; signal_type is free text, 1-50 chars | members of `Asset.ports` | -The six `AssetLevel` values are ISA-95-derived with single-word names. Levels are conventional: the decider checks that an Enterprise-level Asset has no parent and that every other level has one, but it does not enforce that a `Device` parents to an `Assembly`. Smart instruments with addressable sub-modules legitimately put a `Device` under another `Device`. +The six `AssetLevel` values are ISA-95-derived with single-word names. Levels are conventional: the decider checks that an Enterprise-level Asset has no parent and that every other level has one, but it does not enforce that a `Device` parents to a `Component`. Smart instruments with addressable sub-modules legitimately put a `Device` under another `Device`. Whether a composite vendor unit is one Asset with a wide settings dict or a parent Asset with several child Assets follows three tests. Any one is sufficient to spawn a child Asset: @@ -223,7 +223,7 @@ CREATE TABLE proj_equipment_asset_summary ( asset_id UUID PRIMARY KEY, name TEXT NOT NULL, level TEXT NOT NULL CHECK ( - level IN ('Enterprise', 'Site', 'Area', 'Unit', 'Assembly', 'Device') + level IN ('Enterprise', 'Site', 'Area', 'Unit', 'Component', 'Device') ), lifecycle TEXT NOT NULL CHECK ( lifecycle IN ('Commissioned', 'Active', 'Maintenance', 'Decommissioned') diff --git a/docs/architecture/standards.md b/docs/architecture/standards.md index 550bedeed..9dedb0434 100644 --- a/docs/architecture/standards.md +++ b/docs/architecture/standards.md @@ -10,7 +10,7 @@ Borrowed names and structure, not wire formats. A reader fluent in any of these | Standard | Provides | Lands in | | --- | --- | --- | -| ISA-95 | asset hierarchy (Enterprise / Site / Area / Unit / Assembly / Device) | `equipment` | +| ISA-95 | asset hierarchy (Enterprise / Site / Area / Unit / Component / Device) | `equipment` | | ISA-88 | episodic procedures (recipe ladder: Method / Practice / Plan / Run) | `recipe`, `run` | | ISA-106 | continuous operations | `operation`, `supply` (planned) | | ISA-99 / IEC 62443 | trust topology (Zones, Conduits, Surfaces, Policies) | `trust` | diff --git a/docs/deployments/2-bm/assets.md b/docs/deployments/2-bm/assets.md index 27faa2ac1..d6ca0433a 100644 --- a/docs/deployments/2-bm/assets.md +++ b/docs/deployments/2-bm/assets.md @@ -16,7 +16,7 @@ The Devices that hang off 2-BM. The 2-BM Asset itself sits at the Unit level and | `Sample_top_Pitch` | `Device` | `LinearStage` | `2-BM` | | `Hexapod_2BM` | `Device` | `Hexapod` | `2-BM` | | `Optique_Peter_focus_Z` | `Device` | `LinearStage` | `2-BM` (wired into `MCTOptics`) | -| `MCTOptics` | `Assembly` | `Microscope` | `2-BM` | +| `MCTOptics` | `Component` | `Microscope` | `2-BM` | | `MCTOptics_lens_turret` | `Device` | `RotaryStage` (pending) | `2-BM` (wired into `MCTOptics`) | | `MCTOptics_objective_0` | `Device` | `Objective` | `MCTOptics` | | `MCTOptics_objective_1` | `Device` | `Objective` | `MCTOptics` | @@ -26,11 +26,11 @@ The Devices that hang off 2-BM. The 2-BM Asset itself sits at the Unit level and ### MCTOptics composition -The Optique Peter detector at ~55 m from the source (controlled by the [BCDA-APS MCTOptics IOC](https://github.com/BCDA-APS/tomo-bits/blob/main/src/tomo_instrument/devices/mct_optics.py)) registers as a `Microscope`-Family Assembly with 5 Device children. The lens turret sits as a sibling under 2-BM (wired in, not a child), and the existing `Optique_Peter_focus_Z` linear stage is reused for shared focus. +The Optique Peter detector at ~55 m from the source (controlled by the [BCDA-APS MCTOptics IOC](https://github.com/BCDA-APS/tomo-bits/blob/main/src/tomo_instrument/devices/mct_optics.py)) registers as a `Microscope`-Family Component with 5 Device children. The lens turret sits as a sibling under 2-BM (wired in, not a child), and the existing `Optique_Peter_focus_Z` linear stage is reused for shared focus. ``` 2-BM (Unit) -+-- MCTOptics (Assembly) Family: Microscope ++-- MCTOptics (Component) Family: Microscope | +-- MCTOptics_objective_0 (Device) Family: Objective 10x | +-- MCTOptics_objective_1 (Device) Family: Objective 5x | +-- MCTOptics_objective_2 (Device) Family: Objective 1.1x diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 2b0f6676d..226aa39e3 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -40,7 +40,7 @@ For anyone reading CORA. Each term defined once and used the same way in code, c *ISA, ISO/IEC, NIST, PROV-O, RAiD.* -- **ISA-95.** Manufacturing operations hierarchy: Enterprise / Site / Area / Unit / Assembly / Device. Used for the Asset model. +- **ISA-95.** Manufacturing operations hierarchy: Enterprise / Site / Area / Unit / Component / Device. Used for the Asset model. - **ISA-88.** Batch control. Basis for Track A (Method / Practice / Plan / Run). - **ISA-106.** Continuous-process operations. Basis for Track B. - **ISA-99 / IEC 62443.** Industrial cybersecurity. Basis for Track C: Zones, Conduits, Policies (`trust` BC). @@ -69,7 +69,7 @@ Watch-only (not adopted as a glossary term, see [Deferred](../stack/deferred.md) *Assets, Families, Affordances. The device-classification side of Equipment BC.* -- **Asset.** A physical equipment instance registered in the hierarchy (Enterprise / Site / Area / Unit / Device per ISA-95). Belongs to one or more Families. +- **Asset.** A physical equipment instance registered in the hierarchy (Enterprise / Site / Area / Unit / Component / Device per ISA-95). Belongs to one or more Families. - **Asset two-axis state.** Asset carries two orthogonal state axes: `lifecycle` (`Commissioned` / `Active` / `Maintenance` / `Decommissioned` — is this device part of inventory and assignable) and `condition` (`Nominal` / `Degraded` / `Faulted` — is it actually working right now). The two move independently: a Decommissioned asset can be discovered Faulted on inventory check; an Active asset can be Degraded without being pulled out of service. The split is the deliberate design lock per the asset-condition memo; do not collapse `lifecycle` and `condition` into a single FSM. Matches PI-System asset-health (Good / Warning / Bad) and SEMI E10 productive-vs-unproductive-time orthogonality. - **Family.** A device-class abstraction: WHAT kind of equipment this is, device-agnostic. Examples: `RotaryStage`, `LinearStage`, `Camera`, `Scintillator`, `Hexapod`, `Mirror`. Earlier this aggregate was named `Capability`; the operations-layer Recipe BC `Capability` aggregate (separate concept; see below) landed 2026-05-18 and took that name over. - **Affordance.** A closed-enum primitive a Family declares it supports. Two patterns: operational affordances (`-able`/`-ible`/`-ing` suffix per Swift Guidelines, "device supports doing X" or "device performs X"; 27 items mixing 22 `-able`/`-ible` actions with 5 `-ing` role/flow gerunds — `Marking`, `Pulsing`, `Following`, `Leading`, `Recording`), and lifecycle affordances (noun, "device has lifecycle property X"; 1 item — `Consumable`). Cross-BC contract: at `define_plan` time, the union of every wired Asset's Families' affordances must cover the bound Method's Capability `required_affordances` (otherwise `PlanAffordancesNotSatisfiedError`, 409). See [Affordances reference](affordances.md) for the 28-item v1 list. diff --git a/infra/atlas/migrations/20260602000000_rename_asset_level_assembly_to_component.sql b/infra/atlas/migrations/20260602000000_rename_asset_level_assembly_to_component.sql new file mode 100644 index 000000000..470c32338 --- /dev/null +++ b/infra/atlas/migrations/20260602000000_rename_asset_level_assembly_to_component.sql @@ -0,0 +1,52 @@ +-- Rename `AssetLevel` value `Assembly` -> `Component` on the asset +-- summary projection. +-- +-- The token `Assembly` is being freed for the new Assembly aggregate +-- (Equipment BC's 5th aggregate, designed in +-- [[project_assembly_aggregate_design]]). `Component` is the +-- replacement value at the ISA-88 Equipment-Module tier; `Unit` (the +-- tier above) stays untouched. The other five `AssetLevel` values +-- (Enterprise, Site, Area, Unit, Device) are unchanged. +-- +-- Forward-only per [[project_forward_only_migrations]]. The +-- compensating rollback, if ever needed, is a new ADD migration that +-- reverses the value, not a DOWN step. +-- +-- ## Operation order +-- +-- 1. DROP the old CHECK so the backfill UPDATE can write 'Component'. +-- 2. UPDATE every 'Assembly' row to 'Component'. +-- 3. ADD the new CHECK with the renamed value set. +-- +-- The table is briefly unconstrained between steps 1 and 3; that +-- window is single-transaction-bounded (Atlas wraps each migration +-- file in a transaction) so no other writer can observe the gap. +-- +-- ## Greenfield posture +-- +-- Zero rows live in `proj_equipment_asset_summary` with +-- level='Assembly' on any environment beyond test fixtures, so the +-- UPDATE is effectively a no-op in production. The migration still +-- ships the UPDATE so replay against any prior dev database that did +-- register such rows folds cleanly. The corresponding +-- `from_stored` wrap on the Asset evolver is intentionally NOT +-- added: under greenfield posture (lock at 66db6a1f8) we string- +-- replace events at rest if any ever surface, not at read time. + +ALTER TABLE proj_equipment_asset_summary + DROP CONSTRAINT proj_equipment_asset_summary_level_check; + +UPDATE proj_equipment_asset_summary + SET level = 'Component' + WHERE level = 'Assembly'; + +ALTER TABLE proj_equipment_asset_summary + ADD CONSTRAINT proj_equipment_asset_summary_level_check + CHECK (level IN ( + 'Enterprise', + 'Site', + 'Area', + 'Unit', + 'Component', + 'Device' + )); diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index a8fe46873..f69627f3e 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:1NLrqPW/bFYVIPXRz5sWJ6oPhsU98drZ9Ttrsv7rlzg= +h1:O0eZ3rwkekN3nR7UA2zL0cF4eBDxxQ7l9ojvQysX6dc= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -90,8 +90,9 @@ h1:1NLrqPW/bFYVIPXRz5sWJ6oPhsU98drZ9Ttrsv7rlzg= 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= -20260602110000_add_asset_summary_model.sql h1:6JNrSeL/whEo/ZQ6IlZs21RJ79BGcl0bR35nH0IhTGc= -20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:AGBAc0XP1TXK04PIFKavYe0buGgonSFMbL9MxYyr7bk= -20260602120000_rename_model_summary_declared_families_column.sql h1:NI5sDXMPXmI7p+azimD6S6LzWaaP0N06+JcU7tbj+6Y= -20260603100000_add_asset_summary_alternate_identifiers.sql h1:Nc5kSxfv6zEUvJBD+ZZULyqlrboCLFD42gMoTEQxRcI= +20260602000000_rename_asset_level_assembly_to_component.sql h1:wUWROwvhmmG3n/qsp5YOt4rf5aUthO79EiuzBKHDNOE= +20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:MRFJiqBX/MdPpVoeaZW3ofTdxJtXChIglfXJOiWqZt8= +20260602110000_add_asset_summary_model.sql h1:Xq9VNxr5Yqfmr6GQ3lU3GY0pYYHaqToJGNIXeJGQcrE= +20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:iHxz9PxUQg/ND3vOsEIcEG2IZVdZ8xrq2wCVdBxZkNI= +20260602120000_rename_model_summary_declared_families_column.sql h1:USEFSTeYZrGtPRiwCpsiEFm1fXKWBPaV34BVM3dAlnY= +20260603100000_add_asset_summary_alternate_identifiers.sql h1:0E5lvIJUDUAEOKOv8xTbhJjZe4z7MjZiLdspqmVdYqE= From d06f22d9ea1cb86eea2f7b2e6aa21c9ec9429075 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 15:55:44 +0300 Subject: [PATCH 2/5] feat(equipment): scaffold Assembly aggregate (5th aggregate) Equipment BC's 5th aggregate: a content-addressed composition blueprint that declares required_slots (Family-typed, cardinality- annotated, optionally pre-Placed) and required_wires (slot-keyed 4-tuples), and exposes a stable presents_as_family_id so Method.needed_families can treat an instantiated Assembly as one typed unit at the same level as a single Asset. Fills the gap operators at APS 2-BM keep hitting: hand-instantiating the same 5-to-12-Asset MCTOptics fixture and re-wiring it from memory. Scaffold-only: state + 3 main-stream events (Defined / Versioned / Deprecated) + evolver + content-hash helper + summary projector + migration + 78 unit tests. AssemblyInstantiated lives on a separate assembly_instantiation stream and ships with the instantiate_assembly slice. Hoists placement_to/from_payload to _placement.py and drawing_to/from_payload to _drawing.py as the shared codecs; Mount, Frame, and Assembly events all import from one site, closing the codec-duplication anti-hook flagged in project_mount_frame_design Watch items. canonical_assembly_subset is the single source of truth for the content-hash subset (name + presents_as_family_id + required_slots + required_wires + parameter_overrides_schema); Assembly.content_subset() and compute_assembly_content_hash() both delegate. Drawing and version are excluded by design; content_hash is stable across "define" and "version" snapshots of the same canonical content. TemplateSlot carries a custom __hash__ that canonicalizes the dict-valued default_settings via json sort-keys; required because Assembly.required_slots is frozenset[TemplateSlot]. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/cora/equipment/_projections.py | 2 + .../aggregates/_assembly_content_hash.py | 103 +++ .../src/cora/equipment/aggregates/_drawing.py | 31 + .../cora/equipment/aggregates/_placement.py | 56 ++ .../equipment/aggregates/assembly/__init__.py | 102 +++ .../equipment/aggregates/assembly/events.py | 366 ++++++++++ .../equipment/aggregates/assembly/evolver.py | 115 ++++ .../equipment/aggregates/assembly/read.py | 23 + .../equipment/aggregates/assembly/state.py | 628 ++++++++++++++++++ .../cora/equipment/aggregates/frame/events.py | 69 +- .../cora/equipment/aggregates/mount/events.py | 84 +-- .../cora/equipment/projections/__init__.py | 2 + .../equipment/projections/assembly_summary.py | 66 ++ apps/api/src/cora/equipment/routes.py | 34 + .../test_no_assembly_asset_level_literal.py | 18 + .../equipment/test_assembly_content_hash.py | 486 ++++++++++++++ .../unit/equipment/test_assembly_events.py | 177 +++++ .../unit/equipment/test_assembly_evolver.py | 242 +++++++ .../unit/equipment/test_assembly_state.py | 260 ++++++++ .../test_assembly_summary_projection.py | 125 ++++ .../equipment/test_assembly_template_slot.py | 105 +++ .../equipment/test_assembly_template_wire.py | 130 ++++ .../cora-review-codification-research.md | 138 ++++ ...0_init_proj_equipment_assembly_summary.sql | 69 ++ infra/atlas/migrations/atlas.sum | 11 +- 25 files changed, 3314 insertions(+), 128 deletions(-) create mode 100644 apps/api/src/cora/equipment/aggregates/_assembly_content_hash.py create mode 100644 apps/api/src/cora/equipment/aggregates/assembly/__init__.py create mode 100644 apps/api/src/cora/equipment/aggregates/assembly/events.py create mode 100644 apps/api/src/cora/equipment/aggregates/assembly/evolver.py create mode 100644 apps/api/src/cora/equipment/aggregates/assembly/read.py create mode 100644 apps/api/src/cora/equipment/aggregates/assembly/state.py create mode 100644 apps/api/src/cora/equipment/projections/assembly_summary.py create mode 100644 apps/api/tests/unit/equipment/test_assembly_content_hash.py create mode 100644 apps/api/tests/unit/equipment/test_assembly_events.py create mode 100644 apps/api/tests/unit/equipment/test_assembly_evolver.py create mode 100644 apps/api/tests/unit/equipment/test_assembly_state.py create mode 100644 apps/api/tests/unit/equipment/test_assembly_summary_projection.py create mode 100644 apps/api/tests/unit/equipment/test_assembly_template_slot.py create mode 100644 apps/api/tests/unit/equipment/test_assembly_template_wire.py create mode 100644 docs/projects/cora-outreach/cora-review-codification-research.md create mode 100644 infra/atlas/migrations/20260602100000_init_proj_equipment_assembly_summary.sql diff --git a/apps/api/src/cora/equipment/_projections.py b/apps/api/src/cora/equipment/_projections.py index 75ff2b48d..2d0dff693 100644 --- a/apps/api/src/cora/equipment/_projections.py +++ b/apps/api/src/cora/equipment/_projections.py @@ -8,6 +8,7 @@ """ from cora.equipment.projections import ( + AssemblySummaryProjection, AssetFamilyMembershipProjection, AssetLocationProjection, AssetSummaryProjection, @@ -41,6 +42,7 @@ def register_equipment_projections( registry.register(MountSlotCodeProjection()) registry.register(MountChildrenProjection()) registry.register(AssetLocationProjection()) + registry.register(AssemblySummaryProjection()) __all__ = ["register_equipment_projections"] diff --git a/apps/api/src/cora/equipment/aggregates/_assembly_content_hash.py b/apps/api/src/cora/equipment/aggregates/_assembly_content_hash.py new file mode 100644 index 000000000..489cc12d2 --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/_assembly_content_hash.py @@ -0,0 +1,103 @@ +"""Content-hash helper for the Assembly aggregate. + +Wraps the shared `cora.infrastructure.content_hash.compute_content_hash` +pipeline with the Assembly-specific payload_type and the canonical +subset materialized by `canonical_assembly_subset`. + +Used at two sites: `define_assembly` and `version_assembly`. Both +fire the same hash for the same structural content; the +payload_type is aggregate-level +(`application/vnd.cora.assembly+json`), NOT event-level, so the +hash is stable across "define" and "version" snapshots of the same +canonical subset. + +Per `project_content_addressed_identity_design`: SHA-256 hex of +DSSE PAE-wrapped canonical JSON. Per +`project_canonicalization_research`: stdlib json sort-keys + NFC + +set-to-sorted-list, all delegated to the shared +`canonical_body_bytes`. + +Lives at BC root (`equipment/aggregates/_assembly_content_hash.py`) +alongside `_drawing.py` and `_placement.py`, following the +shared-helper convention. +""" + +from uuid import UUID + +from cora.equipment.aggregates.assembly.state import ( + Assembly, + AssemblyName, + TemplateSlot, + TemplateWire, + canonical_assembly_subset, +) +from cora.infrastructure.content_hash import compute_content_hash +from cora.infrastructure.signing import event_type_to_payload_type + +# Aggregate-level payload_type: stable across define / version events +# so two snapshots of the same canonical subset produce the same hash. +ASSEMBLY_PAYLOAD_TYPE = "application/vnd.cora.assembly+json" + +# Per-event payload_types for signing the events themselves (not the +# canonical-subset hash). Kept here to avoid scattering the strings +# across slice deciders that emit these events. +ASSEMBLY_DEFINED_PAYLOAD_TYPE = event_type_to_payload_type("AssemblyDefined") +ASSEMBLY_VERSIONED_PAYLOAD_TYPE = event_type_to_payload_type("AssemblyVersioned") +ASSEMBLY_DEPRECATED_PAYLOAD_TYPE = event_type_to_payload_type("AssemblyDeprecated") + + +def compute_assembly_content_hash( + name: AssemblyName | str, + presents_as_family_id: UUID, + required_slots: frozenset[TemplateSlot], + required_wires: frozenset[TemplateWire], + parameter_overrides_schema: dict[str, object] | None, +) -> str: + """Compute SHA-256 hex over an Assembly's canonical content subset. + + Accepts either an AssemblyName VO or a raw string for `name` so + callers building from operator-supplied input do not need to + construct the VO twice (the AssemblyDefined decider validates + once via the VO, then passes the VO here for hashing). + + Round-trip equivalence with + `compute_assembly_content_hash_from_state(Assembly(...))` is + pinned in tests; both paths funnel through + `canonical_assembly_subset` so structural drift between them is + impossible. + """ + body = canonical_assembly_subset( + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=required_slots, + required_wires=required_wires, + parameter_overrides_schema=parameter_overrides_schema, + ) + return compute_content_hash(ASSEMBLY_PAYLOAD_TYPE, body) + + +def compute_assembly_content_hash_from_state(state: Assembly) -> str: + """Compute the content_hash for a fully-constructed Assembly state. + + Convenience wrapper: extracts the canonical subset via + `state.content_subset()` (which itself delegates to + `canonical_assembly_subset`) and feeds it to the shared + `compute_content_hash` pipeline under `ASSEMBLY_PAYLOAD_TYPE`. + + Used in tests to verify that the state-method path and the + explicit-args path produce identical hashes, and may be useful + in future re-hash flows (e.g., a one-shot fitness that recomputes + all live Assembly hashes and asserts they match the stored + content_hash). + """ + return compute_content_hash(ASSEMBLY_PAYLOAD_TYPE, state.content_subset()) + + +__all__ = [ + "ASSEMBLY_DEFINED_PAYLOAD_TYPE", + "ASSEMBLY_DEPRECATED_PAYLOAD_TYPE", + "ASSEMBLY_PAYLOAD_TYPE", + "ASSEMBLY_VERSIONED_PAYLOAD_TYPE", + "compute_assembly_content_hash", + "compute_assembly_content_hash_from_state", +] diff --git a/apps/api/src/cora/equipment/aggregates/_drawing.py b/apps/api/src/cora/equipment/aggregates/_drawing.py index 3032c3f4b..4ff748caa 100644 --- a/apps/api/src/cora/equipment/aggregates/_drawing.py +++ b/apps/api/src/cora/equipment/aggregates/_drawing.py @@ -157,9 +157,40 @@ def __post_init__(self) -> None: object.__setattr__(self, "revision", trimmed_revision) +def drawing_to_payload(drawing: Drawing) -> dict[str, object]: + """Serialize a Drawing VO to a JSON-friendly dict for jsonb storage. + + Canonical codec shared by every aggregate event that carries a + Drawing (Mount, Asset, Assembly). Sibling rebuilder is + `drawing_from_payload`. Hoisted to the VO module to close the + duplication anti-hook flagged in `project_mount_frame_design` + Watch items. + """ + return { + "system": drawing.system.value, + "number": drawing.number, + "revision": drawing.revision, + } + + +def drawing_from_payload(payload: dict[str, object]) -> Drawing: + """Reconstruct a Drawing VO from its JSON payload. + + Sibling of `drawing_to_payload`. Round-trips losslessly with it. + Re-runs Drawing.__post_init__ validation. + """ + return Drawing( + system=DrawingSystem(str(payload["system"])), + number=str(payload["number"]), + revision=(str(payload["revision"]) if payload.get("revision") is not None else None), + ) + + __all__ = [ "DRAWING_NUMBER_MAX_LENGTH", "DRAWING_REVISION_MAX_LENGTH", "Drawing", "DrawingSystem", + "drawing_from_payload", + "drawing_to_payload", ] diff --git a/apps/api/src/cora/equipment/aggregates/_placement.py b/apps/api/src/cora/equipment/aggregates/_placement.py index 8a1511ebe..4d8474870 100644 --- a/apps/api/src/cora/equipment/aggregates/_placement.py +++ b/apps/api/src/cora/equipment/aggregates/_placement.py @@ -201,8 +201,64 @@ def __post_init__(self) -> None: ) +def placement_to_payload(placement: Placement) -> dict[str, object]: + """Serialize a Placement VO to a JSON-friendly dict for jsonb storage. + + Canonical codec shared by every aggregate event that carries a + Placement (Mount, Frame, Assembly). Sibling rebuilder is + `placement_from_payload`. Hoisted to the VO module to close the + duplication anti-hook flagged in `project_mount_frame_design` + Watch items. + """ + return { + "x": placement.x, + "y": placement.y, + "z": placement.z, + "rx": placement.rx, + "ry": placement.ry, + "rz": placement.rz, + "parent_frame_id": str(placement.parent_frame_id), + "reference_surface": placement.reference_surface.value, + "tol_x": placement.tol_x, + "tol_y": placement.tol_y, + "tol_z": placement.tol_z, + "tol_rx": placement.tol_rx, + "tol_ry": placement.tol_ry, + "tol_rz": placement.tol_rz, + "units": placement.units.value, + } + + +def placement_from_payload(payload: dict[str, object]) -> Placement: + """Reconstruct a Placement VO from its JSON payload. + + Sibling of `placement_to_payload`. Round-trips losslessly with it. + Re-runs Placement.__post_init__ validation so on-disk corruption + (NaN, Inf, negative tolerance) surfaces at load time. + """ + return Placement( + x=payload["x"], # type: ignore[arg-type] + y=payload["y"], # type: ignore[arg-type] + z=payload["z"], # type: ignore[arg-type] + rx=payload["rx"], # type: ignore[arg-type] + ry=payload["ry"], # type: ignore[arg-type] + rz=payload["rz"], # type: ignore[arg-type] + parent_frame_id=UUID(str(payload["parent_frame_id"])), + reference_surface=ReferenceSurface(str(payload["reference_surface"])), + tol_x=payload["tol_x"], # type: ignore[arg-type] + tol_y=payload["tol_y"], # type: ignore[arg-type] + tol_z=payload["tol_z"], # type: ignore[arg-type] + tol_rx=payload["tol_rx"], # type: ignore[arg-type] + tol_ry=payload["tol_ry"], # type: ignore[arg-type] + tol_rz=payload["tol_rz"], # type: ignore[arg-type] + units=UnitSystem(str(payload["units"])), + ) + + __all__ = [ "Placement", "ReferenceSurface", "UnitSystem", + "placement_from_payload", + "placement_to_payload", ] diff --git a/apps/api/src/cora/equipment/aggregates/assembly/__init__.py b/apps/api/src/cora/equipment/aggregates/assembly/__init__.py new file mode 100644 index 000000000..4be444c2a --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/assembly/__init__.py @@ -0,0 +1,102 @@ +"""Assembly aggregate: the composition blueprint for a reusable cluster of Assets. + +An `Assembly` declares `required_slots` (Family-typed, cardinality- +annotated, optionally pre-Placed) and `required_wires` (slot-keyed +4-tuples), and exposes a stable `presents_as_family_id` so other +aggregates (Method.needed_families, Capability bindings) can treat +an instantiated Assembly as one typed unit at the same level as a +single Asset. + +Content-addressed: `content_hash` is the SHA-256 hex fingerprint of +the canonical subset {name, presents_as_family_id, required_slots, +required_wires, parameter_overrides_schema}. Two operators +independently authoring the same Assembly converge on the same +content_hash. + +Lifecycle: `Defined | Versioned | Deprecated`. Multiple +AssemblyVersioned events per stream (append-only revisions). + +Vertical slices that operate on this aggregate live under +`cora.equipment.features._assembly/` (define / version / +deprecate / instantiate); this scaffold ships the aggregate +kernel without any wired slices. +""" + +from cora.equipment.aggregates.assembly.events import ( + AssemblyDefined, + AssemblyDeprecated, + AssemblyEvent, + AssemblyVersioned, + event_type_name, + from_stored, + to_payload, +) +from cora.equipment.aggregates.assembly.evolver import evolve, fold +from cora.equipment.aggregates.assembly.read import load_assembly +from cora.equipment.aggregates.assembly.state import ( + ASSEMBLY_NAME_MAX_LENGTH, + SLOT_NAME_MAX_LENGTH, + WIRE_PORT_NAME_MAX_LENGTH, + Assembly, + AssemblyAlreadyExistsError, + AssemblyCannotDeprecateError, + AssemblyCannotInstantiateError, + AssemblyCannotVersionError, + AssemblyInstantiationAssetFamilyMismatchError, + AssemblyInstantiationMappingIncompleteError, + AssemblyInstantiationParameterOverridesInvalidError, + AssemblyName, + AssemblyNotFoundError, + AssemblyStatus, + FamilyNotFoundForAssemblyError, + InvalidAssemblyNameError, + InvalidParameterOverridesSchemaError, + InvalidSlotCardinalityError, + InvalidSlotNameError, + InvalidTemplateSlotError, + InvalidWireSpecError, + SlotCardinality, + SlotName, + TemplateSlot, + TemplateWire, + WireReferencesUnknownSlotError, +) + +__all__ = [ + "ASSEMBLY_NAME_MAX_LENGTH", + "SLOT_NAME_MAX_LENGTH", + "WIRE_PORT_NAME_MAX_LENGTH", + "Assembly", + "AssemblyAlreadyExistsError", + "AssemblyCannotDeprecateError", + "AssemblyCannotInstantiateError", + "AssemblyCannotVersionError", + "AssemblyDefined", + "AssemblyDeprecated", + "AssemblyEvent", + "AssemblyInstantiationAssetFamilyMismatchError", + "AssemblyInstantiationMappingIncompleteError", + "AssemblyInstantiationParameterOverridesInvalidError", + "AssemblyName", + "AssemblyNotFoundError", + "AssemblyStatus", + "AssemblyVersioned", + "FamilyNotFoundForAssemblyError", + "InvalidAssemblyNameError", + "InvalidParameterOverridesSchemaError", + "InvalidSlotCardinalityError", + "InvalidSlotNameError", + "InvalidTemplateSlotError", + "InvalidWireSpecError", + "SlotCardinality", + "SlotName", + "TemplateSlot", + "TemplateWire", + "WireReferencesUnknownSlotError", + "event_type_name", + "evolve", + "fold", + "from_stored", + "load_assembly", + "to_payload", +] diff --git a/apps/api/src/cora/equipment/aggregates/assembly/events.py b/apps/api/src/cora/equipment/aggregates/assembly/events.py new file mode 100644 index 000000000..79f9facf9 --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/assembly/events.py @@ -0,0 +1,366 @@ +"""Domain events emitted by the Assembly aggregate, plus the discriminated union. + +Mirrors the Mount event-module shape (see `aggregates/mount/events.py`): +event classes as frozen dataclasses, discriminated union, +`event_type_name`, `to_payload`, `from_stored` with per-arm +KeyError / TypeError / AttributeError wrapping into tagged +ValueError per `project_from_stored_wrap_convention`. + +Event catalog (3, scaffold-only): + - `AssemblyDefined` (genesis; carries the canonical structural + fields + content_hash + drawing + version) + - `AssemblyVersioned` (new revision snapshot on the same stream; + append-only mirroring CalibrationRevision) + - `AssemblyDeprecated` (terminal lifecycle) + +`AssemblyInstantiated` is a fourth event in the design memo but +lives on a separate `assembly_instantiation` stream and ships with +the `instantiate_assembly` slice. Keeping it out of this scaffold +module keeps the discriminated union focused on the main-stream +events that the Assembly evolver folds. + +## Payload conventions for Assembly + +`status` is NOT carried in the payload: the event TYPE encodes the +state change (`AssemblyDefined -> Defined`, `AssemblyVersioned -> +Versioned`, `AssemblyDeprecated -> Deprecated`). Same precedent as +Mount / Frame / Asset / Family. + +`content_hash` IS carried in `AssemblyDefined` and `AssemblyVersioned` +payloads (the hash is computed at write time and embedded for +audit-self-containment). `content_hash` is absent from +`AssemblyDeprecated` because deprecation does not change content +identity; the prior hash stays the structural fingerprint. + +`required_slots` and `required_wires` serialize as sorted lists of +dicts / 4-tuples; deserialization reconstructs frozensets via the +VO constructors (which re-run __post_init__ validation, catching +on-disk corruption at load time). + +`drawing` is carried optionally on `AssemblyDefined` and +`AssemblyVersioned` (the drawing may itself be re-attested at +version time). Placement and Drawing payload codecs are imported +from the shared `_placement` / `_drawing` modules to honor the +codec-helper-duplication anti-hook flagged in +`project_mount_frame_design` Watch items. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, assert_never +from uuid import UUID + +from cora.equipment.aggregates._drawing import ( + Drawing, + drawing_from_payload, + drawing_to_payload, +) +from cora.equipment.aggregates._placement import ( + placement_from_payload, + placement_to_payload, +) +from cora.equipment.aggregates.assembly.state import ( + AssemblyName, + SlotCardinality, + SlotName, + TemplateSlot, + TemplateWire, +) +from cora.infrastructure.event_payload import deserialize_or_raise +from cora.infrastructure.ports.event_store import StoredEvent + + +def _template_slot_to_payload(slot: TemplateSlot) -> dict[str, Any]: + return { + "slot_name": slot.slot_name.value, + "required_family_ids": sorted(str(f) for f in slot.required_family_ids), + "cardinality": slot.cardinality.value, + "default_settings": slot.default_settings, + "default_placement": ( + placement_to_payload(slot.default_placement) + if slot.default_placement is not None + else None + ), + } + + +def _template_slot_from_payload(payload: dict[str, Any]) -> TemplateSlot: + raw_placement = payload.get("default_placement") + return TemplateSlot( + slot_name=SlotName(payload["slot_name"]), + required_family_ids=frozenset(UUID(f) for f in payload["required_family_ids"]), + cardinality=SlotCardinality(payload["cardinality"]), + default_settings=payload.get("default_settings"), + default_placement=( + placement_from_payload(raw_placement) if raw_placement is not None else None + ), + ) + + +def _template_wire_to_payload(wire: TemplateWire) -> dict[str, Any]: + return { + "source_slot_name": wire.source_slot_name, + "source_port_name": wire.source_port_name, + "target_slot_name": wire.target_slot_name, + "target_port_name": wire.target_port_name, + } + + +def _template_wire_from_payload(payload: dict[str, Any]) -> TemplateWire: + return TemplateWire( + source_slot_name=payload["source_slot_name"], + source_port_name=payload["source_port_name"], + target_slot_name=payload["target_slot_name"], + target_port_name=payload["target_port_name"], + ) + + +@dataclass(frozen=True) +class AssemblyDefined: + """A new Assembly was defined. + + Genesis event. Carries the full canonical structural subset + (name, presents_as_family_id, required_slots, required_wires, + parameter_overrides_schema) plus the computed content_hash and + the operator-curatorial fields (drawing, version). + + Status is implicit (`Defined`); the evolver sets it from the + event type. + """ + + assembly_id: UUID + name: AssemblyName + presents_as_family_id: UUID + required_slots: frozenset[TemplateSlot] + required_wires: frozenset[TemplateWire] + parameter_overrides_schema: dict[str, Any] | None + drawing: Drawing | None + version: str | None + content_hash: str + occurred_at: datetime + + +@dataclass(frozen=True) +class AssemblyVersioned: + """A new revision snapshot of the Assembly was published. + + Replace-on-version: the payload carries the FULL canonical + structural subset (not a diff), the recomputed content_hash, and + the new version label. Multiple AssemblyVersioned events on the + same stream are permitted; each is a fresh snapshot under the + same aggregate id. + + Re-attestation with identical structural payload (same + content_hash) is allowed: it surfaces a refreshed version label + or drawing while pinning that no structural change happened. + """ + + assembly_id: UUID + name: AssemblyName + presents_as_family_id: UUID + required_slots: frozenset[TemplateSlot] + required_wires: frozenset[TemplateWire] + parameter_overrides_schema: dict[str, Any] | None + drawing: Drawing | None + version: str | None + content_hash: str + previous_content_hash: str | None + occurred_at: datetime + + +@dataclass(frozen=True) +class AssemblyDeprecated: + """The Assembly was retired from active use. + + Terminal lifecycle transition. Subsequent `instantiate_assembly` + calls reject. New revisions must fork via `define_assembly` with + a fresh id. + + `reason` is operator-supplied free text (audit-log breadcrumb). + """ + + assembly_id: UUID + reason: str + occurred_at: datetime + + +AssemblyEvent = AssemblyDefined | AssemblyVersioned | AssemblyDeprecated + + +def event_type_name(event: AssemblyEvent) -> str: + """Discriminator string written into StoredEvent.event_type.""" + return type(event).__name__ + + +def to_payload(event: AssemblyEvent) -> dict[str, Any]: + """Serialize an Assembly event to a JSON-friendly dict for jsonb storage.""" + match event: + case AssemblyDefined( + assembly_id=assembly_id, + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=required_slots, + required_wires=required_wires, + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing, + version=version, + content_hash=content_hash, + occurred_at=occurred_at, + ): + return { + "assembly_id": str(assembly_id), + "name": name.value, + "presents_as_family_id": str(presents_as_family_id), + "required_slots": sorted( + (_template_slot_to_payload(s) for s in required_slots), + key=lambda d: d["slot_name"], + ), + "required_wires": sorted( + (_template_wire_to_payload(w) for w in required_wires), + key=lambda d: ( + d["source_slot_name"], + d["source_port_name"], + d["target_slot_name"], + d["target_port_name"], + ), + ), + "parameter_overrides_schema": parameter_overrides_schema, + "drawing": (drawing_to_payload(drawing) if drawing is not None else None), + "version": version, + "content_hash": content_hash, + "occurred_at": occurred_at.isoformat(), + } + case AssemblyVersioned( + assembly_id=assembly_id, + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=required_slots, + required_wires=required_wires, + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing, + version=version, + content_hash=content_hash, + previous_content_hash=previous_content_hash, + occurred_at=occurred_at, + ): + return { + "assembly_id": str(assembly_id), + "name": name.value, + "presents_as_family_id": str(presents_as_family_id), + "required_slots": sorted( + (_template_slot_to_payload(s) for s in required_slots), + key=lambda d: d["slot_name"], + ), + "required_wires": sorted( + (_template_wire_to_payload(w) for w in required_wires), + key=lambda d: ( + d["source_slot_name"], + d["source_port_name"], + d["target_slot_name"], + d["target_port_name"], + ), + ), + "parameter_overrides_schema": parameter_overrides_schema, + "drawing": (drawing_to_payload(drawing) if drawing is not None else None), + "version": version, + "content_hash": content_hash, + "previous_content_hash": previous_content_hash, + "occurred_at": occurred_at.isoformat(), + } + case AssemblyDeprecated( + assembly_id=assembly_id, + reason=reason, + occurred_at=occurred_at, + ): + return { + "assembly_id": str(assembly_id), + "reason": reason, + "occurred_at": occurred_at.isoformat(), + } + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def from_stored(stored: StoredEvent) -> AssemblyEvent: + """Rebuild an Assembly event from a StoredEvent loaded from the event store. + + Dispatches on `stored.event_type`; raises ValueError on unknown + discriminators so a stream contaminated with foreign event types + fails loud rather than silently being dropped by the evolver. + Per-arm `(KeyError, TypeError, AttributeError)` wrap into tagged + ValueError per `project_from_stored_wrap_convention`. + """ + payload = stored.payload + match stored.event_type: + case "AssemblyDefined": + + def _build_defined() -> AssemblyDefined: + raw_drawing = payload.get("drawing") + return AssemblyDefined( + assembly_id=UUID(payload["assembly_id"]), + name=AssemblyName(payload["name"]), + presents_as_family_id=UUID(payload["presents_as_family_id"]), + required_slots=frozenset( + _template_slot_from_payload(s) for s in payload["required_slots"] + ), + required_wires=frozenset( + _template_wire_from_payload(w) for w in payload["required_wires"] + ), + parameter_overrides_schema=payload.get("parameter_overrides_schema"), + drawing=( + drawing_from_payload(raw_drawing) if raw_drawing is not None else None + ), + version=payload.get("version"), + content_hash=payload["content_hash"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + return deserialize_or_raise("AssemblyDefined", _build_defined) + case "AssemblyVersioned": + + def _build_versioned() -> AssemblyVersioned: + raw_drawing = payload.get("drawing") + return AssemblyVersioned( + assembly_id=UUID(payload["assembly_id"]), + name=AssemblyName(payload["name"]), + presents_as_family_id=UUID(payload["presents_as_family_id"]), + required_slots=frozenset( + _template_slot_from_payload(s) for s in payload["required_slots"] + ), + required_wires=frozenset( + _template_wire_from_payload(w) for w in payload["required_wires"] + ), + parameter_overrides_schema=payload.get("parameter_overrides_schema"), + drawing=( + drawing_from_payload(raw_drawing) if raw_drawing is not None else None + ), + version=payload.get("version"), + content_hash=payload["content_hash"], + previous_content_hash=payload.get("previous_content_hash"), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + return deserialize_or_raise("AssemblyVersioned", _build_versioned) + case "AssemblyDeprecated": + return deserialize_or_raise( + "AssemblyDeprecated", + lambda: AssemblyDeprecated( + assembly_id=UUID(payload["assembly_id"]), + reason=payload["reason"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case _: + msg = f"Unknown AssemblyEvent event_type: {stored.event_type!r}" + raise ValueError(msg) + + +__all__ = [ + "AssemblyDefined", + "AssemblyDeprecated", + "AssemblyEvent", + "AssemblyVersioned", + "event_type_name", + "from_stored", + "to_payload", +] diff --git a/apps/api/src/cora/equipment/aggregates/assembly/evolver.py b/apps/api/src/cora/equipment/aggregates/assembly/evolver.py new file mode 100644 index 000000000..72cd6ebbe --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/assembly/evolver.py @@ -0,0 +1,115 @@ +"""Evolver: replay events to reconstruct Assembly state. + +Status mapping per event type: + - `AssemblyDefined` -> DEFINED (genesis) + - `AssemblyVersioned` -> VERSIONED (re-attestation allowed; + multiple versions on one stream) + - `AssemblyDeprecated` -> DEPRECATED (terminal) + +The status mapping is hardcoded per match arm; the event type IS +the status-change indicator (no status field in event payloads). +Same precedent as Mount / Frame / Asset / Family. + +`AssemblyVersioned` REPLACES the structural fields (slots / wires / +parameter_overrides_schema / drawing / version / content_hash) with +the new snapshot's values. The previous-snapshot fingerprint is +carried on the event itself as `previous_content_hash` for audit; +the evolver does not retain prior snapshots in state (the event +stream IS the revision history). + +**Critical invariant**: every transition arm MUST construct the +full Assembly dataclass; partial construction relies on dataclass +defaults that would silently zero out unchanged fields. `id` and +`presents_as_family_id` are immutable across the lifecycle. + +Transition events applied to empty state raise ValueError: they +can never appear before `AssemblyDefined` in a well-formed stream. +""" + +from collections.abc import Sequence +from typing import assert_never + +from cora.equipment.aggregates.assembly.events import ( + AssemblyDefined, + AssemblyDeprecated, + AssemblyEvent, + AssemblyVersioned, +) +from cora.equipment.aggregates.assembly.state import Assembly, AssemblyStatus +from cora.infrastructure.evolver import require_state + + +def evolve(state: Assembly | None, event: AssemblyEvent) -> Assembly: + """Apply one event to the current state.""" + match event: + case AssemblyDefined( + assembly_id=assembly_id, + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=required_slots, + required_wires=required_wires, + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing, + version=version, + content_hash=content_hash, + ): + _ = state # AssemblyDefined is genesis; prior state ignored + return Assembly( + id=assembly_id, + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=required_slots, + required_wires=required_wires, + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing, + status=AssemblyStatus.DEFINED, + version=version, + content_hash=content_hash, + ) + case AssemblyVersioned( + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=required_slots, + required_wires=required_wires, + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing, + version=version, + content_hash=content_hash, + ): + prior = require_state(state, "AssemblyVersioned") + return Assembly( + id=prior.id, + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=required_slots, + required_wires=required_wires, + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing, + status=AssemblyStatus.VERSIONED, + version=version, + content_hash=content_hash, + ) + case AssemblyDeprecated(): + prior = require_state(state, "AssemblyDeprecated") + return Assembly( + id=prior.id, + name=prior.name, + presents_as_family_id=prior.presents_as_family_id, + required_slots=prior.required_slots, + required_wires=prior.required_wires, + parameter_overrides_schema=prior.parameter_overrides_schema, + drawing=prior.drawing, + status=AssemblyStatus.DEPRECATED, + version=prior.version, + content_hash=prior.content_hash, + ) + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def fold(events: Sequence[AssemblyEvent]) -> Assembly | None: + """Replay a stream of events from the empty initial state.""" + state: Assembly | None = None + for event in events: + state = evolve(state, event) + return state diff --git a/apps/api/src/cora/equipment/aggregates/assembly/read.py b/apps/api/src/cora/equipment/aggregates/assembly/read.py new file mode 100644 index 000000000..c2070d9cd --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/assembly/read.py @@ -0,0 +1,23 @@ +"""Read repository for the Assembly aggregate. + +`load_assembly(event_store, assembly_id) -> Assembly | None` mirrors +`load_mount` / `load_frame` / `load_asset`. Used by update-style +commands (`version_assembly`, `deprecate_assembly`, +`instantiate_assembly`) that need to load + fold before deciding. +""" + +from uuid import UUID + +from cora.equipment.aggregates.assembly.events import from_stored +from cora.equipment.aggregates.assembly.evolver import fold +from cora.equipment.aggregates.assembly.state import Assembly +from cora.infrastructure.ports import EventStore + +_STREAM_TYPE = "Assembly" + + +async def load_assembly(event_store: EventStore, assembly_id: UUID) -> Assembly | None: + """Load and fold an Assembly's event stream into current state.""" + stored, _version = await event_store.load(_STREAM_TYPE, assembly_id) + events = [from_stored(s) for s in stored] + return fold(events) diff --git a/apps/api/src/cora/equipment/aggregates/assembly/state.py b/apps/api/src/cora/equipment/aggregates/assembly/state.py new file mode 100644 index 000000000..04c06360d --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/assembly/state.py @@ -0,0 +1,628 @@ +"""Assembly aggregate state, value objects, status enum, and domain errors. + +An `Assembly` is a content-addressed composition blueprint for a +reusable cluster of Assets (e.g., the MCTOptics fixture at APS 2-BM: +microscope + 3 objectives + camera + scintillator, wired together). +Declares `required_slots` (Family-typed, cardinality-annotated, +optionally pre-Placed) and `required_wires` (slot-keyed 4-tuples). +Exposes a stable `presents_as_family_id` so other aggregates +(Method.needed_families, Capability bindings) can treat an +instantiated Assembly as one typed unit at the same level as a +single Asset. + +See `project_assembly_aggregate_design` for the locked design memo. + +## Identity + +`id` is the opaque UUID (CORA's standard internal id) used for +event-store stream keying. `name` is a human-readable AssemblyName +(non-unique). `content_hash` is the structural fingerprint +(SHA-256 hex over the canonical subset +`{name, presents_as_family_id, required_slots, required_wires, +parameter_overrides_schema}`); two operators independently authoring +the same Assembly converge on the same hash. + +## Slot keying + +`required_slots: frozenset[TemplateSlot]` and `required_wires: +frozenset[TemplateWire]` BOTH key by `slot_name` (string), NOT by +Asset UUID. Reason: an Assembly is a template; the Assets it +references do not exist at template-definition time. Slot-to-asset +translation happens at `instantiate_assembly` time. +This inverts the timing of Plan.wires validation, which has access +to concrete Asset.ports and so enforces direction + signal-type + +fan-in at write time. + +## Internal closure + +`Assembly.__post_init__` enforces that every `TemplateWire`'s +endpoints reference a slot present in `required_slots`. This is the +structural well-formedness check; direction / signal_type / fan-in +rules live at instantiate time when concrete Assets are bound. + +## Revision lineage + +`status` is the AssemblyStatus FSM (Defined / Versioned / +Deprecated). `version` is an operator-curated free-form label +(no SemVer enforcement). Multiple AssemblyVersioned events on the +same stream are permitted; each writes a fresh content_hash +snapshot. Mirrors CalibrationRevision's append-only revisions. + +## Drawing + +`drawing` is the optional engineering reference for the assembly +itself (ICMS or DOI). Excluded from the content_hash canonical +subset because it is operator-curatorial metadata, not structural +identity: two Assemblies with identical structure but different +drawings collide on content_hash, which is the intended semantic. + +## Bounded VOs + +`AssemblyName` is trimmed 1-200 chars (matches Family/Mount). +`SlotName` is trimmed 1-100 chars (matches Wire port-name bound). +Both raise dedicated InvalidXError classes on violation. +""" + +import json +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any +from uuid import UUID + +from cora.equipment.aggregates._drawing import Drawing +from cora.equipment.aggregates._placement import Placement +from cora.infrastructure.bounded_text import validate_bounded_text + +ASSEMBLY_NAME_MAX_LENGTH = 200 +SLOT_NAME_MAX_LENGTH = 100 +WIRE_PORT_NAME_MAX_LENGTH = 100 + + +class AssemblyStatus(StrEnum): + """The Assembly's lifecycle state. + + Template-shaped FSM matching CalibrationRevision and the six + template aggregates per project_template_aggregate_timestamps. + """ + + DEFINED = "Defined" + VERSIONED = "Versioned" + DEPRECATED = "Deprecated" + + +class SlotCardinality(StrEnum): + """How many Assets can fill a slot at instantiation time. + + Closed enum: adding a fifth member is a deliberate widen, not + an additive default. Numeric bounds (`AtLeast2`, `AtMost3`) are + explicitly out of scope to keep the closed-enum discipline. + """ + + EXACTLY_1 = "Exactly1" + ZERO_OR_ONE = "ZeroOrOne" + ONE_OR_MORE = "OneOrMore" + ZERO_OR_MORE = "ZeroOrMore" + + +class InvalidAssemblyNameError(ValueError): + """The supplied Assembly name is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Assembly name must be 1-{ASSEMBLY_NAME_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidSlotNameError(ValueError): + """The supplied slot name is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"TemplateSlot slot_name must be 1-{SLOT_NAME_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidSlotCardinalityError(ValueError): + """The supplied cardinality is not a SlotCardinality member. + + Closed enum violation; mapped to HTTP 400 by the BC exception + handler. The boundary Pydantic layer rejects 422 before this + fires; the domain error exists for in-code constructors. + """ + + def __init__(self, value: str) -> None: + super().__init__( + f"TemplateSlot cardinality must be one of " + f"{[c.value for c in SlotCardinality]} (got: {value!r})" + ) + self.value = value + + +class InvalidWireSpecError(ValueError): + """A TemplateWire is structurally malformed. + + Failure modes: + - Any of the 4 string fields fails the trim-and-bound check + (1-100 chars after trimming). + - The degenerate full-loop case: same slot AND same port on + both endpoints (mirrors PlanWireSelfLoopError). Self-slot + with DIFFERENT ports is allowed (PandABox LUT pattern). + + Direction, signal_type, and fan-in are NOT checked here; those + fire at `instantiate_assembly` time against materialized + Asset.ports. + """ + + def __init__(self, reason: str) -> None: + super().__init__(f"Invalid TemplateWire: {reason}") + self.reason = reason + + +class InvalidTemplateSlotError(ValueError): + """A TemplateSlot is structurally malformed. + + Failure modes: + - `required_family_ids` is empty (a slot must require at least + one Family for instantiation-time validation to mean anything). + + Distinct from InvalidWireSpecError so the route's exception + handler can diverge if needed; today both map to HTTP 400. + """ + + def __init__(self, reason: str) -> None: + super().__init__(f"Invalid TemplateSlot: {reason}") + self.reason = reason + + +class WireReferencesUnknownSlotError(ValueError): + """A TemplateWire endpoint names a slot absent from `required_slots`. + + Structural well-formedness internal to the Assembly definition. + The slot-name set is closed by the same construction call, so a + missing reference is a typo or design mistake, not a cross-aggregate + eventual-consistency case. + """ + + def __init__(self, slot_name: str) -> None: + super().__init__( + f"TemplateWire references slot {slot_name!r}, not present in required_slots" + ) + self.slot_name = slot_name + + +class InvalidParameterOverridesSchemaError(ValueError): + """The supplied parameter_overrides_schema is not a valid JSON + Schema in CORA's constrained subset. + + Mapped to HTTP 422 by the BC exception handler. + """ + + def __init__(self, reason: str) -> None: + super().__init__(f"Invalid Assembly parameter_overrides_schema: {reason}") + self.reason = reason + + +class AssemblyAlreadyExistsError(Exception): + """Attempted to define an assembly whose stream already has events.""" + + def __init__(self, assembly_id: UUID) -> None: + super().__init__(f"Assembly {assembly_id} already exists") + self.assembly_id = assembly_id + + +class AssemblyNotFoundError(Exception): + """Attempted an operation on an assembly whose stream has no events.""" + + def __init__(self, assembly_id: UUID) -> None: + super().__init__(f"Assembly {assembly_id} not found") + self.assembly_id = assembly_id + + +class AssemblyCannotVersionError(Exception): + """Attempted to version a Deprecated assembly. + + New revisions of a Deprecated Assembly must fork via + `define_assembly` with a fresh id; the existing stream stays + terminal. + """ + + def __init__(self, assembly_id: UUID, reason: str) -> None: + super().__init__(f"Assembly {assembly_id} cannot be versioned: {reason}") + self.assembly_id = assembly_id + self.reason = reason + + +class AssemblyCannotDeprecateError(Exception): + """Attempted to deprecate an already-Deprecated assembly. + + Strict-not-idempotent; the second call raises. Mirrors + MountCannotDecommissionError. + """ + + def __init__(self, assembly_id: UUID, reason: str) -> None: + super().__init__(f"Assembly {assembly_id} cannot be deprecated: {reason}") + self.assembly_id = assembly_id + self.reason = reason + + +class AssemblyCannotInstantiateError(Exception): + """Attempted to instantiate a Deprecated assembly.""" + + def __init__(self, assembly_id: UUID, reason: str) -> None: + super().__init__(f"Assembly {assembly_id} cannot be instantiated: {reason}") + self.assembly_id = assembly_id + self.reason = reason + + +class FamilyNotFoundForAssemblyError(Exception): + """A FamilyId referenced by `presents_as_family_id` or by a + TemplateSlot's `required_family_ids` does not resolve to a defined + Family. + + Handler-side projection check (mirrors Plan binding's existence + checks). Distinct from the Recipe BC's FamilyNotFoundError so the + route mapping can diverge if needed; today both map to 404. + """ + + def __init__(self, family_id: UUID) -> None: + super().__init__(f"Family {family_id} not found") + self.family_id = family_id + + +class AssemblyInstantiationMappingIncompleteError(Exception): + """`instantiate_assembly`'s slot_to_asset_mapping does not satisfy + the required cardinality of one or more slots. + + Example failure modes: + - An `Exactly1` slot has zero or two mapped Assets. + - A `OneOrMore` slot has zero mapped Assets. + """ + + def __init__(self, slot_name: str, reason: str) -> None: + super().__init__(f"Slot {slot_name!r} cardinality not satisfied at instantiation: {reason}") + self.slot_name = slot_name + self.reason = reason + + +class AssemblyInstantiationAssetFamilyMismatchError(Exception): + """A mapped Asset's `families` do not intersect the TemplateSlot's + `required_family_ids`.""" + + def __init__(self, slot_name: str, asset_id: UUID) -> None: + super().__init__( + f"Asset {asset_id} mapped to slot {slot_name!r} does not carry " + f"any of the slot's required_family_ids" + ) + self.slot_name = slot_name + self.asset_id = asset_id + + +class AssemblyInstantiationParameterOverridesInvalidError(Exception): + """`instantiate_assembly`'s parameter_overrides dict fails the + Assembly's parameter_overrides_schema validation.""" + + def __init__(self, reason: str) -> None: + super().__init__(f"Assembly parameter_overrides invalid: {reason}") + self.reason = reason + + +@dataclass(frozen=True) +class AssemblyName: + """A trimmed-bounded-text VO for the Assembly's display name. + + 1-200 chars after trimming. Non-unique; the UUID is the storage + identity and the content_hash is the structural fingerprint. + """ + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=ASSEMBLY_NAME_MAX_LENGTH, + error_class=InvalidAssemblyNameError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class SlotName: + """A trimmed-bounded-text VO for a TemplateSlot's slot_name. + + 1-100 chars after trimming. Bound mirrors Wire port-name to keep + a single max-length convention across slot-keyed shapes. + """ + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=SLOT_NAME_MAX_LENGTH, + error_class=InvalidSlotNameError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class TemplateSlot: + """One slot in an Assembly's composition blueprint. + + `slot_name` is the canonical slot identity within this Assembly + (string, 1-100 chars). `required_family_ids` is the non-empty set + of FamilyIds any instantiated Asset must include at least one of. + `cardinality` says how many Assets can fill this slot + (Exactly1 / ZeroOrOne / OneOrMore / ZeroOrMore). `default_settings` + and `default_placement` are optional template defaults applied + at instantiation unless overridden. + + `default_settings` validation against the intersection of all + required_family_ids' settings schemas runs at define_assembly time + (handler-side, not in __post_init__) because it requires loading + Family records. The VO's __post_init__ only enforces structural + well-formedness. + """ + + slot_name: SlotName + required_family_ids: frozenset[UUID] + cardinality: SlotCardinality + default_settings: dict[str, Any] | None = None + default_placement: Placement | None = None + + def __post_init__(self) -> None: + # Defensive: cardinality is annotated as SlotCardinality but + # nothing in Python stops a caller from passing a raw string + # at construction. Catching it here surfaces a typed domain + # error rather than a downstream AttributeError on .value. + if not isinstance(self.cardinality, SlotCardinality): # pyright: ignore[reportUnnecessaryIsInstance] + raise InvalidSlotCardinalityError(str(self.cardinality)) + if not self.required_family_ids: + raise InvalidTemplateSlotError( + f"TemplateSlot {self.slot_name.value!r} requires at least one Family" + ) + + def __hash__(self) -> int: + # TemplateSlot is frozen but its `default_settings` field is a + # dict (unhashable by Python default). Required because every + # Assembly carries `required_slots: frozenset[TemplateSlot]`. + # Hash canonicalizes the dict portion via stdlib json sort-keys + # to give a stable, deterministic hash independent of insertion + # order; full-record __eq__ stays dataclass-generated. + settings_key = ( + json.dumps(self.default_settings, sort_keys=True, separators=(",", ":")) + if self.default_settings is not None + else None + ) + return hash( + ( + self.slot_name, + self.required_family_ids, + self.cardinality, + settings_key, + self.default_placement, + ) + ) + + +@dataclass(frozen=True) +class TemplateWire: + """A typed slot-to-slot connection in an Assembly's blueprint. + + Tuple `(source_slot_name, source_port_name, target_slot_name, + target_port_name)` describes one connection. The 4-tuple IS the + identity; `frozenset[TemplateWire]` deduplicates on the tuple. + + Mirrors `Wire` (the Plan-tier signal-routing VO) in shape but + keys by slot_name strings rather than Asset UUIDs because an + Assembly cannot reference Assets that do not exist yet at + template-definition time. + + Validation rules at instantiation time (NOT here): + - source port must have `direction=OUTPUT` + - target port must have `direction=INPUT` + - `source_port.signal_type == target_port.signal_type` + - target port is the destination of at most one Wire (fan-in + forbidden); fan-out (one source to many targets) is allowed + + `__post_init__` enforces structural shape only: each of the four + string fields trims and bounds 1-100 chars, and the degenerate + full-loop case (same slot AND same port on both endpoints) is + rejected. Self-slot wires with different port names are allowed + (PandABox LUT pattern, mirroring the Plan invariant). + + Cross-wire closure (every slot_name must exist in the parent + Assembly's `required_slots`) is enforced at the Assembly level + via `WireReferencesUnknownSlotError`, not here. + """ + + source_slot_name: str + source_port_name: str + target_slot_name: str + target_port_name: str + + def __post_init__(self) -> None: + for label, value in ( + ("source_slot_name", self.source_slot_name), + ("source_port_name", self.source_port_name), + ("target_slot_name", self.target_slot_name), + ("target_port_name", self.target_port_name), + ): + trimmed = value.strip() + if not trimmed: + raise InvalidWireSpecError(f"{label} cannot be empty after trimming") + if len(trimmed) > WIRE_PORT_NAME_MAX_LENGTH: + raise InvalidWireSpecError( + f"{label} must be 1-{WIRE_PORT_NAME_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + object.__setattr__(self, label, trimmed) + if ( + self.source_slot_name == self.target_slot_name + and self.source_port_name == self.target_port_name + ): + raise InvalidWireSpecError( + f"degenerate full self-loop on slot {self.source_slot_name!r} " + f"port {self.source_port_name!r}" + ) + + +@dataclass(frozen=True) +class Assembly: + """Aggregate root: a reusable composition blueprint. + + `id` is the opaque UUID stream key; `name` is the human-readable + AssemblyName. `presents_as_family_id` is the FamilyId the + instantiated Assembly looks like to Method.needed_families and + Plan binding (one Asset-shaped unit). + + `required_slots` and `required_wires` together describe the + composition: slots declare what kinds of Assets fill which roles, + wires declare how those Assets connect. Both key by slot_name + (not by Asset UUID) since Assets do not exist at template time. + + `parameter_overrides_schema` is an optional JSON Schema subset + declaring the shape of parameter_overrides accepted at + instantiation. `drawing` is the optional engineering reference. + + `status` transitions Defined -> Versioned (multiple times) -> + Deprecated. `version` is an operator-curated label. `content_hash` + is the SHA-256 hex fingerprint of the canonical subset + `{name, presents_as_family_id, required_slots, required_wires, + parameter_overrides_schema}` (excludes id / drawing / version / + status, which are not structural identity per the design memo). + + `__post_init__` enforces internal closure: every TemplateWire's + source_slot_name and target_slot_name MUST appear in + `required_slots`. Cross-aggregate references (FamilyId existence, + schema validation) live in handler-side projection checks. + """ + + id: UUID + name: AssemblyName + presents_as_family_id: UUID + required_slots: frozenset[TemplateSlot] = field(default_factory=frozenset[TemplateSlot]) + required_wires: frozenset[TemplateWire] = field(default_factory=frozenset[TemplateWire]) + parameter_overrides_schema: dict[str, Any] | None = None + drawing: Drawing | None = None + status: AssemblyStatus = AssemblyStatus.DEFINED + version: str | None = None + content_hash: str | None = None + + def __post_init__(self) -> None: + slot_names = {slot.slot_name.value for slot in self.required_slots} + for wire in self.required_wires: + if wire.source_slot_name not in slot_names: + raise WireReferencesUnknownSlotError(wire.source_slot_name) + if wire.target_slot_name not in slot_names: + raise WireReferencesUnknownSlotError(wire.target_slot_name) + + def content_subset(self) -> dict[str, object]: + """Canonical content subset hashed into `content_hash`. + + Pins identity per `project_content_addressed_identity_design`: + `name + presents_as_family_id + required_slots + required_wires + + parameter_overrides_schema`. Excluded: `id` (identity, not + content), `status` and `version` (lifecycle, derived in evolver + from event type and version label), `drawing` (operator- + curatorial metadata per the design memo's content_hash + composition lock), `content_hash` itself (cannot self-contain). + + Delegates to `canonical_assembly_subset` so the helper used by + `compute_assembly_content_hash` (raw-args path) and this + method (state path) materialize the same shape. Field + addition lands in ONE place; drift between the two paths + becomes structurally impossible. + """ + return canonical_assembly_subset( + name=self.name, + presents_as_family_id=self.presents_as_family_id, + required_slots=self.required_slots, + required_wires=self.required_wires, + parameter_overrides_schema=self.parameter_overrides_schema, + ) + + +def canonical_assembly_subset( + *, + name: "AssemblyName | str", + presents_as_family_id: UUID, + required_slots: frozenset[TemplateSlot], + required_wires: frozenset[TemplateWire], + parameter_overrides_schema: dict[str, object] | None, +) -> dict[str, object]: + """Materialize the canonical content subset of an Assembly. + + Single source of truth for the structural-identity body. Called by + both `Assembly.content_subset()` (state path) and + `compute_assembly_content_hash` (raw-args path); the round-trip + equivalence between the two is pinned in tests. + + Slots render as a list of dicts sorted by slot_name; wires render + as sorted 4-tuples-of-strings for canonical-sort determinism. + UUIDs render as strings. Adding a new identity-bearing field + requires editing this one function plus the content_hash + differs-test corpus. + """ + name_value = name.value if isinstance(name, AssemblyName) else name + return { + "name": name_value, + "presents_as_family_id": str(presents_as_family_id), + "required_slots": sorted( + ( + { + "slot_name": slot.slot_name.value, + "required_family_ids": sorted(str(f) for f in slot.required_family_ids), + "cardinality": slot.cardinality.value, + "default_settings": slot.default_settings, + "default_placement": ( + canonical_placement_subset(slot.default_placement) + if slot.default_placement is not None + else None + ), + } + for slot in required_slots + ), + key=lambda d: str(d["slot_name"]), + ), + "required_wires": sorted( + ( + wire.source_slot_name, + wire.source_port_name, + wire.target_slot_name, + wire.target_port_name, + ) + for wire in required_wires + ), + "parameter_overrides_schema": parameter_overrides_schema, + } + + +def canonical_placement_subset(placement: Placement) -> dict[str, object]: + """Canonical dict form of a Placement for content_hash inclusion. + + Distinct from `placement_to_payload` (which is the JSON-storage + codec on the Placement VO module): the canonical-subset form + excludes nothing today but reserves the freedom to drop or rename + fields without disturbing the at-rest event schema. + """ + return { + "x": placement.x, + "y": placement.y, + "z": placement.z, + "rx": placement.rx, + "ry": placement.ry, + "rz": placement.rz, + "parent_frame_id": str(placement.parent_frame_id), + "reference_surface": placement.reference_surface.value, + "tol_x": placement.tol_x, + "tol_y": placement.tol_y, + "tol_z": placement.tol_z, + "tol_rx": placement.tol_rx, + "tol_ry": placement.tol_ry, + "tol_rz": placement.tol_rz, + "units": placement.units.value, + } diff --git a/apps/api/src/cora/equipment/aggregates/frame/events.py b/apps/api/src/cora/equipment/aggregates/frame/events.py index d4a2494b4..224666b9d 100644 --- a/apps/api/src/cora/equipment/aggregates/frame/events.py +++ b/apps/api/src/cora/equipment/aggregates/frame/events.py @@ -53,64 +53,25 @@ from typing import Any, assert_never from uuid import UUID -from cora.equipment.aggregates._placement import Placement, ReferenceSurface, UnitSystem +from cora.equipment.aggregates._placement import ( + Placement, + placement_from_payload, + placement_to_payload, +) from cora.equipment.aggregates.frame.state import FrameRevisionLink from cora.infrastructure.event_payload import deserialize_or_raise from cora.infrastructure.ports.event_store import StoredEvent - -def _placement_to_payload(placement: Placement) -> dict[str, Any]: - """Serialize a Placement VO to a JSON-friendly dict.""" - return { - "x": placement.x, - "y": placement.y, - "z": placement.z, - "rx": placement.rx, - "ry": placement.ry, - "rz": placement.rz, - "parent_frame_id": str(placement.parent_frame_id), - "reference_surface": placement.reference_surface.value, - "tol_x": placement.tol_x, - "tol_y": placement.tol_y, - "tol_z": placement.tol_z, - "tol_rx": placement.tol_rx, - "tol_ry": placement.tol_ry, - "tol_rz": placement.tol_rz, - "units": placement.units.value, - } - - -def _placement_from_payload(payload: dict[str, Any]) -> Placement: - """Reconstruct a Placement VO from its JSON payload. - - Raises KeyError / TypeError / AttributeError on malformed input; - callers wrap these into tagged ValueError per the from_stored - convention. - """ - return Placement( - x=payload["x"], - y=payload["y"], - z=payload["z"], - rx=payload["rx"], - ry=payload["ry"], - rz=payload["rz"], - parent_frame_id=UUID(payload["parent_frame_id"]), - reference_surface=ReferenceSurface(payload["reference_surface"]), - tol_x=payload["tol_x"], - tol_y=payload["tol_y"], - tol_z=payload["tol_z"], - tol_rx=payload["tol_rx"], - tol_ry=payload["tol_ry"], - tol_rz=payload["tol_rz"], - units=UnitSystem(payload["units"]), - ) +# Codec helpers (placement_to/from_payload) imported above from the +# shared VO module per the codec-helper-duplication anti-hook in +# project_mount_frame_design Watch items. def _frame_revision_link_to_payload(link: FrameRevisionLink) -> dict[str, Any]: """Serialize a FrameRevisionLink VO to a JSON-friendly dict.""" return { "predecessor_frame_id": str(link.predecessor_frame_id), - "transform_from_predecessor": _placement_to_payload(link.transform_from_predecessor), + "transform_from_predecessor": placement_to_payload(link.transform_from_predecessor), } @@ -123,7 +84,7 @@ def _frame_revision_link_from_payload(payload: dict[str, Any]) -> FrameRevisionL """ return FrameRevisionLink( predecessor_frame_id=UUID(payload["predecessor_frame_id"]), - transform_from_predecessor=_placement_from_payload(payload["transform_from_predecessor"]), + transform_from_predecessor=placement_from_payload(payload["transform_from_predecessor"]), ) @@ -220,7 +181,7 @@ def to_payload(event: FrameEvent) -> dict[str, Any]: "frame_id": str(frame_id), "name": name, "parent_frame_id": (str(parent_frame_id) if parent_frame_id is not None else None), - "placement": (_placement_to_payload(placement) if placement is not None else None), + "placement": (placement_to_payload(placement) if placement is not None else None), "supersedes": ( _frame_revision_link_to_payload(supersedes) if supersedes is not None else None ), @@ -234,7 +195,7 @@ def to_payload(event: FrameEvent) -> dict[str, Any]: ): return { "frame_id": str(frame_id), - "new_placement": _placement_to_payload(new_placement), + "new_placement": placement_to_payload(new_placement), "survey": survey, "occurred_at": occurred_at.isoformat(), } @@ -274,9 +235,7 @@ def _build_registered() -> FrameRegistered: name=payload["name"], parent_frame_id=UUID(raw_parent) if raw_parent is not None else None, placement=( - _placement_from_payload(raw_placement) - if raw_placement is not None - else None + placement_from_payload(raw_placement) if raw_placement is not None else None ), supersedes=( _frame_revision_link_from_payload(raw_supersedes) @@ -292,7 +251,7 @@ def _build_registered() -> FrameRegistered: "FramePlacementUpdated", lambda: FramePlacementUpdated( frame_id=UUID(payload["frame_id"]), - new_placement=_placement_from_payload(payload["new_placement"]), + new_placement=placement_from_payload(payload["new_placement"]), survey=payload.get("survey"), occurred_at=datetime.fromisoformat(payload["occurred_at"]), ), diff --git a/apps/api/src/cora/equipment/aggregates/mount/events.py b/apps/api/src/cora/equipment/aggregates/mount/events.py index fa8614e4c..dd621d142 100644 --- a/apps/api/src/cora/equipment/aggregates/mount/events.py +++ b/apps/api/src/cora/equipment/aggregates/mount/events.py @@ -47,74 +47,22 @@ from typing import Any, assert_never from uuid import UUID -from cora.equipment.aggregates._drawing import Drawing, DrawingSystem +from cora.equipment.aggregates._drawing import ( + Drawing, + drawing_from_payload, + drawing_to_payload, +) from cora.equipment.aggregates._placement import ( Placement, - ReferenceSurface, - UnitSystem, + placement_from_payload, + placement_to_payload, ) from cora.infrastructure.event_payload import deserialize_or_raise from cora.infrastructure.ports.event_store import StoredEvent - -def _placement_to_payload(placement: Placement) -> dict[str, Any]: - """Serialize a Placement VO to a JSON-friendly dict.""" - return { - "x": placement.x, - "y": placement.y, - "z": placement.z, - "rx": placement.rx, - "ry": placement.ry, - "rz": placement.rz, - "parent_frame_id": str(placement.parent_frame_id), - "reference_surface": placement.reference_surface.value, - "tol_x": placement.tol_x, - "tol_y": placement.tol_y, - "tol_z": placement.tol_z, - "tol_rx": placement.tol_rx, - "tol_ry": placement.tol_ry, - "tol_rz": placement.tol_rz, - "units": placement.units.value, - } - - -def _placement_from_payload(payload: dict[str, Any]) -> Placement: - """Reconstruct a Placement VO from its JSON payload.""" - return Placement( - x=payload["x"], - y=payload["y"], - z=payload["z"], - rx=payload["rx"], - ry=payload["ry"], - rz=payload["rz"], - parent_frame_id=UUID(payload["parent_frame_id"]), - reference_surface=ReferenceSurface(payload["reference_surface"]), - tol_x=payload["tol_x"], - tol_y=payload["tol_y"], - tol_z=payload["tol_z"], - tol_rx=payload["tol_rx"], - tol_ry=payload["tol_ry"], - tol_rz=payload["tol_rz"], - units=UnitSystem(payload["units"]), - ) - - -def _drawing_to_payload(drawing: Drawing) -> dict[str, Any]: - """Serialize a Drawing VO to a JSON-friendly dict.""" - return { - "system": drawing.system.value, - "number": drawing.number, - "revision": drawing.revision, - } - - -def _drawing_from_payload(payload: dict[str, Any]) -> Drawing: - """Reconstruct a Drawing VO from its JSON payload.""" - return Drawing( - system=DrawingSystem(payload["system"]), - number=payload["number"], - revision=payload.get("revision"), - ) +# Codec helpers (placement_to/from_payload, drawing_to/from_payload) are +# imported above from the shared VO modules per the codec-helper- +# duplication anti-hook in project_mount_frame_design Watch items. @dataclass(frozen=True) @@ -244,8 +192,8 @@ def to_payload(event: MountEvent) -> dict[str, Any]: "mount_id": str(mount_id), "slot_code": slot_code, "parent_mount_id": (str(parent_mount_id) if parent_mount_id is not None else None), - "placement": _placement_to_payload(placement), - "drawing": (_drawing_to_payload(drawing) if drawing is not None else None), + "placement": placement_to_payload(placement), + "drawing": (drawing_to_payload(drawing) if drawing is not None else None), "occurred_at": occurred_at.isoformat(), } case MountDecommissioned( @@ -266,7 +214,7 @@ def to_payload(event: MountEvent) -> dict[str, Any]: ): return { "mount_id": str(mount_id), - "new_placement": _placement_to_payload(new_placement), + "new_placement": placement_to_payload(new_placement), "survey": survey, "occurred_at": occurred_at.isoformat(), } @@ -318,9 +266,9 @@ def _build_registered() -> MountRegistered: mount_id=UUID(payload["mount_id"]), slot_code=payload["slot_code"], parent_mount_id=UUID(raw_parent) if raw_parent is not None else None, - placement=_placement_from_payload(payload["placement"]), + placement=placement_from_payload(payload["placement"]), drawing=( - _drawing_from_payload(raw_drawing) if raw_drawing is not None else None + drawing_from_payload(raw_drawing) if raw_drawing is not None else None ), occurred_at=datetime.fromisoformat(payload["occurred_at"]), ) @@ -340,7 +288,7 @@ def _build_registered() -> MountRegistered: "MountPlacementUpdated", lambda: MountPlacementUpdated( mount_id=UUID(payload["mount_id"]), - new_placement=_placement_from_payload(payload["new_placement"]), + new_placement=placement_from_payload(payload["new_placement"]), survey=payload.get("survey"), occurred_at=datetime.fromisoformat(payload["occurred_at"]), ), diff --git a/apps/api/src/cora/equipment/projections/__init__.py b/apps/api/src/cora/equipment/projections/__init__.py index dfa442d05..e0e45c21d 100644 --- a/apps/api/src/cora/equipment/projections/__init__.py +++ b/apps/api/src/cora/equipment/projections/__init__.py @@ -5,6 +5,7 @@ re-exporting its class + adding it to `register_equipment_projections`. """ +from cora.equipment.projections.assembly_summary import AssemblySummaryProjection from cora.equipment.projections.asset import AssetSummaryProjection from cora.equipment.projections.asset_family_membership import ( AssetFamilyMembershipProjection, @@ -20,6 +21,7 @@ from cora.equipment.projections.mount_summary import MountSummaryProjection __all__ = [ + "AssemblySummaryProjection", "AssetFamilyMembershipProjection", "AssetLocationProjection", "AssetSummaryProjection", diff --git a/apps/api/src/cora/equipment/projections/assembly_summary.py b/apps/api/src/cora/equipment/projections/assembly_summary.py new file mode 100644 index 000000000..c5126a08b --- /dev/null +++ b/apps/api/src/cora/equipment/projections/assembly_summary.py @@ -0,0 +1,66 @@ +"""AssemblySummaryProjection: folds the Assembly aggregate's lifecycle +events into the `proj_equipment_assembly_summary` read model. + +v1 ships ONLY the `AssemblyDefined` arm. The `AssemblyVersioned` +and `AssemblyDeprecated` arms land with their respective slices +to keep the slice-per-commit gate-review discipline intact (no +projector arms without a matching emitter slice). + +Subscribed events (v1, scaffold): + - AssemblyDefined -> INSERT (status=Defined, version=NULL on + payload absence; content_hash from payload) + +All branches idempotent (INSERT uses ON CONFLICT DO NOTHING). +Mirrors FamilySummaryProjection's shape. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +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["assembly_id"])) + + +_INSERT_ASSEMBLY_SQL = """ +INSERT INTO proj_equipment_assembly_summary + (assembly_id, name, presents_as_family_id, status, version, + content_hash, created_at) +VALUES ($1, $2, $3, 'Defined', $4, $5, $6) +ON CONFLICT (assembly_id) DO NOTHING +""" + + +class AssemblySummaryProjection: + """Maintains the `proj_equipment_assembly_summary` read model.""" + + name = "proj_equipment_assembly_summary" + subscribed_event_types = frozenset({"AssemblyDefined"}) + + async def apply( + self, + event: StoredEvent, + conn: ConnectionLike, + ) -> None: + match event.event_type: + case "AssemblyDefined": + payload = event.payload + await conn.execute( + _INSERT_ASSEMBLY_SQL, + _id(payload), + payload["name"], + UUID(str(payload["presents_as_family_id"])), + payload.get("version"), + payload["content_hash"], + datetime.fromisoformat(str(payload["occurred_at"])), + ) + case _: + pass + + +__all__ = ["AssemblySummaryProjection"] diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index fdaa4a97c..46a4572c1 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -37,6 +37,24 @@ from cora.equipment.aggregates._drawing import InvalidDrawingError from cora.equipment.aggregates._placement import InvalidPlacementError +from cora.equipment.aggregates.assembly import ( + AssemblyAlreadyExistsError, + AssemblyCannotDeprecateError, + AssemblyCannotInstantiateError, + AssemblyCannotVersionError, + AssemblyInstantiationAssetFamilyMismatchError, + AssemblyInstantiationMappingIncompleteError, + AssemblyInstantiationParameterOverridesInvalidError, + AssemblyNotFoundError, + FamilyNotFoundForAssemblyError, + InvalidAssemblyNameError, + InvalidParameterOverridesSchemaError, + InvalidSlotCardinalityError, + InvalidSlotNameError, + InvalidTemplateSlotError, + InvalidWireSpecError, + WireReferencesUnknownSlotError, +) from cora.equipment.aggregates.asset import ( AssetAlreadyExistsError, AssetAlternateIdentifierAlreadyPresentError, @@ -290,6 +308,16 @@ def register_equipment_routes(app: FastAPI) -> None: InvalidModelVersionTagError, InvalidModelDeprecationReasonError, InvalidDeclaredFamiliesError, + InvalidAssemblyNameError, + InvalidSlotNameError, + InvalidSlotCardinalityError, + InvalidTemplateSlotError, + InvalidWireSpecError, + WireReferencesUnknownSlotError, + InvalidParameterOverridesSchemaError, + AssemblyInstantiationMappingIncompleteError, + AssemblyInstantiationAssetFamilyMismatchError, + AssemblyInstantiationParameterOverridesInvalidError, ): app.add_exception_handler(validation_cls, _handle_validation_error) for not_found_cls in ( @@ -299,6 +327,8 @@ def register_equipment_routes(app: FastAPI) -> None: MountNotFoundError, AssetNotFoundForMountError, ModelNotFoundError, + AssemblyNotFoundError, + FamilyNotFoundForAssemblyError, ): app.add_exception_handler(not_found_cls, _handle_not_found) for already_exists_cls in ( @@ -307,6 +337,7 @@ def register_equipment_routes(app: FastAPI) -> None: FrameAlreadyExistsError, MountAlreadyExistsError, ModelAlreadyExistsError, + AssemblyAlreadyExistsError, ): app.add_exception_handler(already_exists_cls, _handle_already_exists) for cannot_transition_cls in ( @@ -343,6 +374,9 @@ def register_equipment_routes(app: FastAPI) -> None: ModelCannotRemoveFamilyError, ModelFamilyAlreadyPresentError, ModelFamilyNotPresentError, + AssemblyCannotVersionError, + AssemblyCannotDeprecateError, + AssemblyCannotInstantiateError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py index 20b9674fc..0b79e382f 100644 --- a/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py +++ b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py @@ -46,6 +46,24 @@ _ALLOW_RELATIVE_PATHS: frozenset[str] = frozenset( { "apps/api/tests/architecture/test_no_assembly_asset_level_literal.py", + # The Assembly aggregate legitimately carries the "Assembly" + # token in event_type discriminators (`case "AssemblyDefined":`), + # docstrings, and class names. Added at v1 ship of the + # aggregate; widen the list as new sites land at gate review. + "apps/api/src/cora/equipment/aggregates/_assembly_content_hash.py", + "apps/api/src/cora/equipment/aggregates/assembly/__init__.py", + "apps/api/src/cora/equipment/aggregates/assembly/events.py", + "apps/api/src/cora/equipment/aggregates/assembly/evolver.py", + "apps/api/src/cora/equipment/aggregates/assembly/read.py", + "apps/api/src/cora/equipment/aggregates/assembly/state.py", + "apps/api/src/cora/equipment/projections/assembly_summary.py", + "apps/api/tests/unit/equipment/test_assembly_content_hash.py", + "apps/api/tests/unit/equipment/test_assembly_events.py", + "apps/api/tests/unit/equipment/test_assembly_evolver.py", + "apps/api/tests/unit/equipment/test_assembly_state.py", + "apps/api/tests/unit/equipment/test_assembly_summary_projection.py", + "apps/api/tests/unit/equipment/test_assembly_template_slot.py", + "apps/api/tests/unit/equipment/test_assembly_template_wire.py", } ) diff --git a/apps/api/tests/unit/equipment/test_assembly_content_hash.py b/apps/api/tests/unit/equipment/test_assembly_content_hash.py new file mode 100644 index 000000000..31c639f03 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assembly_content_hash.py @@ -0,0 +1,486 @@ +"""Unit tests for the Assembly content_hash helper. + +Pins three key properties: + 1. Determinism: same canonical content produces the same hash. + 2. Order-insensitivity: slot order and wire order do not affect + the hash (because the canonical-subset materializer sorts). + 3. Round-trip equivalence: compute_assembly_content_hash(...) on + raw fields equals compute_assembly_content_hash_from_state(...) + on the corresponding Assembly state. +""" + +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates._assembly_content_hash import ( + compute_assembly_content_hash, + compute_assembly_content_hash_from_state, +) +from cora.equipment.aggregates.assembly import ( + Assembly, + AssemblyName, + SlotCardinality, + SlotName, + TemplateSlot, + TemplateWire, +) + + +def _slot(name: str, family_id: UUID) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + + +def _wire(src_slot: str, tgt_slot: str) -> TemplateWire: + return TemplateWire( + source_slot_name=src_slot, + source_port_name="trigger_out", + target_slot_name=tgt_slot, + target_port_name="trigger_in", + ) + + +@pytest.mark.unit +def test_content_hash_is_sha256_hex_64_chars() -> None: + h = compute_assembly_content_hash( + name="Empty", + presents_as_family_id=uuid4(), + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + assert len(h) == 64 + assert all(c in "0123456789abcdef" for c in h) + + +@pytest.mark.unit +def test_content_hash_is_deterministic_for_same_input() -> None: + name = "Detector" + family_id = uuid4() + slot_family = uuid4() + slots = frozenset({_slot("camera", slot_family)}) + wires = frozenset({_wire("trigger_source", "camera")}) + h1 = compute_assembly_content_hash( + name=name, + presents_as_family_id=family_id, + required_slots=slots, + required_wires=wires, + parameter_overrides_schema={"type": "object"}, + ) + h2 = compute_assembly_content_hash( + name=name, + presents_as_family_id=family_id, + required_slots=slots, + required_wires=wires, + parameter_overrides_schema={"type": "object"}, + ) + assert h1 == h2 + + +@pytest.mark.unit +def test_content_hash_differs_when_name_differs() -> None: + family_id = uuid4() + h1 = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h2 = compute_assembly_content_hash( + name="DetectorV2", + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + assert h1 != h2 + + +@pytest.mark.unit +def test_content_hash_differs_when_presents_as_family_id_differs() -> None: + h1 = compute_assembly_content_hash( + name="X", + presents_as_family_id=uuid4(), + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h2 = compute_assembly_content_hash( + name="X", + presents_as_family_id=uuid4(), + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + assert h1 != h2 + + +@pytest.mark.unit +def test_content_hash_differs_when_slot_added() -> None: + family_id = uuid4() + slot_family = uuid4() + h_empty = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h_with_slot = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset({_slot("camera", slot_family)}), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + assert h_empty != h_with_slot + + +@pytest.mark.unit +def test_content_hash_is_insensitive_to_slot_iteration_order() -> None: + """Building the slot set from a list in two different orders must + produce the same hash (the canonical-subset materializer sorts).""" + family_id = uuid4() + sf_a, sf_b, sf_c = uuid4(), uuid4(), uuid4() + slots_one = frozenset( + { + _slot("camera", sf_a), + _slot("scintillator", sf_b), + _slot("trigger_source", sf_c), + } + ) + slots_two = frozenset( + { + _slot("trigger_source", sf_c), + _slot("camera", sf_a), + _slot("scintillator", sf_b), + } + ) + h1 = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=slots_one, + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h2 = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=slots_two, + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + assert h1 == h2 + + +@pytest.mark.unit +def test_content_hash_differs_when_wire_added() -> None: + family_id = uuid4() + sf_a, sf_b = uuid4(), uuid4() + slots = frozenset({_slot("camera", sf_a), _slot("trigger_source", sf_b)}) + h_no_wires = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=slots, + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h_with_wire = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=slots, + required_wires=frozenset({_wire("trigger_source", "camera")}), + parameter_overrides_schema=None, + ) + assert h_no_wires != h_with_wire + + +@pytest.mark.unit +def test_content_hash_differs_when_parameter_overrides_schema_differs() -> None: + family_id = uuid4() + h_none = compute_assembly_content_hash( + name="X", + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h_simple = compute_assembly_content_hash( + name="X", + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema={"type": "object"}, + ) + h_richer = compute_assembly_content_hash( + name="X", + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema={"type": "object", "additionalProperties": False}, + ) + assert h_none != h_simple + assert h_simple != h_richer + assert h_none != h_richer + + +@pytest.mark.unit +def test_content_hash_differs_when_slot_cardinality_differs() -> None: + family_id = uuid4() + slot_family = uuid4() + slot_exactly_1 = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({slot_family}), + cardinality=SlotCardinality.EXACTLY_1, + ) + slot_zero_or_one = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({slot_family}), + cardinality=SlotCardinality.ZERO_OR_ONE, + ) + h_a = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset({slot_exactly_1}), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h_b = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset({slot_zero_or_one}), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + assert h_a != h_b + + +@pytest.mark.unit +def test_content_hash_differs_when_default_settings_differs() -> None: + family_id = uuid4() + slot_family = uuid4() + slot_low = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({slot_family}), + cardinality=SlotCardinality.EXACTLY_1, + default_settings={"exposure_ms": 100}, + ) + slot_high = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({slot_family}), + cardinality=SlotCardinality.EXACTLY_1, + default_settings={"exposure_ms": 200}, + ) + h_a = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset({slot_low}), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + h_b = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset({slot_high}), + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + assert h_a != h_b + + +@pytest.mark.unit +def test_content_hash_is_insensitive_to_wire_iteration_order() -> None: + """Three wires authored in two different orderings must produce + the same hash; canonical-subset materializer sorts the wire set.""" + family_id = uuid4() + sf_a, sf_b, sf_c, sf_d = uuid4(), uuid4(), uuid4(), uuid4() + slots = frozenset( + { + _slot("trigger_source", sf_a), + _slot("camera", sf_b), + _slot("scintillator", sf_c), + _slot("filter", sf_d), + } + ) + wires_one = frozenset( + { + _wire("trigger_source", "camera"), + _wire("camera", "scintillator"), + _wire("filter", "camera"), + } + ) + wires_two = frozenset( + { + _wire("filter", "camera"), + _wire("trigger_source", "camera"), + _wire("camera", "scintillator"), + } + ) + h1 = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=slots, + required_wires=wires_one, + parameter_overrides_schema=None, + ) + h2 = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=slots, + required_wires=wires_two, + parameter_overrides_schema=None, + ) + assert h1 == h2 + + +@pytest.mark.unit +def test_content_hash_round_trip_via_state_with_default_placement() -> None: + """Round-trip equivalence must hold when slots carry non-None + default_placement. Pins that the Placement subset survives both + paths (state.content_subset and compute_assembly_content_hash) + identically; without this test, drift between + `canonical_placement_subset` and any drift-introduced copy would + be invisible.""" + from cora.equipment.aggregates._placement import ( + Placement, + ReferenceSurface, + UnitSystem, + ) + + family_id = uuid4() + slot_family = uuid4() + frame_id = uuid4() + placement = Placement( + x=12.5, + y=-3.0, + z=27626.0, + rx=0.0, + ry=0.001, + rz=0.0, + parent_frame_id=frame_id, + reference_surface=ReferenceSurface.OPTIC_CENTER, + tol_x=0.25, + tol_y=0.25, + tol_z=5.0, + tol_rx=0.0001, + tol_ry=0.0001, + tol_rz=0.0001, + units=UnitSystem.SI_MM_RAD, + ) + slot_with_placement = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({slot_family}), + cardinality=SlotCardinality.EXACTLY_1, + default_placement=placement, + ) + slots = frozenset({slot_with_placement}) + h_from_args = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=slots, + required_wires=frozenset(), + parameter_overrides_schema=None, + ) + state = Assembly( + id=uuid4(), + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=slots, + required_wires=frozenset(), + ) + h_from_state = compute_assembly_content_hash_from_state(state) + assert h_from_args == h_from_state + + +@pytest.mark.unit +def test_content_hash_round_trip_via_state() -> None: + """compute_assembly_content_hash(args) MUST equal + compute_assembly_content_hash_from_state(Assembly(args)). + + This pins that the two computation paths (raw-args and via + state.content_subset()) materialize the same canonical body. + """ + family_id = uuid4() + slot_family = uuid4() + slots = frozenset({_slot("camera", slot_family)}) + wires = frozenset({_wire("trigger_source", "camera")}) + + extra_slot = _slot("trigger_source", uuid4()) + full_slots = slots | {extra_slot} + + h_from_args = compute_assembly_content_hash( + name="Detector", + presents_as_family_id=family_id, + required_slots=full_slots, + required_wires=wires, + parameter_overrides_schema={"type": "object"}, + ) + state = Assembly( + id=uuid4(), + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=full_slots, + required_wires=wires, + parameter_overrides_schema={"type": "object"}, + ) + h_from_state = compute_assembly_content_hash_from_state(state) + assert h_from_args == h_from_state + + +@pytest.mark.unit +def test_content_hash_ignores_drawing_per_design_lock() -> None: + """Drawing is excluded from the canonical subset; two Assemblies + differing only by drawing collide on content_hash (intended).""" + from cora.equipment.aggregates._drawing import Drawing, DrawingSystem + + family_id = uuid4() + state_no_drawing = Assembly( + id=uuid4(), + name=AssemblyName("X"), + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + ) + state_with_drawing = Assembly( + id=uuid4(), + name=AssemblyName("X"), + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + drawing=Drawing(system=DrawingSystem.ICMS, number="P4105", revision="A"), + ) + assert compute_assembly_content_hash_from_state( + state_no_drawing + ) == compute_assembly_content_hash_from_state(state_with_drawing) + + +@pytest.mark.unit +def test_content_hash_ignores_version_per_design_lock() -> None: + """Version is operator-curated lifecycle metadata, excluded from + content_subset; two snapshots with same structure but different + version labels collide on content_hash (intended re-attestation).""" + family_id = uuid4() + state_v1 = Assembly( + id=uuid4(), + name=AssemblyName("X"), + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + version="v1", + ) + state_v2 = Assembly( + id=uuid4(), + name=AssemblyName("X"), + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + version="v2", + ) + assert compute_assembly_content_hash_from_state( + state_v1 + ) == compute_assembly_content_hash_from_state(state_v2) diff --git a/apps/api/tests/unit/equipment/test_assembly_events.py b/apps/api/tests/unit/equipment/test_assembly_events.py new file mode 100644 index 000000000..8abc10548 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assembly_events.py @@ -0,0 +1,177 @@ +"""Unit tests for Assembly events: to_payload + from_stored round-trip.""" + +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates._drawing import Drawing, DrawingSystem +from cora.equipment.aggregates.assembly import ( + AssemblyDefined, + AssemblyDeprecated, + AssemblyName, + AssemblyVersioned, + SlotCardinality, + SlotName, + TemplateSlot, + TemplateWire, + from_stored, + to_payload, +) +from cora.infrastructure.ports.event_store import StoredEvent + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Assembly", + 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, + ) + + +def _slot(slot_name: str = "camera", family_id: UUID | None = None) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(slot_name), + required_family_ids=frozenset({family_id or uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + ) + + +def _wire(src_slot: str = "trigger_source", tgt_slot: str = "camera") -> TemplateWire: + return TemplateWire( + source_slot_name=src_slot, + source_port_name="trigger_out", + target_slot_name=tgt_slot, + target_port_name="trigger_in", + ) + + +@pytest.mark.unit +def test_assembly_defined_to_payload_then_from_stored_round_trip() -> None: + assembly_id = uuid4() + family_id = uuid4() + slot1 = _slot("camera", family_id) + slot2 = _slot("trigger_source", uuid4()) + wire = _wire() + original = AssemblyDefined( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=uuid4(), + required_slots=frozenset({slot1, slot2}), + required_wires=frozenset({wire}), + parameter_overrides_schema={"type": "object"}, + drawing=Drawing(system=DrawingSystem.ICMS, number="P4105", revision="A"), + version="v1.0.0", + content_hash="a" * 64, + occurred_at=_NOW, + ) + payload = to_payload(original) + stored = _stored("AssemblyDefined", payload) + rebuilt = from_stored(stored) + assert rebuilt == original + + +@pytest.mark.unit +def test_assembly_defined_round_trip_with_no_drawing_no_version_no_schema() -> None: + original = AssemblyDefined( + assembly_id=uuid4(), + name=AssemblyName("Empty"), + presents_as_family_id=uuid4(), + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version=None, + content_hash="b" * 64, + occurred_at=_NOW, + ) + payload = to_payload(original) + rebuilt = from_stored(_stored("AssemblyDefined", payload)) + assert rebuilt == original + + +@pytest.mark.unit +def test_assembly_versioned_round_trip_carries_previous_hash() -> None: + original = AssemblyVersioned( + assembly_id=uuid4(), + name=AssemblyName("Detector"), + presents_as_family_id=uuid4(), + required_slots=frozenset({_slot()}), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v1.1.0", + content_hash="c" * 64, + previous_content_hash="a" * 64, + occurred_at=_NOW, + ) + payload = to_payload(original) + rebuilt = from_stored(_stored("AssemblyVersioned", payload)) + assert rebuilt == original + + +@pytest.mark.unit +def test_assembly_versioned_round_trip_allows_no_previous_hash() -> None: + """First Versioned snapshot may have no previous_content_hash if + promoted from a Defined that pre-dates the hash field. Optional + per the additive-state convention.""" + original = AssemblyVersioned( + assembly_id=uuid4(), + name=AssemblyName("Detector"), + presents_as_family_id=uuid4(), + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version=None, + content_hash="d" * 64, + previous_content_hash=None, + occurred_at=_NOW, + ) + rebuilt = from_stored(_stored("AssemblyVersioned", to_payload(original))) + assert rebuilt == original + + +@pytest.mark.unit +def test_assembly_deprecated_round_trip() -> None: + original = AssemblyDeprecated( + assembly_id=uuid4(), + reason="superseded by next-generation MCTOptics revision", + occurred_at=_NOW, + ) + rebuilt = from_stored(_stored("AssemblyDeprecated", to_payload(original))) + assert rebuilt == original + + +@pytest.mark.unit +def test_from_stored_rejects_unknown_event_type() -> None: + with pytest.raises(ValueError, match="Unknown AssemblyEvent event_type"): + from_stored(_stored("MysteryEvent", {"assembly_id": str(uuid4())})) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "event_type", + ["AssemblyDefined", "AssemblyVersioned", "AssemblyDeprecated"], +) +def test_from_stored_wraps_malformed_payload_into_tagged_value_error( + event_type: str, +) -> None: + """Per project_from_stored_wrap_convention: every arm wraps + (KeyError, TypeError, AttributeError) into ValueError tagged + with the event name. Empty payload triggers KeyError on the + first required key access.""" + with pytest.raises(ValueError, match=f"Malformed {event_type}"): + from_stored(_stored(event_type, {})) diff --git a/apps/api/tests/unit/equipment/test_assembly_evolver.py b/apps/api/tests/unit/equipment/test_assembly_evolver.py new file mode 100644 index 000000000..a9ca244ca --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assembly_evolver.py @@ -0,0 +1,242 @@ +"""Unit tests for the Assembly evolver: genesis + revision + deprecation.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates._drawing import Drawing, DrawingSystem +from cora.equipment.aggregates.assembly import ( + Assembly, + AssemblyDefined, + AssemblyDeprecated, + AssemblyName, + AssemblyStatus, + AssemblyVersioned, + SlotCardinality, + SlotName, + TemplateSlot, + evolve, + fold, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _slot(name: str = "camera") -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_family_ids=frozenset({uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + ) + + +@pytest.mark.unit +def test_evolve_genesis_sets_defined_status() -> None: + assembly_id = uuid4() + family_id = uuid4() + slot = _slot("camera") + event = AssemblyDefined( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=frozenset({slot}), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v0.1.0", + content_hash="a" * 64, + occurred_at=_NOW, + ) + state = evolve(None, event) + assert state.id == assembly_id + assert state.presents_as_family_id == family_id + assert state.status == AssemblyStatus.DEFINED + assert state.required_slots == frozenset({slot}) + assert state.version == "v0.1.0" + assert state.content_hash == "a" * 64 + + +@pytest.mark.unit +def test_evolve_versioned_replaces_structural_fields_and_status() -> None: + assembly_id = uuid4() + family_id = uuid4() + initial_slot = _slot("camera") + new_slot = _slot("scintillator") + defined = AssemblyDefined( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=frozenset({initial_slot}), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v0.1.0", + content_hash="a" * 64, + occurred_at=_NOW, + ) + versioned = AssemblyVersioned( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=frozenset({initial_slot, new_slot}), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=Drawing(system=DrawingSystem.ICMS, number="P4105", revision="B"), + version="v0.2.0", + content_hash="b" * 64, + previous_content_hash="a" * 64, + occurred_at=_NOW, + ) + final = fold([defined, versioned]) + assert final is not None + assert final.status == AssemblyStatus.VERSIONED + assert final.required_slots == frozenset({initial_slot, new_slot}) + assert final.version == "v0.2.0" + assert final.content_hash == "b" * 64 + assert final.drawing is not None + + +@pytest.mark.unit +def test_evolve_versioned_multiple_revisions_replace_each_time() -> None: + """Multiple AssemblyVersioned events on one stream are permitted; + each is a fresh snapshot.""" + assembly_id = uuid4() + family_id = uuid4() + defined = AssemblyDefined( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=frozenset({_slot("camera")}), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v1", + content_hash="1" * 64, + occurred_at=_NOW, + ) + v2 = AssemblyVersioned( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=frozenset({_slot("camera")}), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v2", + content_hash="2" * 64, + previous_content_hash="1" * 64, + occurred_at=_NOW, + ) + v3 = AssemblyVersioned( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=frozenset({_slot("camera")}), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v3", + content_hash="3" * 64, + previous_content_hash="2" * 64, + occurred_at=_NOW, + ) + final = fold([defined, v2, v3]) + assert final is not None + assert final.version == "v3" + assert final.content_hash == "3" * 64 + assert final.status == AssemblyStatus.VERSIONED + + +@pytest.mark.unit +def test_evolve_deprecated_preserves_structural_fields_and_sets_status() -> None: + assembly_id = uuid4() + family_id = uuid4() + slot = _slot() + defined = AssemblyDefined( + assembly_id=assembly_id, + name=AssemblyName("Detector"), + presents_as_family_id=family_id, + required_slots=frozenset({slot}), + required_wires=frozenset(), + parameter_overrides_schema={"type": "object"}, + drawing=None, + version="v1", + content_hash="a" * 64, + occurred_at=_NOW, + ) + deprecated = AssemblyDeprecated( + assembly_id=assembly_id, + reason="superseded", + occurred_at=_NOW, + ) + final = fold([defined, deprecated]) + assert final is not None + assert final.status == AssemblyStatus.DEPRECATED + assert final.required_slots == frozenset({slot}) + assert final.parameter_overrides_schema == {"type": "object"} + assert final.content_hash == "a" * 64 + + +@pytest.mark.unit +def test_evolve_non_genesis_against_empty_state_raises() -> None: + """AssemblyVersioned and AssemblyDeprecated cannot appear before + AssemblyDefined in a well-formed stream.""" + versioned = AssemblyVersioned( + assembly_id=uuid4(), + name=AssemblyName("X"), + presents_as_family_id=uuid4(), + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version=None, + content_hash="x" * 64, + previous_content_hash=None, + occurred_at=_NOW, + ) + with pytest.raises(ValueError, match="AssemblyVersioned"): + evolve(None, versioned) + + +@pytest.mark.unit +def test_fold_empty_event_stream_returns_none() -> None: + assert fold([]) is None + + +@pytest.mark.unit +def test_fold_preserves_immutable_id_across_lifecycle() -> None: + assembly_id = uuid4() + family_id = uuid4() + events = [ + AssemblyDefined( + assembly_id=assembly_id, + name=AssemblyName("X"), + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v1", + content_hash="a" * 64, + occurred_at=_NOW, + ), + AssemblyVersioned( + assembly_id=assembly_id, + name=AssemblyName("X"), + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + parameter_overrides_schema=None, + drawing=None, + version="v2", + content_hash="b" * 64, + previous_content_hash="a" * 64, + occurred_at=_NOW, + ), + AssemblyDeprecated(assembly_id=assembly_id, reason="r", occurred_at=_NOW), + ] + final = fold(events) + assert isinstance(final, Assembly) + assert final.id == assembly_id + assert final.presents_as_family_id == family_id diff --git a/apps/api/tests/unit/equipment/test_assembly_state.py b/apps/api/tests/unit/equipment/test_assembly_state.py new file mode 100644 index 000000000..84ce03801 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assembly_state.py @@ -0,0 +1,260 @@ +"""Unit tests for the Assembly aggregate's state, VOs, enums, and errors.""" + +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.assembly import ( + ASSEMBLY_NAME_MAX_LENGTH, + SLOT_NAME_MAX_LENGTH, + Assembly, + AssemblyAlreadyExistsError, + AssemblyCannotDeprecateError, + AssemblyCannotInstantiateError, + AssemblyCannotVersionError, + AssemblyInstantiationAssetFamilyMismatchError, + AssemblyInstantiationMappingIncompleteError, + AssemblyInstantiationParameterOverridesInvalidError, + AssemblyName, + AssemblyNotFoundError, + AssemblyStatus, + FamilyNotFoundForAssemblyError, + InvalidAssemblyNameError, + InvalidParameterOverridesSchemaError, + InvalidSlotCardinalityError, + InvalidSlotNameError, + SlotCardinality, + SlotName, + TemplateSlot, + TemplateWire, + WireReferencesUnknownSlotError, +) + + +@pytest.mark.unit +def test_assembly_status_has_three_template_lifecycle_values() -> None: + assert {s.value for s in AssemblyStatus} == {"Defined", "Versioned", "Deprecated"} + + +@pytest.mark.unit +def test_slot_cardinality_has_four_closed_values() -> None: + assert {c.value for c in SlotCardinality} == { + "Exactly1", + "ZeroOrOne", + "OneOrMore", + "ZeroOrMore", + } + + +@pytest.mark.unit +def test_assembly_name_trims_and_validates_bounded_text() -> None: + name = AssemblyName(" MCTOptics ") + assert name.value == "MCTOptics" + + +@pytest.mark.unit +@pytest.mark.parametrize("value", ["", " "]) +def test_assembly_name_rejects_empty_or_whitespace(value: str) -> None: + with pytest.raises(InvalidAssemblyNameError): + AssemblyName(value) + + +@pytest.mark.unit +def test_assembly_name_rejects_too_long() -> None: + with pytest.raises(InvalidAssemblyNameError): + AssemblyName("x" * (ASSEMBLY_NAME_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_slot_name_trims_and_validates_bounded_text() -> None: + name = SlotName(" objective_0 ") + assert name.value == "objective_0" + + +@pytest.mark.unit +@pytest.mark.parametrize("value", ["", " "]) +def test_slot_name_rejects_empty_or_whitespace(value: str) -> None: + with pytest.raises(InvalidSlotNameError): + SlotName(value) + + +@pytest.mark.unit +def test_slot_name_rejects_too_long() -> None: + with pytest.raises(InvalidSlotNameError): + SlotName("x" * (SLOT_NAME_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_assembly_empty_closure_passes() -> None: + """An Assembly with no slots and no wires is structurally valid.""" + assembly = Assembly( + id=uuid4(), + name=AssemblyName("EmptyTemplate"), + presents_as_family_id=uuid4(), + ) + assert assembly.required_slots == frozenset() + assert assembly.required_wires == frozenset() + assert assembly.status == AssemblyStatus.DEFINED + assert assembly.version is None + assert assembly.content_hash is None + + +@pytest.mark.unit +def test_assembly_closure_accepts_wire_referencing_declared_slot() -> None: + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + ) + slot2 = TemplateSlot( + slot_name=SlotName("trigger_source"), + required_family_ids=frozenset({uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + ) + wire = TemplateWire( + source_slot_name="trigger_source", + source_port_name="trigger_out", + target_slot_name="camera", + target_port_name="trigger_in", + ) + assembly = Assembly( + id=uuid4(), + name=AssemblyName("Detector"), + presents_as_family_id=uuid4(), + required_slots=frozenset({slot, slot2}), + required_wires=frozenset({wire}), + ) + assert wire in assembly.required_wires + + +@pytest.mark.unit +def test_assembly_closure_rejects_wire_referencing_unknown_source_slot() -> None: + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + ) + wire = TemplateWire( + source_slot_name="missing_panda", + source_port_name="trigger_out", + target_slot_name="camera", + target_port_name="trigger_in", + ) + with pytest.raises(WireReferencesUnknownSlotError) as exc_info: + Assembly( + id=uuid4(), + name=AssemblyName("Broken"), + presents_as_family_id=uuid4(), + required_slots=frozenset({slot}), + required_wires=frozenset({wire}), + ) + assert exc_info.value.slot_name == "missing_panda" + + +@pytest.mark.unit +def test_assembly_closure_rejects_wire_referencing_unknown_target_slot() -> None: + slot = TemplateSlot( + slot_name=SlotName("trigger_source"), + required_family_ids=frozenset({uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + ) + wire = TemplateWire( + source_slot_name="trigger_source", + source_port_name="trigger_out", + target_slot_name="missing_camera", + target_port_name="trigger_in", + ) + with pytest.raises(WireReferencesUnknownSlotError) as exc_info: + Assembly( + id=uuid4(), + name=AssemblyName("Broken"), + presents_as_family_id=uuid4(), + required_slots=frozenset({slot}), + required_wires=frozenset({wire}), + ) + assert exc_info.value.slot_name == "missing_camera" + + +@pytest.mark.unit +def test_assembly_already_exists_error_carries_assembly_id() -> None: + assembly_id = uuid4() + err = AssemblyAlreadyExistsError(assembly_id) + assert err.assembly_id == assembly_id + assert str(assembly_id) in str(err) + + +@pytest.mark.unit +def test_assembly_not_found_error_carries_assembly_id() -> None: + assembly_id = uuid4() + err = AssemblyNotFoundError(assembly_id) + assert err.assembly_id == assembly_id + + +@pytest.mark.unit +def test_assembly_cannot_version_carries_id_and_reason() -> None: + assembly_id = uuid4() + err = AssemblyCannotVersionError(assembly_id, "already Deprecated") + assert err.assembly_id == assembly_id + assert err.reason == "already Deprecated" + + +@pytest.mark.unit +def test_assembly_cannot_deprecate_carries_id_and_reason() -> None: + assembly_id = uuid4() + err = AssemblyCannotDeprecateError(assembly_id, "already Deprecated") + assert err.assembly_id == assembly_id + + +@pytest.mark.unit +def test_assembly_cannot_instantiate_carries_id_and_reason() -> None: + assembly_id = uuid4() + err = AssemblyCannotInstantiateError(assembly_id, "Assembly is Deprecated") + assert err.assembly_id == assembly_id + + +@pytest.mark.unit +def test_family_not_found_for_assembly_carries_family_id() -> None: + family_id = uuid4() + err = FamilyNotFoundForAssemblyError(family_id) + assert err.family_id == family_id + + +@pytest.mark.unit +def test_invalid_slot_cardinality_carries_value() -> None: + err = InvalidSlotCardinalityError("BogusCardinality") + assert err.value == "BogusCardinality" + assert "BogusCardinality" in str(err) + + +@pytest.mark.unit +def test_invalid_parameter_overrides_schema_carries_reason() -> None: + err = InvalidParameterOverridesSchemaError("oneOf is forbidden in the subset") + assert err.reason == "oneOf is forbidden in the subset" + assert "oneOf is forbidden" in str(err) + + +@pytest.mark.unit +def test_assembly_instantiation_mapping_incomplete_carries_slot_and_reason() -> None: + err = AssemblyInstantiationMappingIncompleteError( + "camera", "Exactly1 slot received zero Assets" + ) + assert err.slot_name == "camera" + assert err.reason == "Exactly1 slot received zero Assets" + assert "camera" in str(err) + + +@pytest.mark.unit +def test_assembly_instantiation_asset_family_mismatch_carries_slot_and_asset() -> None: + asset_id = uuid4() + err = AssemblyInstantiationAssetFamilyMismatchError("rotary", asset_id) + assert err.slot_name == "rotary" + assert err.asset_id == asset_id + assert "rotary" in str(err) + assert str(asset_id) in str(err) + + +@pytest.mark.unit +def test_assembly_instantiation_parameter_overrides_invalid_carries_reason() -> None: + err = AssemblyInstantiationParameterOverridesInvalidError("exposure_ms must be <= 60000") + assert err.reason == "exposure_ms must be <= 60000" + assert "exposure_ms" in str(err) diff --git a/apps/api/tests/unit/equipment/test_assembly_summary_projection.py b/apps/api/tests/unit/equipment/test_assembly_summary_projection.py new file mode 100644 index 000000000..0591e28e0 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assembly_summary_projection.py @@ -0,0 +1,125 @@ +"""Unit tests for AssemblySummaryProjection. + +Pins per-event-type apply() dispatch + idempotency for the events +the projector subscribes to in v1 (AssemblyDefined only). Postgres- +side behavior lands in the integration suite when the slice for +define_assembly arrives. +""" + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from cora.equipment.projections.assembly_summary import AssemblySummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent + +_ASSEMBLY_ID = uuid4() +_FAMILY_ID = uuid4() +_EVENT_ID = uuid4() +_CORRELATION_ID = uuid4() +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=_EVENT_ID, + stream_type="Assembly", + stream_id=_ASSEMBLY_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 = AssemblySummaryProjection() + assert proj.name == "proj_equipment_assembly_summary" + assert proj.subscribed_event_types == frozenset({"AssemblyDefined"}) + + +@pytest.mark.unit +def test_projection_does_not_subscribe_to_versioned_or_deprecated_in_v1() -> None: + """v1 ships AssemblyDefined arm only; the Versioned and Deprecated + arms land with their respective slices per the slice-per-commit + discipline.""" + proj = AssemblySummaryProjection() + assert "AssemblyVersioned" not in proj.subscribed_event_types + assert "AssemblyDeprecated" not in proj.subscribed_event_types + + +@pytest.mark.unit +async def test_assembly_defined_inserts_with_defined_status() -> None: + proj = AssemblySummaryProjection() + conn = AsyncMock() + event = _stored( + "AssemblyDefined", + { + "assembly_id": str(_ASSEMBLY_ID), + "name": "MCTOptics", + "presents_as_family_id": str(_FAMILY_ID), + "required_slots": [], + "required_wires": [], + "parameter_overrides_schema": None, + "drawing": None, + "version": "v0.1.0", + "content_hash": "a" * 64, + "occurred_at": _NOW.isoformat(), + }, + ) + await proj.apply(event, conn) + args = conn.execute.await_args + assert args is not None + # Positional args after the SQL string: assembly_id, name, + # presents_as_family_id, version, content_hash, created_at. + assert args.args[1] == _ASSEMBLY_ID + assert args.args[2] == "MCTOptics" + assert args.args[3] == _FAMILY_ID + assert args.args[4] == "v0.1.0" + assert args.args[5] == "a" * 64 + assert args.args[6] == _NOW + + +@pytest.mark.unit +async def test_assembly_defined_handles_null_version() -> None: + proj = AssemblySummaryProjection() + conn = AsyncMock() + event = _stored( + "AssemblyDefined", + { + "assembly_id": str(_ASSEMBLY_ID), + "name": "MCTOptics", + "presents_as_family_id": str(_FAMILY_ID), + "required_slots": [], + "required_wires": [], + "parameter_overrides_schema": None, + "drawing": None, + "version": None, + "content_hash": "b" * 64, + "occurred_at": _NOW.isoformat(), + }, + ) + await proj.apply(event, conn) + args = conn.execute.await_args + assert args is not None + assert args.args[4] is None # version + + +@pytest.mark.unit +async def test_unrelated_event_type_is_silently_ignored() -> None: + """Out-of-subscription events return without raising; the projector + catalog is the source of truth for what gets folded.""" + proj = AssemblySummaryProjection() + conn = AsyncMock() + event = _stored("UnrelatedEvent", {"assembly_id": str(_ASSEMBLY_ID)}) + await proj.apply(event, conn) + assert conn.execute.await_count == 0 diff --git a/apps/api/tests/unit/equipment/test_assembly_template_slot.py b/apps/api/tests/unit/equipment/test_assembly_template_slot.py new file mode 100644 index 000000000..bb3200e22 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assembly_template_slot.py @@ -0,0 +1,105 @@ +"""Unit tests for the TemplateSlot value object.""" + +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.assembly import ( + InvalidSlotCardinalityError, + InvalidTemplateSlotError, + SlotCardinality, + SlotName, + TemplateSlot, +) + + +@pytest.mark.unit +def test_template_slot_minimal_construction() -> None: + family_id = uuid4() + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + assert slot.slot_name.value == "camera" + assert slot.required_family_ids == frozenset({family_id}) + assert slot.cardinality == SlotCardinality.EXACTLY_1 + assert slot.default_settings is None + assert slot.default_placement is None + + +@pytest.mark.unit +def test_template_slot_rejects_empty_required_family_ids() -> None: + with pytest.raises(InvalidTemplateSlotError) as exc_info: + TemplateSlot( + slot_name=SlotName("orphan"), + required_family_ids=frozenset(), + cardinality=SlotCardinality.ZERO_OR_MORE, + ) + assert "at least one Family" in str(exc_info.value) + + +@pytest.mark.unit +def test_template_slot_rejects_non_enum_cardinality() -> None: + """Closed-enum discipline: cardinality MUST be a SlotCardinality + member. A raw string would silently bypass the enum contract.""" + from uuid import uuid4 + + with pytest.raises(InvalidSlotCardinalityError) as exc_info: + TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({uuid4()}), + cardinality="Exactly1", # type: ignore[arg-type] + ) + assert "Exactly1" in str(exc_info.value) + + +@pytest.mark.unit +def test_template_slot_accepts_multiple_required_family_ids() -> None: + """Slot may be satisfied by an Asset carrying any one of N Families.""" + families = frozenset({uuid4(), uuid4(), uuid4()}) + slot = TemplateSlot( + slot_name=SlotName("either_kind"), + required_family_ids=families, + cardinality=SlotCardinality.ZERO_OR_ONE, + ) + assert slot.required_family_ids == families + + +@pytest.mark.unit +def test_template_slot_carries_default_settings() -> None: + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + default_settings={"exposure_ms": 100, "binning": 1}, + ) + assert slot.default_settings == {"exposure_ms": 100, "binning": 1} + + +@pytest.mark.unit +def test_template_slot_is_frozen() -> None: + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({uuid4()}), + cardinality=SlotCardinality.EXACTLY_1, + ) + with pytest.raises(Exception): # noqa: B017 # FrozenInstanceError + slot.cardinality = SlotCardinality.ZERO_OR_ONE # type: ignore[misc] + + +@pytest.mark.unit +def test_template_slot_dedup_by_full_value() -> None: + """Frozenset dedupes on whole-record equality, NOT slot_name.""" + family_id = uuid4() + slot_a = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + slot_b = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + assert frozenset({slot_a, slot_b}) == frozenset({slot_a}) diff --git a/apps/api/tests/unit/equipment/test_assembly_template_wire.py b/apps/api/tests/unit/equipment/test_assembly_template_wire.py new file mode 100644 index 000000000..faf8a5fad --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assembly_template_wire.py @@ -0,0 +1,130 @@ +"""Unit tests for the TemplateWire value object.""" + +import pytest + +from cora.equipment.aggregates.assembly import ( + WIRE_PORT_NAME_MAX_LENGTH, + InvalidWireSpecError, + TemplateWire, +) + + +@pytest.mark.unit +def test_template_wire_minimal_construction() -> None: + wire = TemplateWire( + source_slot_name="trigger_source", + source_port_name="trigger_out", + target_slot_name="camera", + target_port_name="trigger_in", + ) + assert wire.source_slot_name == "trigger_source" + assert wire.source_port_name == "trigger_out" + assert wire.target_slot_name == "camera" + assert wire.target_port_name == "trigger_in" + + +@pytest.mark.unit +def test_template_wire_trims_all_four_fields() -> None: + wire = TemplateWire( + source_slot_name=" src_slot ", + source_port_name=" src_port ", + target_slot_name=" tgt_slot ", + target_port_name=" tgt_port ", + ) + assert wire.source_slot_name == "src_slot" + assert wire.source_port_name == "src_port" + assert wire.target_slot_name == "tgt_slot" + assert wire.target_port_name == "tgt_port" + + +@pytest.mark.unit +@pytest.mark.parametrize("empty", ["", " "]) +@pytest.mark.parametrize( + "field", + [ + "source_slot_name", + "source_port_name", + "target_slot_name", + "target_port_name", + ], +) +def test_template_wire_rejects_empty_field(field: str, empty: str) -> None: + base = { + "source_slot_name": "src_slot", + "source_port_name": "src_port", + "target_slot_name": "tgt_slot", + "target_port_name": "tgt_port", + } + base[field] = empty + with pytest.raises(InvalidWireSpecError) as exc_info: + TemplateWire(**base) + assert field in str(exc_info.value) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "field", + [ + "source_slot_name", + "source_port_name", + "target_slot_name", + "target_port_name", + ], +) +def test_template_wire_rejects_too_long(field: str) -> None: + base = { + "source_slot_name": "src_slot", + "source_port_name": "src_port", + "target_slot_name": "tgt_slot", + "target_port_name": "tgt_port", + } + base[field] = "x" * (WIRE_PORT_NAME_MAX_LENGTH + 1) + with pytest.raises(InvalidWireSpecError) as exc_info: + TemplateWire(**base) + assert field in str(exc_info.value) + + +@pytest.mark.unit +def test_template_wire_rejects_degenerate_full_self_loop() -> None: + """Same slot AND same port on both endpoints is a degenerate loop.""" + with pytest.raises(InvalidWireSpecError) as exc_info: + TemplateWire( + source_slot_name="lut", + source_port_name="out", + target_slot_name="lut", + target_port_name="out", + ) + assert "degenerate" in str(exc_info.value).lower() + + +@pytest.mark.unit +def test_template_wire_allows_self_slot_with_different_ports() -> None: + """PandABox LUT pattern: same slot, different ports is allowed.""" + wire = TemplateWire( + source_slot_name="lut", + source_port_name="out", + target_slot_name="lut", + target_port_name="feedback_in", + ) + assert wire.source_slot_name == wire.target_slot_name + assert wire.source_port_name != wire.target_port_name + + +@pytest.mark.unit +def test_template_wire_is_frozen() -> None: + wire = TemplateWire( + source_slot_name="src", + source_port_name="out", + target_slot_name="tgt", + target_port_name="in", + ) + with pytest.raises(Exception): # noqa: B017 + wire.source_slot_name = "other" # type: ignore[misc] + + +@pytest.mark.unit +def test_template_wire_dedup_by_full_4_tuple() -> None: + """Identity IS the 4-tuple; frozenset dedupes on it.""" + wire_a = TemplateWire("src", "out", "tgt", "in") + wire_b = TemplateWire("src", "out", "tgt", "in") + assert frozenset({wire_a, wire_b}) == frozenset({wire_a}) diff --git a/docs/projects/cora-outreach/cora-review-codification-research.md b/docs/projects/cora-outreach/cora-review-codification-research.md new file mode 100644 index 000000000..cad56f885 --- /dev/null +++ b/docs/projects/cora-outreach/cora-review-codification-research.md @@ -0,0 +1,138 @@ +# Review-Process Codification: Should CORA Commit the Loop? + +A corpus-grounded decision framework on whether the multi-axis pre-push review should live as durable artifacts (subagents, hooks, ledger) or stay with the operator and the model in the moment. + +## 1. One-Paragraph Answer + +The corpus splits cleanly into two camps that agree on the gradient but disagree on the extrapolation: free-form voices (Cherny, Karpathy, Willison) bet that the next model erases today's scaffolding, while codification voices (Boeckeler, Yan, Anthropic's own best-practices page) bet that long-lived correctness demands explicit anchors. The practitioner consensus is narrower than either extreme: codify the *knowledge* and the *invariants*, free-form the *loop that invokes them*; ten of ten solo OSS projects surveyed commit a thin PR template and a CONTRIBUTING file, three of ten (Astral ruff, Astral uv, Bun) commit `AGENTS.md` operational playbooks, and zero of ten commit a multi-axis reviewer pipeline or a per-PR ledger. CORA's situation (16 BCs, 28 aggregates, 53+ design memos, a single operator, a 5-year horizon, MEMORY.md already over its size warning, and a documented near-miss on the R3 noun-LAST rule) puts it on the codification side of the gradient for reviewer *prompts* but on the free-form side for reviewer *orchestration*. Recommendation framed as a conditional: if you have already paid the rule-of-three for a given review axis (R3 naming, BC-boundaries, plan-conformance), then commit that axis as a `.claude/agents/.md` subagent file and reference it from `CLAUDE.md`; if you have not, do not pre-build the pipeline, the ledger, or the gating hooks, because every codified-tool failure mode in the corpus (rule rot, ritual bypass, harness debt) hits solo devs HARDER than enterprises and the asymmetric downside favors the lighter artifact. + +## 2. Corpus Headlines + +- **Anthropic's own load-bearing test:** "Would removing this cause Claude to make mistakes? If not, cut it." Hooks for deterministic gates, skills for on-demand knowledge, subagents for fresh-context review. [Best practices for Claude Code](https://code.claude.com/docs/en/best-practices) +- **Boeckeler on anchor-rot:** prose-only standards are "Java exams at university, in pencil" because no compiler catches decay; a living reference application is the antidote. [Anchoring AI to a Reference Application](https://martinfowler.com/articles/exploring-gen-ai/anchoring-to-reference.html) +- **Boeckeler on context overload:** "the agent's effectiveness goes down when it gets too much context"; teams "inadvertently repeat instructions or contradict existing ones." [Context Engineering for Coding Agents](https://martinfowler.com/articles/exploring-gen-ai/context-engineering-coding-agents.html) +- **Cherny's bitter-lesson bet:** "everything is the model; as the model gets better, it subsumes everything else"; CLAUDE.md is "the simplest thing that could work." [Latent Space Claude Code episode](https://www.latent.space/p/claude-code) +- **Solo OSS minimum pattern:** ten of ten projects ship CONTRIBUTING + thin PR template; three of ten (Astral ruff, Astral uv, Bun) commit `AGENTS.md` operational playbooks; zero commit reviewer-side checklists or multi-axis pipelines. [Ruff AGENTS.md](https://github.com/astral-sh/ruff/blob/main/AGENTS.md), [Bun AGENTS.md](https://github.com/oven-sh/bun/blob/main/AGENTS.md) +- **GitClear churn signal:** code reverted within two weeks forecast to double vs 2021 baseline under heavy Copilot use; review caught syntax but not duplication-and-discard. [GitClear study](https://www.gitclear.com/coding_on_copilot_data_shows_ais_downward_pressure_on_code_quality) +- **Solo-dev sustainability of artifacts (tools survey):** highest-rated are pre-commit (10/10), Reviewdog (9/10), Semgrep (9/10), ArchUnit (9/10), all repo-local; lowest are SaaS-interpreted YAML (CodeRabbit 4/10, Snyk 5/10) because the verdict lives off-checkout. [pre-commit](https://pre-commit.com), [Semgrep](https://semgrep.dev/docs/writing-rules/rule-syntax) +- **No published convention for axis-decomposed AI review:** the Claude community has shipped role-per-language reviewers (wshobson) and Anthropic's two-reviewer pattern (correctness + plan-conformance), but nobody publishes the multi-axis matrix CORA is sketching. [wshobson/agents](https://github.com/wshobson/agents) + +## 3. Codification Continuum Table + +Eight gaps from the prior audit, numbered for reference: +1. persistent reviewer prompts across sessions +2. per-PR review ledger +3. axis disclosure (which review dimensions exist) +4. verification record (what was checked) +5. inconsistent review depth +6. missing security pass +7. operator-change survival (handoff) +8. over-engineering (codification itself) + +| Tier | Who uses it | Gaps closed | Maintenance | Failure mode | Solo fit | +|---|---|---|---|---|---| +| **T0: Nothing committed** | Karpathy vibe-coding, throwaway scope | None | Zero | Anchor-rot, convention drift, poisoned context | 3/10 for 5-yr horizon | +| **T1: PR template only** | htmx, Tailwind, Starlette, httpx, FastAPI, Pydantic, Rails | 3 (axis disclosure via checklist), partial 7 | Near zero; edit when norms change | Checklist becomes ritual; reviewer-of-one acks-all | 9/10 | +| **T2: PR template + CLAUDE.md + bundled skills (`/code-review`, `/security-review`)** | CORA today; Anthropic best-practices baseline; Willison ("codify knowledge, free-form loop") | 3, 6, partial 1, partial 7 | Low; CLAUDE.md edits track convention shifts | Reviewer-fatigue ("a reviewer prompted to find gaps will usually report some") | 9/10 | +| **T3: Committed `AGENTS.md` + reviewer subagents in `.claude/agents/`** | Astral ruff, Astral uv, Bun, wshobson marketplace | 1, 3, 6, 7, partial 5 | Medium; quarterly prune per Boeckeler | Rule rot if not pruned; copy-paste accretion | 7/10 | +| **T4: Committed workflow + hooks + JSON ledger** | No surveyed solo project; Anthropic enterprise teams via plugins | 1, 2, 3, 4, 5, 6, 7 | High; harness IS code, harness has debt | Harness maintenance debt (OpenAI 5-month build with GC agents fighting decay); ritual bypass (Kiro's 16 acceptance criteria for a bug fix) | 4/10 | +| **T5: External SaaS bot (CodeRabbit, Sourcery, Snyk policies)** | Enterprise teams with CI budget | 1, 2, 4, 6 (vendor-mediated) | Vendor absorbs; you absorb config drift | Verdict not reproducible from checkout; vendor pricing changes; review-summary balloon | 4/10 | + +Notes the corpus is silent on: nobody publishes a controlled comparison of T2 vs T3 outcomes for solo devs over multi-year horizons; the failure-story bank skews toward T4 and T5 because those generate dramatic post-mortems while T2 quietly works. + +## 4. Three Steelman Positions Distilled + +**Position A: Don't codify the review process.** +- The eight-failure corpus shows three over-codification failures (rule rot, ritual bypass, harness debt) that all hit solo devs HARDER than enterprises; Anthropic's own "would removing this cause mistakes? if not, cut it" test fails for most reviewer scaffolding. +- The minimum solo OSS pattern is brutally thin: htmx, Pydantic, Starlette, httpx, FastAPI, Tailwind all ship without committed reviewer checklists; the projects that DID add ceremony (Kiro spec-kit, OpenAI's 5-month harness) regret it. +- CORA's existing architecture-fitness suite plus `tracked_python_files()` plus content-addressed identity already IS the compilable anchor Boeckeler prescribes; codifying review on top of that adds ritual without adding anchor. +- The bitter-lesson bet: every reviewer file you write today is a file the next model subsumes; trust Opus 4.7 to 5.0 generalization and keep CLAUDE.md as the surface. + +**Position B: Fully codify workflow + agents + PR template + ledger.** +- CORA is not a weekend project; it is 16 BCs, 28 aggregates, 53+ memos, MEMORY.md already over the size warning. The operator literally cannot remember the rules; the model cannot infer them from a poisoned context full of legacy patterns. +- The Doernenburg CCMenu failure is the closest analog: codified conventions existed in the codebase but were not load-bearing in the agent's context; the R3 noun-LAST near-miss recorded in `feedback_audit_r3_direction.md` is the rule-of-three trigger fired. +- Six of seven rubric questions (recurrence, future-non-obvious, statability, no-deploy-variance, mechanism-vs-judgment, no-silent-failure) push CORA toward codification; only Q3 (eval-verifiable) partially fails. +- ArchUnit, Semgrep, and pre-commit have survived multiple model generations precisely because they do not depend on a model; reviewer subagent files are 30 lines of markdown each, proportional in cost to churn. + +**Position C: Commit the prompts, skip the pipeline.** +- The corpus's convergent practice is prompts-as-artifacts, invocation-as-judgment: Astral and Bun commit `AGENTS.md` plus subagent files but never a pipeline or ledger; wshobson's 191-agent marketplace is the same shape at scale. +- The cost curves of prompt-codification and pipeline-codification differ by an order of magnitude: a reviewer prompt is 30-200 lines of markdown editable monthly and reproducible from checkout (Semgrep/ArchUnit profile); a pipeline is code that hits every harness-debt failure in the corpus. +- The framework rubric splits the same way: reviewer prompts pass Q1/Q2/Q5 (recurred, future-non-obvious, per-stage variance), pipeline machinery fails Q1/Q4/Q7 (no recurrence, when-to-invoke is judgment, silent failure of orchestration is worse than no orchestration). +- A ledger you do not read is decoration: SQLite's release checklist (200 items, Gawande-inspired) is for releases not PRs; Hipp distrusts automation specifically for catching what tests miss; the git log IS the ledger for a solo dev. + +## 5. Decision Framework + +Seven questions. For each: what pushes toward more codification, what pushes toward less, what CORA's actual answer is from the auto-memory and recent commits. + +**Q1. Has this review axis caught the same class of mistake three or more times?** +More codify if yes (Rule of Three, Fowler); less if speculative. +*CORA answer:* yes for naming (R3 audit, naming round-5, plurality sweep), yes for BC-boundary (update-handler factory, `_actor_update_handler` hoist), yes for plan-conformance (gate-review memo). No for security beyond bundled `/security-review`. Pushes toward T3 for those three axes specifically, not for review-in-general. + +**Q2. Would the rationale be non-obvious to me in six months?** +More codify if yes (ADR pattern, letter to future self); less if reversible-and-obvious. +*CORA answer:* yes, demonstrably; 53+ design memos and a MEMORY.md overflow prove the operator already loses context across months. Pushes toward T3. + +**Q3. Is the behavior verifiable by an automated check?** +More codify as eval if yes; leave as prose if not. +*CORA answer:* partly. Architecture-fitness covers structural invariants; naming R3, plan-conformance, and altitude judgments are not eval-verifiable today. Pushes toward T2/T3 hybrid: keep evals where they work, add reviewer subagents only where they cannot reach. + +**Q4. Is the rule easier to state than to demonstrate with examples?** +More codify (Software 1.0) if statable; less (Software 2.0) if better-demonstrated. +*CORA answer:* mixed. R1-R4 naming rules are statable; "is this docstring at the right altitude" is demonstration-only. Pushes toward T2 for the demonstration-only axes (let the model judge with examples in context); T3 for the statable ones. + +**Q5. Does this review vary per stage type (Stage-0 research vs Stage-1 design vs BC-shipping commit)?** +More codify (separate artifacts per axis) if yes; less if uniform. +*CORA answer:* yes; `feedback_gate_review_before_commit.md` already records "3 baseline + 1 specialist" varying by stage. Pushes toward T3 with one subagent per axis, invoked selectively. + +**Q6. Is this mechanism (the gate) or policy (the judgment about when to gate)?** +Mechanism into pipeline; policy into operator-controlled config. +*CORA answer:* the *axes* are mechanism (stable list); the *which-to-invoke-on-this-PR* is policy (varies). Pushes toward T3 (commit the axes) but NOT T4 (do not commit the gating). + +**Q7. Would a silent failure here be acceptable on the production critical path?** +No silent failure tolerated -> codify the invariant. Yes -> vibe-code. +*CORA answer:* no, demonstrably; CORA has a forward-only-migrations stance, no rollback culture for memos, single operator. Pushes toward codification UNLESS the codification itself silently fails (the harness-debt failure mode), which is the precise reason to stop at T3 and not progress to T4. + +Tally: five push toward T3 (prompts as committed artifacts), two push against T4 (orchestration as code). Convergent on Position C. + +## 6. Tailored Recommendation for CORA + +The framework lands on **Position C with a sharp caveat**: commit the reviewer prompts as subagent files, do not build the pipeline or the ledger, and accept that invocation is operator (or auto-delegating Claude) judgment. + +Showing the work. Of the four serious counterarguments: + +- *"The bitter lesson will eat your scaffolding"* (Cherny, Position A): valid for capability scaffolding (compaction, memory, planning) which CORA is not building; not valid for policy scaffolding (R3 noun-LAST, BC-boundary rules) which is project-specific and will never be in the model's weights. +- *"Inconsistent enforcement is worse than absent enforcement"* (Position B's strongest shot at C): valid in principle, but the failure corpus is unambiguous that ritual bypass kills heavier systems faster than absent rituals kill lighter ones; the asymmetric downside favors the lighter artifact for solo devs. +- *"Anchor-rot will catch you"* (Boeckeler, Position B): CORA's architecture-fitness suite plus `tracked_python_files()` plus 28 aggregates of compiled invariants IS the anchor; reviewer subagents complement the anchor rather than replace it. +- *"Prompts will rot"* (Position A counter to C): yes, and so do tests; the mitigation is the same (rule-of-three before extraction, quarterly prune per Boeckeler, ADR for the why). + +Where CORA's situation forces a deviation from Position C as written: the steelman C ends at four committed subagents and CLAUDE.md changes. Given the MEMORY.md overflow, the project should also rotate the new policy through a memory entry (`feedback_reviewer_invocation.md`) so it survives auto-memory pressure and so future-Doğa loads it as part of the standing user index. This is paying the existing memory-pattern tax, not adding new ceremony. + +What stays explicitly out: no `.claude/workflows/` directory, no hook-based commit gating, no JSON-receipt ledger, no SaaS bot. Each of those failed at least one rubric question (Q1 for workflows, Q7 for hooks, Q3-and-Q4 for ledgers, Q7 for SaaS), and each carries a documented failure mode the solo configuration cannot absorb. + +The five-year horizon point deserves naming. The bitter-lesson camp's strongest move is "in five years this is all moot." Even granting that, the cost of writing four 80-line markdown files today and pruning them quarterly is bounded; the cost of NOT writing them and continuing to lose context to MEMORY.md overflow is unbounded. The expected value comparison favors action over wait-and-see. + +## 7. Minimum-Viable Next Step + +The smallest reversible commit that tests the chosen direction is **one subagent file plus a CLAUDE.md cross-reference**, not four. + +Concrete shape: + +- Create `.claude/agents/naming-r3-reviewer.md`, ~60-100 lines, YAML frontmatter with `name: naming-r3-reviewer`, `description:` written for Claude's auto-delegation matcher (so it triggers on rename PRs without manual `/` invocation), `tools: Read, Grep, Glob, Bash`, `model: opus`. Body: the R3 noun-LAST rule, the R1/R2/R4 reads, a link to `docs/reference/conventions.md` and to the existing `project_naming_conventions.md` memory entry. +- Add to `CLAUDE.md` under a new short `## Reviewer subagents` section: one line naming the agent and its trigger ("before any rename or new-name commit, invoke `naming-r3-reviewer`"). +- Do NOT add a memory entry yet; let the artifact prove its value through one or two real invocations first. + +Why this is the right test: naming R3 is the axis with the strongest rule-of-three signal (audit, round-5, plurality sweep, near-miss memo), the cheapest invariant to state, and the easiest to evaluate from outcomes ("did this rename PR catch the noun-last violation that would otherwise have shipped?"). If the test succeeds, the same pattern extends to BC-boundary, plan-conformance, and altitude. If it fails (the agent is noisy, the description fails to auto-trigger, the prose rots within two months), delete the file, revert the CLAUDE.md line, and Position A wins on evidence rather than speculation. + +Reversibility cost is one `git revert` of two files. Lock-in cost is zero. + +## 8. Open Questions + +Three points need the user's input before scaling beyond the minimum-viable step. + +**Q1. Auto-delegation vs explicit invocation.** Anthropic's subagent system supports both: a well-written `description:` field lets Claude pick the reviewer automatically, while `/naming-r3-reviewer` is operator-driven. Auto-delegation reduces operator burden and matches Position C's "invocation is judgment" stance but produces reviewer-fatigue noise if the matcher fires too often; explicit invocation is sharper but relies on operator memory (the failure mode CORA is trying to mitigate). Recommend: start auto-delegating with a tight description; downgrade to explicit if noise exceeds signal in the first ten PRs. + +**Q2. Where do reviewer files live for cross-BC vs BC-specific axes?** Naming R3 is universal (one file at `.claude/agents/`). But a hypothetical "Federation port shape reviewer" is BC-specific and the corpus has no convention for nested subagent directories. CORA's existing BC-root-layout memo (flat private files until ~10) suggests flat-then-nest; recommend deferring this question until a second axis with BC-specific scope earns its rule-of-three. + +**Q3. Memory entry shape.** Once the minimum-viable test passes, the `feedback_reviewer_invocation.md` entry needs to fit MEMORY.md's already-tight budget. The user has historically chosen one-line index entries with detail in topic files; recommend that pattern with detail in `project_reviewer_subagents_design.md` so the auto-memory loads only the trigger condition, not the prompt prose. Confirm before authoring. + +Where the corpus is silent: no published outcome data exists on solo-dev subagent maintenance burden over multi-year horizons; the Astral and Bun `AGENTS.md` files are recent enough (2024-2026) that decay evidence has not accumulated. CORA's experiment will be among the data points future projects cite. diff --git a/infra/atlas/migrations/20260602100000_init_proj_equipment_assembly_summary.sql b/infra/atlas/migrations/20260602100000_init_proj_equipment_assembly_summary.sql new file mode 100644 index 000000000..5fc9c0d19 --- /dev/null +++ b/infra/atlas/migrations/20260602100000_init_proj_equipment_assembly_summary.sql @@ -0,0 +1,69 @@ +-- Equipment BC's Assembly aggregate summary projection. +-- +-- Folds the Assembly aggregate's lifecycle events into a queryable +-- read model. Used by the future `list_assemblies` and +-- `get_assembly` slices for the keyset-paginated list endpoint with +-- optional `status` / `content_hash` filters. +-- +-- v1 subscribes to AssemblyDefined only; the AssemblyVersioned and +-- AssemblyDeprecated arms land with their respective slices per the +-- slice-per-commit discipline. +-- +-- Subscribed events (v1): +-- - AssemblyDefined -> INSERT (status=Defined, version from +-- payload, content_hash from payload) +-- - AssemblyVersioned -> UPDATE status=Versioned + version + +-- content_hash (added with the slice) +-- - AssemblyDeprecated -> UPDATE status=Deprecated (added with +-- the slice) +-- +-- `content_hash` is indexed for the future +-- "find Assemblies with this structural fingerprint" cross-facility +-- federation query (per project_federation_port_design). The +-- composite (created_at, assembly_id) index supports keyset +-- pagination on the list endpoint. +-- +-- Mutable read model. cora_app gets full DML. +-- proj_equipment_assembly_summary matches: +-- - the table name (here) +-- - the bookmark row (INSERT below) +-- - `AssemblySummaryProjection.name` in +-- cora.equipment.projections.assembly_summary. + +CREATE TABLE proj_equipment_assembly_summary ( + assembly_id UUID PRIMARY KEY, + name TEXT NOT NULL, + presents_as_family_id UUID NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('Defined', 'Versioned', 'Deprecated') + ), + version TEXT, + content_hash TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX proj_equipment_assembly_summary_keyset_idx + ON proj_equipment_assembly_summary (created_at, assembly_id); + +-- Federation / dedup query support: find Assemblies by their +-- structural fingerprint. Partial index excludes NULL so the +-- empty-hash rows (pre-content_hash legacy events, none today) +-- do not occupy the index. +CREATE INDEX proj_equipment_assembly_summary_content_hash_idx + ON proj_equipment_assembly_summary (content_hash) + WHERE content_hash IS NOT NULL; + +-- presents_as_family_id back-lookup: find every Assembly that +-- claims this Family identity. Used by Method.needed_families +-- satisfaction queries when an Assembly may stand in for the +-- declared Family. +CREATE INDEX proj_equipment_assembly_summary_presents_as_family_id_idx + ON proj_equipment_assembly_summary (presents_as_family_id); + +GRANT SELECT, INSERT, UPDATE, DELETE + ON proj_equipment_assembly_summary TO cora_app; + +INSERT INTO projection_bookmarks (name) +VALUES ('proj_equipment_assembly_summary') +ON CONFLICT DO NOTHING; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index f69627f3e..50dea2710 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:O0eZ3rwkekN3nR7UA2zL0cF4eBDxxQ7l9ojvQysX6dc= +h1:STjsuxbcX1/mFrlHHOtdhGDvtKpN5hsEem4V212Iyvo= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -92,7 +92,8 @@ h1:O0eZ3rwkekN3nR7UA2zL0cF4eBDxxQ7l9ojvQysX6dc= 20260601110000_init_proj_equipment_model_summary.sql h1:QJCanmiewUXP1knkN62ajcTon0dskClItX8pNOUwCzw= 20260602000000_rename_asset_level_assembly_to_component.sql h1:wUWROwvhmmG3n/qsp5YOt4rf5aUthO79EiuzBKHDNOE= 20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:MRFJiqBX/MdPpVoeaZW3ofTdxJtXChIglfXJOiWqZt8= -20260602110000_add_asset_summary_model.sql h1:Xq9VNxr5Yqfmr6GQ3lU3GY0pYYHaqToJGNIXeJGQcrE= -20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:iHxz9PxUQg/ND3vOsEIcEG2IZVdZ8xrq2wCVdBxZkNI= -20260602120000_rename_model_summary_declared_families_column.sql h1:USEFSTeYZrGtPRiwCpsiEFm1fXKWBPaV34BVM3dAlnY= -20260603100000_add_asset_summary_alternate_identifiers.sql h1:0E5lvIJUDUAEOKOv8xTbhJjZe4z7MjZiLdspqmVdYqE= +20260602100000_init_proj_equipment_assembly_summary.sql h1:xGm+ik/jZ7CGFCFnqrNaf/pKY9SjJHIAPH7dl04Hk6Q= +20260602110000_add_asset_summary_model.sql h1:CfBi8djGfOe+WGJePpUt+0KFid0hPm6jC7kqXmDaCRk= +20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:XSNZcOmiKdfKNJ/P7Swn6atJ8cx+H8HaX2b4SUrmvt8= +20260602120000_rename_model_summary_declared_families_column.sql h1:0waLCitLK4CxoNA+7pqa/TYycQE37u1KwvRR5pfjBnk= +20260603100000_add_asset_summary_alternate_identifiers.sql h1:Vb1ecahJzzeE8EMLW9NRixIYUdQgrCFQSJzR8fYwcb4= From 3aac39cf2810cbce4b155629c6a4c173e2891c4e Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 20:14:37 +0300 Subject: [PATCH 3/5] feat(equipment): add define_assembly slice (B.1) The first vertical slice on the Assembly aggregate. Genesis FSM event AssemblyDefined, single-stream append at expected_version=0, handler-side cross-aggregate Family existence check via load_family (matches update_asset_settings precedent for FamilyId checks via event-store replay). Decider invariants: - State must be None -> AssemblyAlreadyExistsError. - context.missing_family_ids empty -> FamilyNotFoundForAssemblyError carrying the sorted-first missing id for deterministic responses. - command.name valid -> InvalidAssemblyNameError via VO. - Every wire endpoint references a declared slot -> WireReferencesUnknownSlotError (defense-in-depth above the Assembly.__post_init__ closure check so a bad command surfaces at the API boundary, not as a load-time evolver fault). - parameter_overrides_schema valid -> InvalidParameterOverridesSchemaError. Reconciles InvalidParameterOverridesSchemaError HTTP mapping at 400 via _handle_validation_error (matches InvalidFamilySettingsSchemaError precedent); state.py docstring updated to match. Hoists TemplateSlotBody and TemplateWireBody as shared wire-format mirrors per the rule-of-three precedent (route + tool both consume). Route body uses list[...] (Pydantic cannot hash BaseModel instances for frozenset); handler converts to domain frozensets. Idempotency-Key support via with_idempotency wrap in wire.py. Tests: 9 decider unit tests, 4 PBT properties, 12 REST contract tests, 4 MCP contract tests, 1 Postgres integration test. All 14,400 architecture-suite tests pass. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 310 ++++++++++++++++++ .../src/cora/equipment/_template_slot_body.py | 87 +++++ .../src/cora/equipment/_template_wire_body.py | 57 ++++ .../equipment/aggregates/assembly/state.py | 4 +- .../features/define_assembly/__init__.py | 23 ++ .../features/define_assembly/command.py | 49 +++ .../features/define_assembly/context.py | 25 ++ .../features/define_assembly/decider.py | 124 +++++++ .../features/define_assembly/handler.py | 170 ++++++++++ .../features/define_assembly/route.py | 185 +++++++++++ .../features/define_assembly/tool.py | 96 ++++++ apps/api/src/cora/equipment/routes.py | 2 + apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 14 + .../test_no_assembly_asset_level_literal.py | 14 + .../contract/test_assemblies_endpoint.py | 309 +++++++++++++++++ .../contract/test_define_assembly_mcp_tool.py | 154 +++++++++ .../test_define_assembly_handler_postgres.py | 77 +++++ .../equipment/test_define_assembly_decider.py | 263 +++++++++++++++ ...test_define_assembly_decider_properties.py | 162 +++++++++ 20 files changed, 2129 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/cora/equipment/_template_slot_body.py create mode 100644 apps/api/src/cora/equipment/_template_wire_body.py create mode 100644 apps/api/src/cora/equipment/features/define_assembly/__init__.py create mode 100644 apps/api/src/cora/equipment/features/define_assembly/command.py create mode 100644 apps/api/src/cora/equipment/features/define_assembly/context.py create mode 100644 apps/api/src/cora/equipment/features/define_assembly/decider.py create mode 100644 apps/api/src/cora/equipment/features/define_assembly/handler.py create mode 100644 apps/api/src/cora/equipment/features/define_assembly/route.py create mode 100644 apps/api/src/cora/equipment/features/define_assembly/tool.py create mode 100644 apps/api/tests/contract/test_assemblies_endpoint.py create mode 100644 apps/api/tests/contract/test_define_assembly_mcp_tool.py create mode 100644 apps/api/tests/integration/test_define_assembly_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_define_assembly_decider.py create mode 100644 apps/api/tests/unit/equipment/test_define_assembly_decider_properties.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ee5f46df2..ba3988820 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3950,6 +3950,97 @@ "title": "DefineAgentResponse", "type": "object" }, + "DefineAssemblyRequest": { + "description": "Body for `POST /assemblies`.", + "properties": { + "drawing": { + "anyOf": [ + { + "$ref": "#/components/schemas/DrawingBody" + }, + { + "type": "null" + } + ], + "description": "Optional engineering reference for the assembly itself. Excluded from the content_hash." + }, + "name": { + "description": "Human-readable display name. Non-unique; the UUID is the storage identity and the content_hash is the structural fingerprint.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "parameter_overrides_schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional JSON Schema subset declaring the shape of parameter_overrides accepted at instantiation.", + "title": "Parameter Overrides Schema" + }, + "presents_as_family_id": { + "description": "FamilyId the instantiated Assembly stands in for at the Method.needed_families satisfaction boundary.", + "format": "uuid", + "title": "Presents As Family Id", + "type": "string" + }, + "required_slots": { + "description": "Slots that compose this Assembly. Each names a slot (canonical identity within the blueprint), the FamilyIds any filling Asset must include, the cardinality, and optional template defaults. Wire shape is a list; duplicates collapse when the route handler converts to the domain frozenset.", + "items": { + "$ref": "#/components/schemas/TemplateSlotBody" + }, + "title": "Required Slots", + "type": "array" + }, + "required_wires": { + "description": "Slot-to-slot signal wires inside the Assembly, keyed by slot_name (NOT Asset UUID). Endpoints must reference slots declared in required_slots. Wire shape is a list; duplicates collapse when the route handler converts to the domain frozenset.", + "items": { + "$ref": "#/components/schemas/TemplateWireBody" + }, + "title": "Required Wires", + "type": "array" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Operator-curated free-form version label (no SemVer enforcement). Excluded from the content_hash.", + "title": "Version" + } + }, + "required": [ + "name", + "presents_as_family_id" + ], + "title": "DefineAssemblyRequest", + "type": "object" + }, + "DefineAssemblyResponse": { + "description": "Response body for `POST /assemblies`.", + "properties": { + "assembly_id": { + "format": "uuid", + "title": "Assembly Id", + "type": "string" + } + }, + "required": [ + "assembly_id" + ], + "title": "DefineAssemblyResponse", + "type": "object" + }, "DefineCalibrationRequest": { "description": "Body for `POST /calibrations`.", "properties": { @@ -9943,6 +10034,17 @@ "title": "SignSealPointerRequest", "type": "object" }, + "SlotCardinality": { + "description": "How many Assets can fill a slot at instantiation time.\n\nClosed enum: adding a fifth member is a deliberate widen, not\nan additive default. Numeric bounds (`AtLeast2`, `AtMost3`) are\nexplicitly out of scope to keep the closed-enum discipline.", + "enum": [ + "Exactly1", + "ZeroOrOne", + "OneOrMore", + "ZeroOrMore" + ], + "title": "SlotCardinality", + "type": "string" + }, "SourceAssertedDTO": { "description": "Wire shape for an Asserted source (operator-typed value).", "properties": { @@ -10717,6 +10819,101 @@ "title": "TargetProcedureDTO", "type": "object" }, + "TemplateSlotBody": { + "description": "Wire format for a TemplateSlot value object.", + "properties": { + "cardinality": { + "$ref": "#/components/schemas/SlotCardinality", + "description": "How many Assets can fill this slot at instantiation: Exactly1, ZeroOrOne, OneOrMore, ZeroOrMore." + }, + "default_placement": { + "anyOf": [ + { + "$ref": "#/components/schemas/PlacementBody" + }, + { + "type": "null" + } + ], + "description": "Optional template-default placement for the slot." + }, + "default_settings": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional template defaults applied at instantiation unless overridden by the instantiator.", + "title": "Default Settings" + }, + "required_families": { + "description": "Set of FamilyIds an instantiated Asset must include at least one of. MUST be non-empty.", + "items": { + "format": "uuid", + "type": "string" + }, + "minItems": 1, + "title": "Required Families", + "type": "array", + "uniqueItems": true + }, + "slot_name": { + "description": "Canonical name of this slot within the Assembly (e.g., 'camera', 'rotary', 'trigger_source').", + "maxLength": 100, + "minLength": 1, + "title": "Slot Name", + "type": "string" + } + }, + "required": [ + "slot_name", + "required_families", + "cardinality" + ], + "title": "TemplateSlotBody", + "type": "object" + }, + "TemplateWireBody": { + "description": "Wire format for a TemplateWire value object.", + "properties": { + "source_port_name": { + "maxLength": 100, + "minLength": 1, + "title": "Source Port Name", + "type": "string" + }, + "source_slot_name": { + "maxLength": 100, + "minLength": 1, + "title": "Source Slot Name", + "type": "string" + }, + "target_port_name": { + "maxLength": 100, + "minLength": 1, + "title": "Target Port Name", + "type": "string" + }, + "target_slot_name": { + "maxLength": 100, + "minLength": 1, + "title": "Target Slot Name", + "type": "string" + } + }, + "required": [ + "source_slot_name", + "source_port_name", + "target_slot_name", + "target_port_name" + ], + "title": "TemplateWireBody", + "type": "object" + }, "TriggerSource": { "description": "The origin of a status-transition event.\n\nThree values locked day one per [[project_supply_design]] for\nforward-compat. Only `Operator` is wired today; `Monitor`\nand `Auto` are reserved for future slice families:\n\n - `Operator`: explicit operator command\n - `Monitor`: substream-derived observation (deferred; needs\n first DAQ substream ingest, paired with run-reading trigger)\n - `Auto`: timer-based auto-restore (deferred; needs first\n operator complaint about `restore_supply` ack overhead OR\n 30+ days of substream-stable recoveries)\n\nLocking three values day one avoids enum-evolution churn when\nthe deferred features land.", "enum": [ @@ -13443,6 +13640,119 @@ ] } }, + "/assemblies": { + "post": { + "operationId": "post_assemblies_assemblies_post", + "parameters": [ + { + "description": "Optional client-supplied key for idempotent retry: replaying the same key returns the original assembly_id without creating a new Assembly.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied key for idempotent retry: replaying the same key returns the original assembly_id without creating a new Assembly.", + "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/DefineAssemblyRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefineAssemblyResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated: whitespace-only name or slot_name, non-enum cardinality, malformed wire spec, wire references an unknown slot, invalid parameter_overrides_schema subset, or invalid Placement / Drawing nested VO." + }, + "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": "A referenced FamilyId (presents_as_family_id or any slot's required_families member) does not resolve to a defined Family." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Stream collision: the generated UUID already has an AssemblyDefined event (essentially impossible with UUIDv7 ids; defensive guard)." + }, + "422": { + "description": "Request body failed schema validation OR Idempotency-Key was reused with a different request body." + } + }, + "summary": "Define a new Assembly composition blueprint", + "tags": [ + "equipment" + ] + } + }, "/assets": { "get": { "operationId": "list_assets_assets_get", diff --git a/apps/api/src/cora/equipment/_template_slot_body.py b/apps/api/src/cora/equipment/_template_slot_body.py new file mode 100644 index 000000000..1bed68d4a --- /dev/null +++ b/apps/api/src/cora/equipment/_template_slot_body.py @@ -0,0 +1,87 @@ +"""Shared Pydantic wire-format mirror of the `TemplateSlot` value object. + +`TemplateSlot` is a frozen dataclass at the domain layer +(`cora.equipment.aggregates.assembly.state`). This body is the wire +shape REST and MCP parse, with `to_domain()` converting to the +domain VO (which may raise `InvalidSlotNameError`, +`InvalidSlotCardinalityError`, `InvalidTemplateSlotError`, or +`InvalidPlacementError` during construction). + +Pydantic enforces field-shape rules at the API boundary; domain +invariants (non-empty `required_families`, closed-enum +`cardinality`, NaN-rejecting `default_placement`) surface from the +domain VO constructors and map to HTTP 400 through the BC's +exception handler. + +Hoisted alongside `PlacementBody` / `DrawingBody` since both the +REST route and the MCP tool for `define_assembly` (and the future +`version_assembly` slice) parse the same shape. +""" + +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field + +from cora.equipment._placement_body import PlacementBody +from cora.equipment.aggregates.assembly.state import ( + SLOT_NAME_MAX_LENGTH, + SlotCardinality, + SlotName, + TemplateSlot, +) + + +class TemplateSlotBody(BaseModel): + """Wire format for a TemplateSlot value object.""" + + slot_name: str = Field( + ..., + min_length=1, + max_length=SLOT_NAME_MAX_LENGTH, + description=( + "Canonical name of this slot within the Assembly " + "(e.g., 'camera', 'rotary', 'trigger_source')." + ), + ) + required_families: frozenset[UUID] = Field( + ..., + min_length=1, + description=( + "Set of FamilyIds an instantiated Asset must include at " + "least one of. MUST be non-empty." + ), + ) + cardinality: SlotCardinality = Field( + ..., + description=( + "How many Assets can fill this slot at instantiation: " + "Exactly1, ZeroOrOne, OneOrMore, ZeroOrMore." + ), + ) + default_settings: dict[str, Any] | None = Field( + None, + description=( + "Optional template defaults applied at instantiation " + "unless overridden by the instantiator." + ), + ) + default_placement: PlacementBody | None = Field( + None, + description="Optional template-default placement for the slot.", + ) + + def to_domain(self) -> TemplateSlot: + """Convert this wire body to the domain TemplateSlot VO.""" + return TemplateSlot( + slot_name=SlotName(self.slot_name), + required_families=self.required_families, + cardinality=self.cardinality, + default_settings=self.default_settings, + default_placement=( + self.default_placement.to_domain() if self.default_placement is not None else None + ), + ) + + +__all__ = ["TemplateSlotBody"] diff --git a/apps/api/src/cora/equipment/_template_wire_body.py b/apps/api/src/cora/equipment/_template_wire_body.py new file mode 100644 index 000000000..bae4bcfa9 --- /dev/null +++ b/apps/api/src/cora/equipment/_template_wire_body.py @@ -0,0 +1,57 @@ +"""Shared Pydantic wire-format mirror of the `TemplateWire` value object. + +`TemplateWire` is a frozen dataclass at the domain layer +(`cora.equipment.aggregates.assembly.state`). The 4-tuple +`(source_slot_name, source_port_name, target_slot_name, +target_port_name)` IS the identity; the frozenset deduplicates on +the tuple. + +Pydantic enforces field length at the API boundary; the degenerate +full-self-loop case (same slot AND same port on both endpoints) +surfaces from `TemplateWire.__post_init__` as +`InvalidWireSpecError` -> HTTP 400. +""" + +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.assembly.state import ( + WIRE_PORT_NAME_MAX_LENGTH, + TemplateWire, +) + + +class TemplateWireBody(BaseModel): + """Wire format for a TemplateWire value object.""" + + source_slot_name: str = Field( + ..., + min_length=1, + max_length=WIRE_PORT_NAME_MAX_LENGTH, + ) + source_port_name: str = Field( + ..., + min_length=1, + max_length=WIRE_PORT_NAME_MAX_LENGTH, + ) + target_slot_name: str = Field( + ..., + min_length=1, + max_length=WIRE_PORT_NAME_MAX_LENGTH, + ) + target_port_name: str = Field( + ..., + min_length=1, + max_length=WIRE_PORT_NAME_MAX_LENGTH, + ) + + def to_domain(self) -> TemplateWire: + """Convert this wire body to the domain TemplateWire VO.""" + return TemplateWire( + source_slot_name=self.source_slot_name, + source_port_name=self.source_port_name, + target_slot_name=self.target_slot_name, + target_port_name=self.target_port_name, + ) + + +__all__ = ["TemplateWireBody"] diff --git a/apps/api/src/cora/equipment/aggregates/assembly/state.py b/apps/api/src/cora/equipment/aggregates/assembly/state.py index 04c06360d..ab9b5bdec 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/state.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/state.py @@ -198,7 +198,9 @@ class InvalidParameterOverridesSchemaError(ValueError): """The supplied parameter_overrides_schema is not a valid JSON Schema in CORA's constrained subset. - Mapped to HTTP 422 by the BC exception handler. + Mapped to HTTP 400 by the BC exception handler, matching the + `InvalidFamilySettingsSchemaError` precedent (both flow through + `_handle_validation_error` in routes.py). """ def __init__(self, reason: str) -> None: diff --git a/apps/api/src/cora/equipment/features/define_assembly/__init__.py b/apps/api/src/cora/equipment/features/define_assembly/__init__.py new file mode 100644 index 000000000..0e321e103 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_assembly/__init__.py @@ -0,0 +1,23 @@ +"""Vertical slice for the `DefineAssembly` command.""" + +from cora.equipment.features.define_assembly import tool +from cora.equipment.features.define_assembly.command import DefineAssembly +from cora.equipment.features.define_assembly.context import DefineAssemblyContext +from cora.equipment.features.define_assembly.decider import decide +from cora.equipment.features.define_assembly.handler import ( + Handler, + IdempotentHandler, + bind, +) +from cora.equipment.features.define_assembly.route import router + +__all__ = [ + "DefineAssembly", + "DefineAssemblyContext", + "Handler", + "IdempotentHandler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/define_assembly/command.py b/apps/api/src/cora/equipment/features/define_assembly/command.py new file mode 100644 index 000000000..0b541072c --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_assembly/command.py @@ -0,0 +1,49 @@ +"""The `DefineAssembly` command - intent dataclass for the define_assembly slice. + +Carries the caller-controlled structural fields: name, +presents_as_family_id, required_slots, required_wires, the optional +parameter_overrides_schema, the optional drawing, and the optional +operator-curatorial version label. + +Server-side concerns (new assembly_id, wall-clock timestamp, +correlation id, per-event ids, computed content_hash) are injected +by the handler from infrastructure ports or computed pre-emit. + +Slots and wires arrive as fully-constructed domain VOs (the route +layer's TemplateSlotBody / TemplateWireBody call .to_domain() before +the command is built); structural VO-level invariants (slot-name +length, cardinality enum membership, non-empty required_families, +wire-port-name length, degenerate-full-self-loop rejection, wire- +endpoints-reference-declared-slots closure via Assembly.__post_init__ +when state is constructed in the evolver) all fire at VO construction +or evolver-fold time, NOT inside the decider. + +Cross-aggregate references checked by the handler before the decider: + - presents_as_family_id must resolve to a defined Family. + - Every FamilyId in every slot's required_families must resolve. + +Cross-aggregate references NOT checked: + - Asset existence (Assembly is a template; the Assets do not exist + yet at define time). instantiate_assembly checks Asset existence + when it lands. +""" + +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + +from cora.equipment.aggregates._drawing import Drawing +from cora.equipment.aggregates.assembly import TemplateSlot, TemplateWire + + +@dataclass(frozen=True) +class DefineAssembly: + """Define a new Assembly composition blueprint.""" + + name: str + presents_as_family_id: UUID + required_slots: frozenset[TemplateSlot] = field(default_factory=frozenset[TemplateSlot]) + required_wires: frozenset[TemplateWire] = field(default_factory=frozenset[TemplateWire]) + parameter_overrides_schema: dict[str, Any] | None = None + drawing: Drawing | None = None + version: str | None = None diff --git a/apps/api/src/cora/equipment/features/define_assembly/context.py b/apps/api/src/cora/equipment/features/define_assembly/context.py new file mode 100644 index 000000000..4bcbfab63 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_assembly/context.py @@ -0,0 +1,25 @@ +"""Context snapshot loaded by the define_assembly handler. + +The define_assembly slice uses single-stream-write + projection- +precondition (per project_mount_frame_design install_asset +precedent). The handler loads each referenced FamilyId via +`load_family` before calling the decider; the context VO carries +the set of FamilyIds that did NOT resolve to a defined Family. + +`missing_family_ids` empty means all referenced Families exist (the +decider can proceed). When non-empty, the decider raises +FamilyNotFoundForAssemblyError carrying the sorted-first missing id +so error responses are stable across runs; surfacing the full set +on a single error would force the route layer to encode a list shape +that no shipped error currently uses. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class DefineAssemblyContext: + """Snapshot of FamilyId existence checks for define_assembly.""" + + missing_family_ids: frozenset[UUID] diff --git a/apps/api/src/cora/equipment/features/define_assembly/decider.py b/apps/api/src/cora/equipment/features/define_assembly/decider.py new file mode 100644 index 000000000..f0f3c55b3 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_assembly/decider.py @@ -0,0 +1,124 @@ +"""Pure decider for the `DefineAssembly` command. + +Pure function: given the current Assembly state (None for a fresh +stream), the loaded context (FamilyId existence checks), and the +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. + +## Invariants + + - State must be None (genesis-only) -> AssemblyAlreadyExistsError + via stream collision (essentially impossible with UUIDv7 ids; + defensive guard). + - `context.missing_family_ids` must be empty + -> FamilyNotFoundForAssemblyError carrying the FIRST missing id. + - `command.name` must be valid -> InvalidAssemblyNameError (via + AssemblyName VO). + - `command.parameter_overrides_schema`, when non-None, must be a + well-formed JSON Schema in CORA's constrained subset + -> InvalidParameterOverridesSchemaError (via shared declarer + validator). + +Structural VO invariants on `required_slots` / `required_wires` +(slot-name length, cardinality enum, non-empty required_families, +wire-port-name length, full-self-loop rejection) fire at VO +construction time in the route / tool layers, never inside the +decider. Internal closure (every wire endpoint references a declared +slot) is enforced by `Assembly.__post_init__` when the evolver folds +the AssemblyDefined event into state. +""" + +from datetime import datetime +from uuid import UUID + +from cora.equipment.aggregates._assembly_content_hash import compute_assembly_content_hash +from cora.equipment.aggregates.assembly import ( + Assembly, + AssemblyAlreadyExistsError, + AssemblyDefined, + AssemblyName, + FamilyNotFoundForAssemblyError, + InvalidParameterOverridesSchemaError, + WireReferencesUnknownSlotError, +) +from cora.equipment.features.define_assembly.command import DefineAssembly +from cora.equipment.features.define_assembly.context import DefineAssemblyContext +from cora.infrastructure.json_schema_validation import validate_schema_declaration + + +def decide( + state: Assembly | None, + command: DefineAssembly, + *, + context: DefineAssemblyContext, + now: datetime, + new_id: UUID, +) -> list[AssemblyDefined]: + """Decide the events produced by defining a new Assembly. + + Invariants: + - State must be None (genesis-only) -> AssemblyAlreadyExistsError + via stream collision; carries the pre-existing assembly_id. + - context.missing_family_ids must be empty + -> FamilyNotFoundForAssemblyError carrying the sorted-first + missing FamilyId for deterministic error responses. + - command.name must be valid -> InvalidAssemblyNameError + (via AssemblyName VO). + - Every wire endpoint must reference a slot in required_slots + -> WireReferencesUnknownSlotError carrying the offending + slot_name. + - command.parameter_overrides_schema, when non-None, must be a + well-formed JSON Schema in CORA's constrained subset + -> InvalidParameterOverridesSchemaError. + """ + if state is not None: + raise AssemblyAlreadyExistsError(state.id) + if context.missing_family_ids: + first_missing = next(iter(sorted(context.missing_family_ids, key=str))) + raise FamilyNotFoundForAssemblyError(first_missing) + + name = AssemblyName(command.name) + + # Internal closure: every wire endpoint must reference a declared + # slot. The same invariant lives on `Assembly.__post_init__` so + # the evolver fold also rejects a corrupt event stream, but the + # decider fires it first so a bad command surfaces at the API + # boundary as a 400 rather than as a load-time evolver fault. + slot_names = {slot.slot_name.value for slot in command.required_slots} + for wire in command.required_wires: + if wire.source_slot_name not in slot_names: + raise WireReferencesUnknownSlotError(wire.source_slot_name) + if wire.target_slot_name not in slot_names: + raise WireReferencesUnknownSlotError(wire.target_slot_name) + + if command.parameter_overrides_schema is not None: + validate_schema_declaration( + command.parameter_overrides_schema, + error_class=InvalidParameterOverridesSchemaError, + ) + + content_hash = compute_assembly_content_hash( + name=name, + presents_as_family_id=command.presents_as_family_id, + required_slots=command.required_slots, + required_wires=command.required_wires, + parameter_overrides_schema=command.parameter_overrides_schema, + ) + + return [ + AssemblyDefined( + assembly_id=new_id, + name=name, + presents_as_family_id=command.presents_as_family_id, + required_slots=command.required_slots, + required_wires=command.required_wires, + parameter_overrides_schema=command.parameter_overrides_schema, + drawing=command.drawing, + version=command.version, + content_hash=content_hash, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/define_assembly/handler.py b/apps/api/src/cora/equipment/features/define_assembly/handler.py new file mode 100644 index 000000000..15aecb3ba --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_assembly/handler.py @@ -0,0 +1,170 @@ +"""Application handler for the `define_assembly` slice. + +Longhand create-style handler (cannot use a factory because it +loads N cross-aggregate references BEFORE calling the decider): + + 1. Authz check (Deny -> UnauthorizedError). + 2. Load Family aggregate for `presents_as_family_id` + every + FamilyId across the slot set's required_families; collect the + missing ones into context. + 3. Call pure decider with state=None + context + command + + now + new_id. + 4. Wrap emitted events and append to the Assembly stream + (single-stream write with expected_version=0 for genesis). + +Pattern mirrors register_mount's longhand-with-precondition shape +but checks N references instead of one. Family loads are concurrent +via `asyncio.gather` to keep latency bounded. +""" + +import asyncio +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.assembly import event_type_name, to_payload +from cora.equipment.aggregates.family import load_family +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.define_assembly.command import DefineAssembly +from cora.equipment.features.define_assembly.context import DefineAssemblyContext +from cora.equipment.features.define_assembly.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 = "Assembly" +_COMMAND_NAME = "DefineAssembly" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare define_assembly handler - what `bind()` returns.""" + + async def __call__( + self, + command: DefineAssembly, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +class IdempotentHandler(Protocol): + """define_assembly handler with Idempotency-Key support.""" + + async def __call__( + self, + command: DefineAssembly, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + idempotency_key: str | None = None, + ) -> UUID: ... + + +def _referenced_family_ids(command: DefineAssembly) -> frozenset[UUID]: + """Collect every FamilyId the Assembly references at define time. + + Union of `presents_as_family_id` and every slot's required_families. + Returned as a frozenset so handler loads are de-duplicated when + multiple slots share a Family. + """ + ids: set[UUID] = {command.presents_as_family_id} + for slot in command.required_slots: + ids.update(slot.required_families) + return frozenset(ids) + + +def bind(deps: Kernel) -> Handler: + """Build a define_assembly handler closed over the shared deps.""" + + async def handler( + command: DefineAssembly, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "define_assembly.start", + command_name=_COMMAND_NAME, + 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_assembly.denied", + command_name=_COMMAND_NAME, + name=command.name, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + new_id = deps.id_generator.new_id() + now = deps.clock.now() + + family_ids = _referenced_family_ids(command) + loaded = await asyncio.gather(*(load_family(deps.event_store, fid) for fid in family_ids)) + missing = frozenset( + fid for fid, family in zip(family_ids, loaded, strict=True) if family is None + ) + context = DefineAssemblyContext(missing_family_ids=missing) + + domain_events = decide( + state=None, + command=command, + context=context, + 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_assembly.success", + command_name=_COMMAND_NAME, + assembly_id=str(new_id), + name=command.name, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + event_count=len(new_events), + ) + return new_id + + return handler diff --git a/apps/api/src/cora/equipment/features/define_assembly/route.py b/apps/api/src/cora/equipment/features/define_assembly/route.py new file mode 100644 index 000000000..be9475455 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_assembly/route.py @@ -0,0 +1,185 @@ +"""HTTP route for the `define_assembly` slice. + +POST /assemblies: define a new composition blueprint. + +Re-uses the shared `PlacementBody`, `DrawingBody`, `TemplateSlotBody`, +and `TemplateWireBody` wire shapes; the route handler converts every +nested wire body to its domain VO before constructing the command. +""" + +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request, status +from pydantic import BaseModel, Field + +from cora.equipment._drawing_body import DrawingBody +from cora.equipment._template_slot_body import TemplateSlotBody +from cora.equipment._template_wire_body import TemplateWireBody +from cora.equipment.aggregates.assembly import ASSEMBLY_NAME_MAX_LENGTH +from cora.equipment.features.define_assembly.command import DefineAssembly +from cora.equipment.features.define_assembly.handler import IdempotentHandler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class DefineAssemblyRequest(BaseModel): + """Body for `POST /assemblies`.""" + + name: str = Field( + ..., + min_length=1, + max_length=ASSEMBLY_NAME_MAX_LENGTH, + description=( + "Human-readable display name. Non-unique; the UUID is the " + "storage identity and the content_hash is the structural " + "fingerprint." + ), + ) + presents_as_family_id: UUID = Field( + ..., + description=( + "FamilyId the instantiated Assembly stands in for at the " + "Method.needed_families satisfaction boundary." + ), + ) + required_slots: list[TemplateSlotBody] = Field( + default_factory=list[TemplateSlotBody], + description=( + "Slots that compose this Assembly. Each names a slot " + "(canonical identity within the blueprint), the FamilyIds " + "any filling Asset must include, the cardinality, and " + "optional template defaults. Wire shape is a list; " + "duplicates collapse when the route handler converts to " + "the domain frozenset." + ), + ) + required_wires: list[TemplateWireBody] = Field( + default_factory=list[TemplateWireBody], + description=( + "Slot-to-slot signal wires inside the Assembly, keyed by " + "slot_name (NOT Asset UUID). Endpoints must reference " + "slots declared in required_slots. Wire shape is a list; " + "duplicates collapse when the route handler converts to " + "the domain frozenset." + ), + ) + parameter_overrides_schema: dict[str, Any] | None = Field( + None, + description=( + "Optional JSON Schema subset declaring the shape of " + "parameter_overrides accepted at instantiation." + ), + ) + drawing: DrawingBody | None = Field( + None, + description=( + "Optional engineering reference for the assembly itself. " + "Excluded from the content_hash." + ), + ) + version: str | None = Field( + None, + description=( + "Operator-curated free-form version label (no SemVer " + "enforcement). Excluded from the content_hash." + ), + ) + + +class DefineAssemblyResponse(BaseModel): + """Response body for `POST /assemblies`.""" + + assembly_id: UUID + + +def _get_handler(request: Request) -> IdempotentHandler: + handler: IdempotentHandler = request.app.state.equipment.define_assembly + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/assemblies", + status_code=status.HTTP_201_CREATED, + response_model=DefineAssemblyResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated: whitespace-only name or " + "slot_name, non-enum cardinality, malformed wire spec, " + "wire references an unknown slot, invalid " + "parameter_overrides_schema subset, or invalid Placement " + "/ Drawing nested VO." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": ( + "A referenced FamilyId (presents_as_family_id or any " + "slot's required_families member) does not resolve to " + "a defined Family." + ), + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Stream collision: the generated UUID already has an " + "AssemblyDefined event (essentially impossible with " + "UUIDv7 ids; defensive guard)." + ), + }, + 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 Assembly composition blueprint", +) +async def post_assemblies( + body: DefineAssemblyRequest, + 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 key for idempotent retry: " + "replaying the same key returns the original " + "assembly_id without creating a new Assembly." + ), + ), + ] = None, +) -> DefineAssemblyResponse: + assembly_id = await handler( + DefineAssembly( + name=body.name, + presents_as_family_id=body.presents_as_family_id, + required_slots=frozenset(slot.to_domain() for slot in body.required_slots), + required_wires=frozenset(wire.to_domain() for wire in body.required_wires), + parameter_overrides_schema=body.parameter_overrides_schema, + drawing=body.drawing.to_domain() if body.drawing is not None else None, + version=body.version, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + idempotency_key=idempotency_key, + ) + return DefineAssemblyResponse(assembly_id=assembly_id) diff --git a/apps/api/src/cora/equipment/features/define_assembly/tool.py b/apps/api/src/cora/equipment/features/define_assembly/tool.py new file mode 100644 index 000000000..963485046 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_assembly/tool.py @@ -0,0 +1,96 @@ +"""MCP tool for the `define_assembly` 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._drawing_body import DrawingBody +from cora.equipment._template_slot_body import TemplateSlotBody +from cora.equipment._template_wire_body import TemplateWireBody +from cora.equipment.aggregates.assembly import ASSEMBLY_NAME_MAX_LENGTH +from cora.equipment.features.define_assembly.command import DefineAssembly +from cora.equipment.features.define_assembly.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 DefineAssemblyOutput(BaseModel): + assembly_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> None: + @mcp.tool( + name="define_assembly", + description=( + "Define a new Assembly composition blueprint. An Assembly " + "is a content-addressed template that declares the slots " + "(Family-typed) and signal wires of a reusable cluster of " + "Assets (e.g., the MCTOptics detector fixture: microscope " + "+ objectives + camera + scintillator, wired together). " + "presents_as_family_id is the FamilyId the instantiated " + "Assembly stands in for at Method.needed_families " + "satisfaction time. Note: this MCP surface has no " + "idempotency-key equivalent of the REST Idempotency-Key " + "header; retries of a failed or lost call may create " + "duplicate Assemblies." + ), + ) + async def define_assembly_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + name: Annotated[ + str, + Field( + min_length=1, + max_length=ASSEMBLY_NAME_MAX_LENGTH, + description="Human-readable display name.", + ), + ], + presents_as_family_id: Annotated[ + UUID, + Field(description="FamilyId the instantiated Assembly stands in for."), + ], + required_slots: Annotated[ + list[TemplateSlotBody], + Field(description="Slots that compose this Assembly."), + ] = [], # noqa: B006 (FastMCP requires a literal default; the route handler copies into a frozenset before storage) + required_wires: Annotated[ + list[TemplateWireBody], + Field(description="Slot-to-slot signal wires inside the Assembly."), + ] = [], # noqa: B006 + parameter_overrides_schema: Annotated[ + dict[str, Any] | None, + Field( + description=( + "Optional JSON Schema subset for parameter_overrides accepted at instantiation." + ), + ), + ] = None, + drawing: Annotated[ + DrawingBody | None, + Field(description="Optional engineering reference for the assembly."), + ] = None, + version: Annotated[ + str | None, + Field(description="Operator-curated free-form version label."), + ] = None, + ) -> DefineAssemblyOutput: + handler = get_handler() + assembly_id = await handler( + DefineAssembly( + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=frozenset(s.to_domain() for s in required_slots), + required_wires=frozenset(w.to_domain() for w in required_wires), + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing.to_domain() if drawing is not None else None, + version=version, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return DefineAssemblyOutput(assembly_id=assembly_id) diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 46a4572c1..cfe6ab1a5 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -141,6 +141,7 @@ decommission_asset, decommission_frame, decommission_mount, + define_assembly, define_family, define_model, degrade_asset, @@ -283,6 +284,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(decommission_mount.router) app.include_router(install_asset.router) app.include_router(uninstall_asset.router) + app.include_router(define_assembly.router) for validation_cls in ( InvalidAffordanceError, InvalidFamilyNameError, diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index 512e5260f..ff84eb985 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -23,6 +23,7 @@ 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 +from cora.equipment.features.define_assembly import tool as define_assembly_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 @@ -238,3 +239,7 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().uninstall_asset, ) + define_assembly_tool.register( + mcp, + get_handler=lambda: get_handlers().define_assembly, + ) diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 74d76a09d..91975238f 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -40,6 +40,7 @@ decommission_asset, decommission_frame, decommission_mount, + define_assembly, define_family, define_model, degrade_asset, @@ -149,6 +150,7 @@ class EquipmentHandlers: decommission_mount: decommission_mount.Handler install_asset: install_asset.Handler uninstall_asset: uninstall_asset.Handler + define_assembly: define_assembly.IdempotentHandler def wire_equipment(deps: Kernel) -> EquipmentHandlers: @@ -397,4 +399,16 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="UninstallAsset", bc=_BC, ), + define_assembly=with_tracing( + with_idempotency( + define_assembly.bind(deps), + deps.idempotency_store, + command_name="DefineAssembly", + serialize_result=str, + deserialize_result=UUID, + lock_stale_seconds=deps.settings.idempotency_lock_stale_seconds, + ), + command_name="DefineAssembly", + bc=_BC, + ), ) diff --git a/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py index 0b79e382f..989592570 100644 --- a/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py +++ b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py @@ -50,13 +50,26 @@ # token in event_type discriminators (`case "AssemblyDefined":`), # docstrings, and class names. Added at v1 ship of the # aggregate; widen the list as new sites land at gate review. + "apps/api/src/cora/equipment/_template_slot_body.py", + "apps/api/src/cora/equipment/_template_wire_body.py", "apps/api/src/cora/equipment/aggregates/_assembly_content_hash.py", "apps/api/src/cora/equipment/aggregates/assembly/__init__.py", "apps/api/src/cora/equipment/aggregates/assembly/events.py", "apps/api/src/cora/equipment/aggregates/assembly/evolver.py", "apps/api/src/cora/equipment/aggregates/assembly/read.py", "apps/api/src/cora/equipment/aggregates/assembly/state.py", + "apps/api/src/cora/equipment/features/define_assembly/__init__.py", + "apps/api/src/cora/equipment/features/define_assembly/command.py", + "apps/api/src/cora/equipment/features/define_assembly/context.py", + "apps/api/src/cora/equipment/features/define_assembly/decider.py", + "apps/api/src/cora/equipment/features/define_assembly/handler.py", + "apps/api/src/cora/equipment/features/define_assembly/route.py", + "apps/api/src/cora/equipment/features/define_assembly/tool.py", "apps/api/src/cora/equipment/projections/assembly_summary.py", + "apps/api/tests/contract/test_assemblies_endpoint.py", + "apps/api/tests/contract/test_define_assembly_mcp_tool.py", + "apps/api/tests/integration/test_define_assembly_handler_postgres.py", + "apps/api/tests/unit/equipment/test_define_assembly_decider_properties.py", "apps/api/tests/unit/equipment/test_assembly_content_hash.py", "apps/api/tests/unit/equipment/test_assembly_events.py", "apps/api/tests/unit/equipment/test_assembly_evolver.py", @@ -64,6 +77,7 @@ "apps/api/tests/unit/equipment/test_assembly_summary_projection.py", "apps/api/tests/unit/equipment/test_assembly_template_slot.py", "apps/api/tests/unit/equipment/test_assembly_template_wire.py", + "apps/api/tests/unit/equipment/test_define_assembly_decider.py", } ) diff --git a/apps/api/tests/contract/test_assemblies_endpoint.py b/apps/api/tests/contract/test_assemblies_endpoint.py new file mode 100644 index 000000000..bdf19a7e3 --- /dev/null +++ b/apps/api/tests/contract/test_assemblies_endpoint.py @@ -0,0 +1,309 @@ +"""Contract tests for `POST /assemblies`. + +Covers the create-style basics (request schema, response schema, +status codes), the structural validation at the API boundary +(missing required fields, malformed wire, invalid cardinality, +unknown-slot wire endpoint), and the FamilyNotFound 404 path via +the in-memory event store. + +Assembly is the 5th Equipment aggregate; per the design memo's +gate-review discipline, contract tests ship with the slice (no +EXEMPT_FROM_*_CONTRACT allowlist entry). +""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.aggregates.assembly import ASSEMBLY_NAME_MAX_LENGTH + + +def _define_family(client: TestClient, name: str = "Camera") -> UUID: + """Define a Family and return its id. Used to seed presents_as_family_id.""" + response = client.post( + "/families", + json={"name": name, "affordances": []}, + ) + assert response.status_code == 201, response.text + return UUID(response.json()["family_id"]) + + +@pytest.mark.contract +def test_post_assemblies_returns_201_with_assembly_id_for_minimal_body() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + response = client.post( + "/assemblies", + json={ + "name": "Detector", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + ) + assert response.status_code == 201, response.text + body = response.json() + assert "assembly_id" in body + UUID(body["assembly_id"]) + + +@pytest.mark.contract +def test_post_assemblies_returns_201_with_slots_and_wires() -> None: + with TestClient(create_app()) as client: + presents_id = _define_family(client, "Detector") + camera_family = _define_family(client, "Camera") + trigger_family = _define_family(client, "TriggerSource") + response = client.post( + "/assemblies", + json={ + "name": "MCTOptics", + "presents_as_family_id": str(presents_id), + "required_slots": [ + { + "slot_name": "camera", + "required_families": [str(camera_family)], + "cardinality": "Exactly1", + }, + { + "slot_name": "trigger_source", + "required_families": [str(trigger_family)], + "cardinality": "Exactly1", + }, + ], + "required_wires": [ + { + "source_slot_name": "trigger_source", + "source_port_name": "trigger_out", + "target_slot_name": "camera", + "target_port_name": "trigger_in", + } + ], + }, + ) + assert response.status_code == 201, response.text + + +@pytest.mark.contract +def test_post_assemblies_returns_404_for_unknown_presents_as_family_id() -> None: + with TestClient(create_app()) as client: + response = client.post( + "/assemblies", + json={ + "name": "Detector", + "presents_as_family_id": str(uuid4()), + "required_slots": [], + "required_wires": [], + }, + ) + assert response.status_code == 404, response.text + assert "Family" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_assemblies_returns_404_when_slot_required_family_missing() -> None: + with TestClient(create_app()) as client: + presents_id = _define_family(client, "Detector") + response = client.post( + "/assemblies", + json={ + "name": "Detector", + "presents_as_family_id": str(presents_id), + "required_slots": [ + { + "slot_name": "camera", + "required_families": [str(uuid4())], + "cardinality": "Exactly1", + } + ], + "required_wires": [], + }, + ) + assert response.status_code == 404, response.text + + +@pytest.mark.contract +def test_post_assemblies_returns_400_for_invalid_parameter_overrides_schema() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + response = client.post( + "/assemblies", + json={ + "name": "Detector", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + "parameter_overrides_schema": {"oneOf": [{"type": "object"}]}, + }, + ) + assert response.status_code == 400, response.text + assert "parameter_overrides_schema" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_assemblies_returns_400_when_wire_references_unknown_slot() -> None: + with TestClient(create_app()) as client: + presents_id = _define_family(client, "Detector") + camera_family = _define_family(client, "Camera") + response = client.post( + "/assemblies", + json={ + "name": "Detector", + "presents_as_family_id": str(presents_id), + "required_slots": [ + { + "slot_name": "camera", + "required_families": [str(camera_family)], + "cardinality": "Exactly1", + } + ], + "required_wires": [ + { + "source_slot_name": "missing_slot", + "source_port_name": "out", + "target_slot_name": "camera", + "target_port_name": "in", + } + ], + }, + ) + assert response.status_code == 400, response.text + assert "missing_slot" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_assemblies_returns_400_for_degenerate_full_self_loop_wire() -> None: + with TestClient(create_app()) as client: + presents_id = _define_family(client, "Detector") + lut_family = _define_family(client, "Lut") + response = client.post( + "/assemblies", + json={ + "name": "X", + "presents_as_family_id": str(presents_id), + "required_slots": [ + { + "slot_name": "lut", + "required_families": [str(lut_family)], + "cardinality": "Exactly1", + } + ], + "required_wires": [ + { + "source_slot_name": "lut", + "source_port_name": "out", + "target_slot_name": "lut", + "target_port_name": "out", + } + ], + }, + ) + assert response.status_code == 400, response.text + assert "degenerate" in response.json()["detail"].lower() + + +@pytest.mark.contract +def test_post_assemblies_returns_422_for_missing_name() -> None: + with TestClient(create_app()) as client: + response = client.post( + "/assemblies", + json={"presents_as_family_id": str(uuid4())}, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assemblies_returns_422_for_name_too_long() -> None: + with TestClient(create_app()) as client: + response = client.post( + "/assemblies", + json={ + "name": "x" * (ASSEMBLY_NAME_MAX_LENGTH + 1), + "presents_as_family_id": str(uuid4()), + }, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assemblies_returns_422_for_unknown_cardinality() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + response = client.post( + "/assemblies", + json={ + "name": "X", + "presents_as_family_id": str(family_id), + "required_slots": [ + { + "slot_name": "camera", + "required_families": [str(family_id)], + "cardinality": "Bogus", + } + ], + "required_wires": [], + }, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assemblies_returns_422_for_empty_required_families() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + response = client.post( + "/assemblies", + json={ + "name": "X", + "presents_as_family_id": str(family_id), + "required_slots": [ + { + "slot_name": "orphan", + "required_families": [], + "cardinality": "ZeroOrMore", + } + ], + "required_wires": [], + }, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assemblies_idempotency_key_returns_same_assembly_id() -> None: + """Idempotency-Key replay returns the original assembly_id.""" + with TestClient(create_app()) as client: + family_id = _define_family(client) + body: dict[str, object] = { + "name": "Idempotent", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + } + headers = {"Idempotency-Key": "test-key-12345"} + first = client.post("/assemblies", json=body, headers=headers) + assert first.status_code == 201, first.text + second = client.post("/assemblies", json=body, headers=headers) + assert second.status_code in (200, 201) + assert first.json()["assembly_id"] == second.json()["assembly_id"] + + +@pytest.mark.contract +def test_post_assemblies_response_omits_content_hash() -> None: + """The POST /assemblies response carries only assembly_id; content_hash + is on the event payload and surfaces via list_assemblies / get_assembly + when those slices ship.""" + with TestClient(create_app()) as client: + family_id = _define_family(client) + response = client.post( + "/assemblies", + json={ + "name": "X", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + ) + assert response.status_code == 201 + assert response.json().keys() == {"assembly_id"} diff --git a/apps/api/tests/contract/test_define_assembly_mcp_tool.py b/apps/api/tests/contract/test_define_assembly_mcp_tool.py new file mode 100644 index 000000000..1ebcb59e4 --- /dev/null +++ b/apps/api/tests/contract/test_define_assembly_mcp_tool.py @@ -0,0 +1,154 @@ +"""Contract tests for the `define_assembly` MCP tool. + +Mirrors `test_activate_asset_mcp_tool.py`. Shared MCP helpers live +in `tests/contract/_mcp_helpers.py`. +""" + +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 + + +def _define_family_via_tool( + client: TestClient, + headers: dict[str, str], + name: str = "Camera", +) -> UUID: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "define_family", + "arguments": {"name": name, "affordances": []}, + }, + }, + headers=headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + return UUID(body["result"]["structuredContent"]["family_id"]) + + +@pytest.mark.contract +def test_mcp_lists_define_assembly_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 "define_assembly" in tool_names + + +@pytest.mark.contract +def test_mcp_define_assembly_tool_succeeds_for_minimal_args() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + family_id = _define_family_via_tool(client, headers) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "define_assembly", + "arguments": { + "name": "Detector", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is False + assembly_id = body["result"]["structuredContent"]["assembly_id"] + UUID(assembly_id) + + +@pytest.mark.contract +def test_mcp_define_assembly_tool_returns_iserror_for_unknown_family() -> None: + """FamilyNotFoundForAssemblyError propagates -> FastMCP wraps as isError.""" + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "define_assembly", + "arguments": { + "name": "Detector", + "presents_as_family_id": str(uuid4()), + "required_slots": [], + "required_wires": [], + }, + }, + }, + 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_define_assembly_tool_succeeds_with_slot_and_wire() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + presents_id = _define_family_via_tool(client, headers, "Detector") + camera_family = _define_family_via_tool(client, headers, "Camera") + trigger_family = _define_family_via_tool(client, headers, "TriggerSource") + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "define_assembly", + "arguments": { + "name": "MCTOptics", + "presents_as_family_id": str(presents_id), + "required_slots": [ + { + "slot_name": "camera", + "required_families": [str(camera_family)], + "cardinality": "Exactly1", + }, + { + "slot_name": "trigger_source", + "required_families": [str(trigger_family)], + "cardinality": "Exactly1", + }, + ], + "required_wires": [ + { + "source_slot_name": "trigger_source", + "source_port_name": "trigger_out", + "target_slot_name": "camera", + "target_port_name": "trigger_in", + } + ], + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is False diff --git a/apps/api/tests/integration/test_define_assembly_handler_postgres.py b/apps/api/tests/integration/test_define_assembly_handler_postgres.py new file mode 100644 index 000000000..7f3fc41a6 --- /dev/null +++ b/apps/api/tests/integration/test_define_assembly_handler_postgres.py @@ -0,0 +1,77 @@ +"""End-to-end integration test: define_assembly handler against real Postgres. + +Mirrors `test_define_family_handler_postgres.py`. The Assembly stream +emits one AssemblyDefined event; the handler verifies the +presents_as_family_id resolves before appending (so a prior +FamilyDefined event must exist in the same Postgres pool). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment.features import define_assembly, define_family +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_family import DefineFamily +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_FAMILY_ID = UUID("01900000-0000-7000-8000-00000054cb01") +_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb0e") +_ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054cb02") +_ASSEMBLY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb1e") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000bb") + + +@pytest.mark.integration +async def test_define_assembly_persists_event_to_postgres( + db_pool: asyncpg.Pool, +) -> None: + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + _FAMILY_ID, + _FAMILY_EVENT_ID, + _ASSEMBLY_ID, + _ASSEMBLY_EVENT_ID, + ], + ) + + family_id = await define_family.bind(deps)( + DefineFamily(name="Detector", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert family_id == _FAMILY_ID + + assembly_id = await define_assembly.bind(deps)( + DefineAssembly(name="MCTOptics", presents_as_family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert assembly_id == _ASSEMBLY_ID + + events, version = await deps.event_store.load("Assembly", _ASSEMBLY_ID) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "AssemblyDefined" + assert stored.schema_version == 1 + payload = stored.payload + assert payload["assembly_id"] == str(_ASSEMBLY_ID) + assert payload["name"] == "MCTOptics" + assert payload["presents_as_family_id"] == str(_FAMILY_ID) + assert payload["required_slots"] == [] + assert payload["required_wires"] == [] + assert payload["parameter_overrides_schema"] is None + assert payload["drawing"] is None + assert payload["version"] is None + assert len(payload["content_hash"]) == 64 + assert stored.correlation_id == _CORRELATION_ID + assert stored.event_id == _ASSEMBLY_EVENT_ID + assert stored.metadata == {"command": "DefineAssembly"} + assert stored.occurred_at == _NOW diff --git a/apps/api/tests/unit/equipment/test_define_assembly_decider.py b/apps/api/tests/unit/equipment/test_define_assembly_decider.py new file mode 100644 index 000000000..0075fe3ea --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_assembly_decider.py @@ -0,0 +1,263 @@ +"""Unit tests for the `define_assembly` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.assembly import ( + Assembly, + AssemblyAlreadyExistsError, + AssemblyDefined, + AssemblyName, + AssemblyStatus, + FamilyNotFoundForAssemblyError, + InvalidAssemblyNameError, + InvalidParameterOverridesSchemaError, + SlotCardinality, + SlotName, + TemplateSlot, + TemplateWire, +) +from cora.equipment.features import define_assembly +from cora.equipment.features.define_assembly import ( + DefineAssembly, + DefineAssemblyContext, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _slot(name: str = "camera", family_id: object = None) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_families=frozenset({family_id or uuid4()}), # type: ignore[arg-type] + cardinality=SlotCardinality.EXACTLY_1, + ) + + +@pytest.mark.unit +def test_decide_emits_assembly_defined_for_minimal_command() -> None: + new_id = uuid4() + family_id = uuid4() + events = define_assembly.decide( + state=None, + command=DefineAssembly( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset(), + required_wires=frozenset(), + ), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + new_id=new_id, + ) + assert len(events) == 1 + event = events[0] + assert isinstance(event, AssemblyDefined) + assert event.assembly_id == new_id + assert event.name == AssemblyName("Detector") + assert event.presents_as_family_id == family_id + assert event.required_slots == frozenset() + assert event.required_wires == frozenset() + assert event.drawing is None + assert event.version is None + assert event.parameter_overrides_schema is None + assert event.occurred_at == _NOW + assert len(event.content_hash) == 64 + assert all(c in "0123456789abcdef" for c in event.content_hash) + + +@pytest.mark.unit +def test_decide_emits_assembly_defined_with_slots_and_wires_and_version() -> None: + new_id = uuid4() + family_id = uuid4() + sf_camera, sf_trigger = uuid4(), uuid4() + slot_camera = _slot("camera", sf_camera) + slot_trigger = _slot("trigger_source", sf_trigger) + wire = TemplateWire( + source_slot_name="trigger_source", + source_port_name="trigger_out", + target_slot_name="camera", + target_port_name="trigger_in", + ) + events = define_assembly.decide( + state=None, + command=DefineAssembly( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset({slot_camera, slot_trigger}), + required_wires=frozenset({wire}), + parameter_overrides_schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + }, + version="v0.1.0", + ), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + new_id=new_id, + ) + assert len(events) == 1 + event = events[0] + assert event.required_slots == frozenset({slot_camera, slot_trigger}) + assert event.required_wires == frozenset({wire}) + assert event.parameter_overrides_schema == { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + } + assert event.version == "v0.1.0" + + +@pytest.mark.unit +def test_decide_rejects_non_none_state_with_assembly_already_exists() -> None: + existing_id = uuid4() + family_id = uuid4() + state = Assembly( + id=existing_id, + name=AssemblyName("Existing"), + presents_as_family_id=family_id, + status=AssemblyStatus.DEFINED, + ) + with pytest.raises(AssemblyAlreadyExistsError) as exc_info: + define_assembly.decide( + state=state, + command=DefineAssembly( + name="X", + presents_as_family_id=family_id, + ), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.assembly_id == existing_id + + +@pytest.mark.unit +def test_decide_rejects_missing_presents_as_family_id_with_family_not_found() -> None: + family_id = uuid4() + with pytest.raises(FamilyNotFoundForAssemblyError) as exc_info: + define_assembly.decide( + state=None, + command=DefineAssembly( + name="Detector", + presents_as_family_id=family_id, + ), + context=DefineAssemblyContext(missing_family_ids=frozenset({family_id})), + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.family_id == family_id + + +@pytest.mark.unit +def test_decide_rejects_missing_slot_required_family_with_family_not_found() -> None: + presents_id = uuid4() + slot_family = uuid4() + slot = _slot("camera", slot_family) + with pytest.raises(FamilyNotFoundForAssemblyError) as exc_info: + define_assembly.decide( + state=None, + command=DefineAssembly( + name="Detector", + presents_as_family_id=presents_id, + required_slots=frozenset({slot}), + ), + context=DefineAssemblyContext(missing_family_ids=frozenset({slot_family})), + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.family_id == slot_family + + +@pytest.mark.unit +def test_decide_surfaces_first_missing_family_id_deterministically() -> None: + """When multiple families are missing, the decider raises with the + sorted-first id so error responses are stable across runs.""" + a, b, c = uuid4(), uuid4(), uuid4() + missing = {a, b, c} + expected_first = sorted(missing, key=str)[0] + with pytest.raises(FamilyNotFoundForAssemblyError) as exc_info: + define_assembly.decide( + state=None, + command=DefineAssembly(name="X", presents_as_family_id=a), + context=DefineAssemblyContext(missing_family_ids=frozenset(missing)), + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.family_id == expected_first + + +@pytest.mark.unit +def test_decide_rejects_invalid_name_via_vo() -> None: + with pytest.raises(InvalidAssemblyNameError): + define_assembly.decide( + state=None, + command=DefineAssembly(name=" ", presents_as_family_id=uuid4()), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_rejects_invalid_parameter_overrides_schema() -> None: + with pytest.raises(InvalidParameterOverridesSchemaError): + define_assembly.decide( + state=None, + command=DefineAssembly( + name="Detector", + presents_as_family_id=uuid4(), + parameter_overrides_schema={"oneOf": [{"type": "object"}]}, + ), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_content_hash_is_deterministic_across_invocations() -> None: + """Same command + context yields the same content_hash (pinning + that the decider does not inject non-deterministic state into + the hash inputs).""" + new_id_a = uuid4() + new_id_b = uuid4() + family_id = uuid4() + slot_family = uuid4() + + def _build(new_id: object) -> str: + events = define_assembly.decide( + state=None, + command=DefineAssembly( + name="Detector", + presents_as_family_id=family_id, + required_slots=frozenset({_slot("camera", slot_family)}), + ), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + new_id=new_id, # type: ignore[arg-type] + ) + return events[0].content_hash + + assert _build(new_id_a) == _build(new_id_b) + + +@pytest.mark.unit +def test_decide_emits_assembly_defined_with_drawing() -> None: + from cora.equipment.aggregates._drawing import Drawing, DrawingSystem + + family_id = uuid4() + drawing = Drawing(system=DrawingSystem.ICMS, number="P4105", revision="A") + events = define_assembly.decide( + state=None, + command=DefineAssembly( + name="Detector", + presents_as_family_id=family_id, + drawing=drawing, + ), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + new_id=uuid4(), + ) + assert events[0].drawing == drawing diff --git a/apps/api/tests/unit/equipment/test_define_assembly_decider_properties.py b/apps/api/tests/unit/equipment/test_define_assembly_decider_properties.py new file mode 100644 index 000000000..688965d5b --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_assembly_decider_properties.py @@ -0,0 +1,162 @@ +"""Property-based tests for `define_assembly.decide` (Equipment BC). + +Mirrors `test_register_mount_decider_properties.py` on a create-style +command with a `context` cross-aggregate kwarg. Universal claims +across generated inputs: + + - state=None + empty missing_family_ids + valid command emits a + single AssemblyDefined with the injected ids / now and an + AssemblyName-trimmed name. + - state=Assembly always raises AssemblyAlreadyExistsError carrying + the pre-existing assembly_id. + - state=None + non-empty missing_family_ids always raises + FamilyNotFoundForAssemblyError carrying the sorted-first missing + family_id for deterministic responses. + - Pure: same (state, command, context, now, new_id) returns the + same events (including the same content_hash). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.assembly import ( + ASSEMBLY_NAME_MAX_LENGTH, + Assembly, + AssemblyAlreadyExistsError, + AssemblyDefined, + AssemblyName, + AssemblyStatus, + FamilyNotFoundForAssemblyError, +) +from cora.equipment.features import define_assembly +from cora.equipment.features.define_assembly import ( + DefineAssembly, + DefineAssemblyContext, +) +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=ASSEMBLY_NAME_MAX_LENGTH) + + +def _assembly(assembly_id: UUID, family_id: UUID) -> Assembly: + return Assembly( + id=assembly_id, + name=AssemblyName("Existing"), + presents_as_family_id=family_id, + status=AssemblyStatus.DEFINED, + ) + + +@pytest.mark.unit +@given( + name=_NAME, + now=aware_datetimes(), +) +def test_decide_genesis_emits_assembly_defined_carrying_injected_fields( + name: str, + now: datetime, +) -> None: + new_id = uuid4() + family_id = uuid4() + events = define_assembly.decide( + state=None, + command=DefineAssembly(name=name, presents_as_family_id=family_id), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=now, + new_id=new_id, + ) + assert len(events) == 1 + event = events[0] + assert isinstance(event, AssemblyDefined) + assert event.assembly_id == new_id + assert event.presents_as_family_id == family_id + assert event.occurred_at == now + assert event.name == AssemblyName(name) + assert len(event.content_hash) == 64 + + +@pytest.mark.unit +@given( + name=_NAME, + now=aware_datetimes(), +) +def test_decide_non_none_state_always_raises_already_exists( + name: str, + now: datetime, +) -> None: + existing_id = uuid4() + family_id = uuid4() + state = _assembly(existing_id, family_id) + with pytest.raises(AssemblyAlreadyExistsError) as exc_info: + define_assembly.decide( + state=state, + command=DefineAssembly(name=name, presents_as_family_id=family_id), + context=DefineAssemblyContext(missing_family_ids=frozenset()), + now=now, + new_id=uuid4(), + ) + assert exc_info.value.assembly_id == existing_id + + +@pytest.mark.unit +@given( + name=_NAME, + missing_count=st.integers(min_value=1, max_value=5), + now=aware_datetimes(), +) +def test_decide_with_missing_families_always_raises_family_not_found( + name: str, + missing_count: int, + now: datetime, +) -> None: + missing = frozenset(uuid4() for _ in range(missing_count)) + with pytest.raises(FamilyNotFoundForAssemblyError) as exc_info: + define_assembly.decide( + state=None, + command=DefineAssembly(name=name, presents_as_family_id=uuid4()), + context=DefineAssemblyContext(missing_family_ids=missing), + now=now, + new_id=uuid4(), + ) + expected_first = sorted(missing, key=str)[0] + assert exc_info.value.family_id == expected_first + + +@pytest.mark.unit +@given( + name=_NAME, + now=aware_datetimes(), +) +def test_decide_is_pure_same_inputs_yield_same_events( + name: str, + now: datetime, +) -> None: + new_id = uuid4() + family_id = uuid4() + command = DefineAssembly(name=name, presents_as_family_id=family_id) + context = DefineAssemblyContext(missing_family_ids=frozenset()) + events_a = define_assembly.decide( + state=None, + command=command, + context=context, + now=now, + new_id=new_id, + ) + events_b = define_assembly.decide( + state=None, + command=command, + context=context, + now=now, + new_id=new_id, + ) + assert events_a == events_b + assert events_a[0].content_hash == events_b[0].content_hash From 2f5dc4a5d9c7cdc8b00e428a207806dd1a7ebc73 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 22:30:44 +0300 Subject: [PATCH 4/5] feat(equipment): version_assembly slice (Assembly Sub-Stage B.2) Multi-source FSM (Defined | Versioned -> Versioned), replace-on- version semantic carrying the FULL canonical structural subset (slots, wires, schema, drawing, version label, presents_as_family_id). Re-attestation with identical content is allowed and emits a fresh event, mirroring version_family's deliberate divergence from strict- not-idempotent. Decider invariants: - State must NOT be None -> AssemblyNotFoundError. - state.status in {Defined, Versioned} -> AssemblyCannotVersionError when Deprecated (terminal; fork via define_assembly). - context.missing_family_ids empty -> FamilyNotFoundForAssemblyError (sorted-first missing id, deterministic). - command.name valid -> InvalidAssemblyNameError via VO. - Every wire endpoint references a declared slot -> WireReferencesUnknownSlotError (defense-in-depth above the Assembly.__post_init__ closure). - parameter_overrides_schema valid -> InvalidParameterOverridesSchemaError. Handler is longhand because it loads N cross-aggregate Family streams. Single event-store load for the Assembly stream (folds state and captures current_version in one call); concurrent Family loads via asyncio.gather; single-stream append at the captured expected_version (NOT 0). AssemblySummaryProjection gains the AssemblyVersioned UPDATE arm: status=Versioned, replaces name + presents_as_family_id + version + content_hash wholesale to mirror the aggregate's replace-on-version semantic. Tests: 10 decider unit tests, 5 PBT properties, 10 REST contract tests, 4 MCP contract tests, 1 Postgres integration test. All 14,472 architecture-suite tests pass. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 176 +++++++++++ .../features/version_assembly/__init__.py | 18 ++ .../features/version_assembly/command.py | 44 +++ .../features/version_assembly/context.py | 22 ++ .../features/version_assembly/decider.py | 117 ++++++++ .../features/version_assembly/handler.py | 163 +++++++++++ .../features/version_assembly/route.py | 163 +++++++++++ .../features/version_assembly/tool.py | 96 ++++++ .../equipment/projections/assembly_summary.py | 52 +++- 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_no_assembly_asset_level_literal.py | 12 + .../test_assembly_versions_endpoint.py | 226 +++++++++++++++ .../test_version_assembly_mcp_tool.py | 167 +++++++++++ .../test_version_assembly_handler_postgres.py | 86 ++++++ .../test_assembly_summary_projection.py | 48 ++- .../test_version_assembly_decider.py | 273 ++++++++++++++++++ ...est_version_assembly_decider_properties.py | 185 ++++++++++++ 19 files changed, 1845 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/cora/equipment/features/version_assembly/__init__.py create mode 100644 apps/api/src/cora/equipment/features/version_assembly/command.py create mode 100644 apps/api/src/cora/equipment/features/version_assembly/context.py create mode 100644 apps/api/src/cora/equipment/features/version_assembly/decider.py create mode 100644 apps/api/src/cora/equipment/features/version_assembly/handler.py create mode 100644 apps/api/src/cora/equipment/features/version_assembly/route.py create mode 100644 apps/api/src/cora/equipment/features/version_assembly/tool.py create mode 100644 apps/api/tests/contract/test_assembly_versions_endpoint.py create mode 100644 apps/api/tests/contract/test_version_assembly_mcp_tool.py create mode 100644 apps/api/tests/integration/test_version_assembly_handler_postgres.py create mode 100644 apps/api/tests/unit/equipment/test_version_assembly_decider.py create mode 100644 apps/api/tests/unit/equipment/test_version_assembly_decider_properties.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ba3988820..84dee5059 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -11179,6 +11179,82 @@ "title": "ValidationError", "type": "object" }, + "VersionAssemblyRequest": { + "description": "Body for `POST /assemblies/{assembly_id}/versions`.", + "properties": { + "drawing": { + "anyOf": [ + { + "$ref": "#/components/schemas/DrawingBody" + }, + { + "type": "null" + } + ], + "description": "Optional engineering reference for this revision. Excluded from the content_hash." + }, + "name": { + "description": "Display name for this revision snapshot. May change across revisions; non-unique.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "parameter_overrides_schema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Optional JSON Schema subset declaring the shape of parameter_overrides accepted at instantiation. Excluded if the revision keeps the previous schema unchanged.", + "title": "Parameter Overrides Schema" + }, + "presents_as_family_id": { + "description": "FamilyId the instantiated Assembly stands in for. May change across revisions (e.g., DCM-revisited may move from Monochromator to a wider BeamConditioning Family).", + "format": "uuid", + "title": "Presents As Family Id", + "type": "string" + }, + "required_slots": { + "description": "Full slot set for this revision (replace-on-version). Wire shape is a list; duplicates collapse when the route handler converts to the domain frozenset.", + "items": { + "$ref": "#/components/schemas/TemplateSlotBody" + }, + "title": "Required Slots", + "type": "array" + }, + "required_wires": { + "description": "Full wire set for this revision (replace-on-version). Endpoints must reference slots declared in required_slots.", + "items": { + "$ref": "#/components/schemas/TemplateWireBody" + }, + "title": "Required Wires", + "type": "array" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Operator-curated free-form version label (no SemVer enforcement). Excluded from the content_hash.", + "title": "Version" + } + }, + "required": [ + "name", + "presents_as_family_id" + ], + "title": "VersionAssemblyRequest", + "type": "object" + }, "VersionCapabilityRequest": { "description": "Body for `POST /capabilities/{capability_id}/version`.", "properties": { @@ -13753,6 +13829,106 @@ ] } }, + "/assemblies/{assembly_id}/versions": { + "post": { + "operationId": "post_assembly_version_assemblies__assembly_id__versions_post", + "parameters": [ + { + "description": "The target Assembly's UUID.", + "in": "path", + "name": "assembly_id", + "required": true, + "schema": { + "description": "The target Assembly's UUID.", + "format": "uuid", + "title": "Assembly 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/VersionAssemblyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated: whitespace-only name or slot_name, non-enum cardinality, malformed wire spec, wire references an unknown slot, or invalid parameter_overrides_schema subset." + }, + "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": "Assembly with the given assembly_id does not exist, OR a referenced FamilyId (presents_as_family_id or any slot's required_families member) does not resolve to a defined Family." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Assembly is in a status that cannot be versioned (Deprecated is terminal; new revisions must fork via POST /assemblies with a fresh id)." + }, + "422": { + "description": "Request body failed schema validation." + } + }, + "summary": "Publish a new revision snapshot of an existing Assembly", + "tags": [ + "equipment" + ] + } + }, "/assets": { "get": { "operationId": "list_assets_assets_get", diff --git a/apps/api/src/cora/equipment/features/version_assembly/__init__.py b/apps/api/src/cora/equipment/features/version_assembly/__init__.py new file mode 100644 index 000000000..3c95a19c1 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_assembly/__init__.py @@ -0,0 +1,18 @@ +"""Vertical slice for the `VersionAssembly` command.""" + +from cora.equipment.features.version_assembly import tool +from cora.equipment.features.version_assembly.command import VersionAssembly +from cora.equipment.features.version_assembly.context import VersionAssemblyContext +from cora.equipment.features.version_assembly.decider import decide +from cora.equipment.features.version_assembly.handler import Handler, bind +from cora.equipment.features.version_assembly.route import router + +__all__ = [ + "Handler", + "VersionAssembly", + "VersionAssemblyContext", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/version_assembly/command.py b/apps/api/src/cora/equipment/features/version_assembly/command.py new file mode 100644 index 000000000..30db26a2d --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_assembly/command.py @@ -0,0 +1,44 @@ +"""The `VersionAssembly` command - intent dataclass for the version_assembly slice. + +Replace-on-version semantics per the design memo: the command +carries the FULL canonical structural subset (the same fields +DefineAssembly carries, plus the target assembly_id), NOT a diff. +The decider replaces structure wholesale; the evolver folds the +new snapshot into state. + +`presents_as_family_id` is mutable across versions because a +re-architected Assembly may stand in for a different Family +(e.g., DCM-revisited may move from `Monochromator` to a wider +`BeamConditioning` Family). The handler re-checks Family existence +for every referenced FamilyId (presents_as_family_id + every slot's +required_families). + +Multi-source FSM transition: Defined -> Versioned AND +Versioned -> Versioned are both valid; only Deprecated rejects. + +Re-attestation: the same structural content (yielding the same +content_hash) emits a fresh AssemblyVersioned event. Re-attesting +is a legitimate audit moment (`operator confirmed v2 again on date +X`); the decider does not refuse it. +""" + +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + +from cora.equipment.aggregates._drawing import Drawing +from cora.equipment.aggregates.assembly import TemplateSlot, TemplateWire + + +@dataclass(frozen=True) +class VersionAssembly: + """Publish a new revision snapshot of an existing Assembly.""" + + assembly_id: UUID + name: str + presents_as_family_id: UUID + required_slots: frozenset[TemplateSlot] = field(default_factory=frozenset[TemplateSlot]) + required_wires: frozenset[TemplateWire] = field(default_factory=frozenset[TemplateWire]) + parameter_overrides_schema: dict[str, Any] | None = None + drawing: Drawing | None = None + version: str | None = None diff --git a/apps/api/src/cora/equipment/features/version_assembly/context.py b/apps/api/src/cora/equipment/features/version_assembly/context.py new file mode 100644 index 000000000..81c5b0496 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_assembly/context.py @@ -0,0 +1,22 @@ +"""Context snapshot loaded by the version_assembly handler. + +Same shape as DefineAssemblyContext: the handler loads each +referenced FamilyId via `load_family` before calling the decider; +the context VO carries the set of FamilyIds that did NOT resolve +to a defined Family. + +`missing_family_ids` empty means all referenced Families exist. +When non-empty, the decider raises FamilyNotFoundForAssemblyError +carrying the sorted-first missing id so error responses are stable +across runs. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class VersionAssemblyContext: + """Snapshot of FamilyId existence checks for version_assembly.""" + + missing_family_ids: frozenset[UUID] diff --git a/apps/api/src/cora/equipment/features/version_assembly/decider.py b/apps/api/src/cora/equipment/features/version_assembly/decider.py new file mode 100644 index 000000000..4ead18753 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_assembly/decider.py @@ -0,0 +1,117 @@ +"""Pure decider for the `VersionAssembly` command. + +Multi-source-state transition: `Defined | Versioned -> Versioned`. +Both Defined (first revision) and Versioned (subsequent revisions) +are valid sources; only Deprecated is rejected. Same precedent as +version_method / version_family. + +Re-attestation: same structural content produces the same +content_hash but emits a fresh AssemblyVersioned event so the +re-confirmation is captured in the audit log. Pinned by +`test_decide_allows_re_attestation_with_same_content`. +""" + +from datetime import datetime + +from cora.equipment.aggregates._assembly_content_hash import compute_assembly_content_hash +from cora.equipment.aggregates.assembly import ( + Assembly, + AssemblyCannotVersionError, + AssemblyName, + AssemblyNotFoundError, + AssemblyStatus, + AssemblyVersioned, + FamilyNotFoundForAssemblyError, + InvalidParameterOverridesSchemaError, + WireReferencesUnknownSlotError, +) +from cora.equipment.features.version_assembly.command import VersionAssembly +from cora.equipment.features.version_assembly.context import VersionAssemblyContext +from cora.infrastructure.json_schema_validation import validate_schema_declaration + +_VERSIONABLE_STATUSES: tuple[AssemblyStatus, ...] = ( + AssemblyStatus.DEFINED, + AssemblyStatus.VERSIONED, +) + + +def decide( + state: Assembly | None, + command: VersionAssembly, + *, + context: VersionAssemblyContext, + now: datetime, +) -> list[AssemblyVersioned]: + """Decide the events produced by versioning an existing Assembly. + + Invariants: + - State must not be None -> AssemblyNotFoundError carrying the + target assembly_id. + - state.status must be in {Defined, Versioned} + -> AssemblyCannotVersionError carrying the current status. + Deprecated is terminal; new revisions must fork via + define_assembly with a new id. + - context.missing_family_ids must be empty + -> FamilyNotFoundForAssemblyError carrying the sorted-first + missing FamilyId for deterministic responses. + - command.name must be valid -> InvalidAssemblyNameError + (via AssemblyName VO). + - Every wire endpoint must reference a slot in required_slots + -> WireReferencesUnknownSlotError carrying the offending + slot_name (defense-in-depth above the evolver-fold check). + - command.parameter_overrides_schema, when non-None, must be a + well-formed JSON Schema in CORA's constrained subset + -> InvalidParameterOverridesSchemaError. + """ + if state is None: + raise AssemblyNotFoundError(command.assembly_id) + if state.status not in _VERSIONABLE_STATUSES: + raise AssemblyCannotVersionError( + state.id, + f"current status is {state.status.value}", + ) + if context.missing_family_ids: + first_missing = next(iter(sorted(context.missing_family_ids, key=str))) + raise FamilyNotFoundForAssemblyError(first_missing) + + name = AssemblyName(command.name) + + # Defense-in-depth: closure-check the wires here so a bad command + # surfaces at the API boundary as a 400 rather than as a load-time + # evolver fault. Same check lives on `Assembly.__post_init__`. + slot_names = {slot.slot_name.value for slot in command.required_slots} + for wire in command.required_wires: + if wire.source_slot_name not in slot_names: + raise WireReferencesUnknownSlotError(wire.source_slot_name) + if wire.target_slot_name not in slot_names: + raise WireReferencesUnknownSlotError(wire.target_slot_name) + + if command.parameter_overrides_schema is not None: + validate_schema_declaration( + command.parameter_overrides_schema, + error_class=InvalidParameterOverridesSchemaError, + ) + + content_hash = compute_assembly_content_hash( + name=name, + presents_as_family_id=command.presents_as_family_id, + required_slots=command.required_slots, + required_wires=command.required_wires, + parameter_overrides_schema=command.parameter_overrides_schema, + ) + + return [ + AssemblyVersioned( + assembly_id=state.id, + name=name, + presents_as_family_id=command.presents_as_family_id, + required_slots=command.required_slots, + required_wires=command.required_wires, + parameter_overrides_schema=command.parameter_overrides_schema, + drawing=command.drawing, + version=command.version, + content_hash=content_hash, + previous_content_hash=state.content_hash, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/version_assembly/handler.py b/apps/api/src/cora/equipment/features/version_assembly/handler.py new file mode 100644 index 000000000..9da291a97 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_assembly/handler.py @@ -0,0 +1,163 @@ +"""Application handler for the `version_assembly` slice. + +Longhand update-style handler (cannot use a factory because it +loads N cross-aggregate Family streams BEFORE calling the decider, +same shape as define_assembly): + + 1. Authz check (Deny -> UnauthorizedError). + 2. Load the Assembly stream once via `event_store.load`, fold to + state, and reuse the same call's `current_version` for the + optimistic-concurrency append (matches decommission_mount's + single-load shape). + 3. Load Family aggregate for `presents_as_family_id` + every + FamilyId across the slot set's required_families (concurrent + via asyncio.gather, de-duplicated via frozenset). Build + context with missing_family_ids. + 4. Call pure decider with state + context + command + now. + 5. Wrap emitted events and append to the Assembly stream at the + captured version. +""" + +import asyncio +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.assembly import ( + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.aggregates.family import load_family +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.version_assembly.command import VersionAssembly +from cora.equipment.features.version_assembly.context import VersionAssemblyContext +from cora.equipment.features.version_assembly.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 = "Assembly" +_COMMAND_NAME = "VersionAssembly" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare version_assembly handler - what `bind()` returns.""" + + async def __call__( + self, + command: VersionAssembly, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def _referenced_family_ids(command: VersionAssembly) -> frozenset[UUID]: + """Collect every FamilyId the new version references. + + Union of `presents_as_family_id` and every slot's required_families. + Returned as a frozenset so handler loads are de-duplicated when + multiple slots share a Family. + """ + ids: set[UUID] = {command.presents_as_family_id} + for slot in command.required_slots: + ids.update(slot.required_families) + return frozenset(ids) + + +def bind(deps: Kernel) -> Handler: + """Build a version_assembly handler closed over the shared deps.""" + + async def handler( + command: VersionAssembly, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "version_assembly.start", + command_name=_COMMAND_NAME, + assembly_id=str(command.assembly_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( + "version_assembly.denied", + command_name=_COMMAND_NAME, + assembly_id=str(command.assembly_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load(_STREAM_TYPE, command.assembly_id) + state = fold([from_stored(s) for s in stored]) + + family_ids = _referenced_family_ids(command) + family_id_tuple = tuple(family_ids) + loaded = await asyncio.gather( + *(load_family(deps.event_store, fid) for fid in family_id_tuple) + ) + missing = frozenset( + fid for fid, family in zip(family_id_tuple, loaded, strict=True) if family is None + ) + context = VersionAssemblyContext(missing_family_ids=missing) + + domain_events = decide( + state=state, + command=command, + context=context, + 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.assembly_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "version_assembly.success", + command_name=_COMMAND_NAME, + assembly_id=str(command.assembly_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + event_count=len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/version_assembly/route.py b/apps/api/src/cora/equipment/features/version_assembly/route.py new file mode 100644 index 000000000..bf5d41463 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_assembly/route.py @@ -0,0 +1,163 @@ +"""HTTP route for the `version_assembly` slice. + +POST /assemblies/{assembly_id}/versions: publish a new revision +snapshot of an existing Assembly. + +Replace-on-version per the design memo: the body carries the FULL +canonical structural subset (slots, wires, schema, drawing, version +label, presents_as_family_id), NOT a diff. The route reuses the +shared TemplateSlotBody / TemplateWireBody / DrawingBody wire +shapes and the same list-to-frozenset conversion as define_assembly. +""" + +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment._drawing_body import DrawingBody +from cora.equipment._template_slot_body import TemplateSlotBody +from cora.equipment._template_wire_body import TemplateWireBody +from cora.equipment.aggregates.assembly import ASSEMBLY_NAME_MAX_LENGTH +from cora.equipment.features.version_assembly.command import VersionAssembly +from cora.equipment.features.version_assembly.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class VersionAssemblyRequest(BaseModel): + """Body for `POST /assemblies/{assembly_id}/versions`.""" + + name: str = Field( + ..., + min_length=1, + max_length=ASSEMBLY_NAME_MAX_LENGTH, + description=( + "Display name for this revision snapshot. May change across revisions; non-unique." + ), + ) + presents_as_family_id: UUID = Field( + ..., + description=( + "FamilyId the instantiated Assembly stands in for. May " + "change across revisions (e.g., DCM-revisited may move " + "from Monochromator to a wider BeamConditioning Family)." + ), + ) + required_slots: list[TemplateSlotBody] = Field( + default_factory=list[TemplateSlotBody], + description=( + "Full slot set for this revision (replace-on-version). " + "Wire shape is a list; duplicates collapse when the " + "route handler converts to the domain frozenset." + ), + ) + required_wires: list[TemplateWireBody] = Field( + default_factory=list[TemplateWireBody], + description=( + "Full wire set for this revision (replace-on-version). " + "Endpoints must reference slots declared in required_slots." + ), + ) + parameter_overrides_schema: dict[str, Any] | None = Field( + None, + description=( + "Optional JSON Schema subset declaring the shape of " + "parameter_overrides accepted at instantiation. Excluded " + "if the revision keeps the previous schema unchanged." + ), + ) + drawing: DrawingBody | None = Field( + None, + description=( + "Optional engineering reference for this revision. Excluded from the content_hash." + ), + ) + version: str | None = Field( + None, + description=( + "Operator-curated free-form version label (no SemVer " + "enforcement). Excluded from the content_hash." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.version_assembly + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/assemblies/{assembly_id}/versions", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated: whitespace-only name or " + "slot_name, non-enum cardinality, malformed wire spec, " + "wire references an unknown slot, or invalid " + "parameter_overrides_schema subset." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": ( + "Assembly with the given assembly_id does not exist, " + "OR a referenced FamilyId (presents_as_family_id or " + "any slot's required_families member) does not resolve " + "to a defined Family." + ), + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Assembly is in a status that cannot be versioned " + "(Deprecated is terminal; new revisions must fork " + "via POST /assemblies with a fresh id)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Request body failed schema validation.", + }, + }, + summary="Publish a new revision snapshot of an existing Assembly", +) +async def post_assembly_version( + assembly_id: Annotated[ + UUID, + Path(description="The target Assembly's UUID."), + ], + body: VersionAssemblyRequest, + 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( + VersionAssembly( + assembly_id=assembly_id, + name=body.name, + presents_as_family_id=body.presents_as_family_id, + required_slots=frozenset(slot.to_domain() for slot in body.required_slots), + required_wires=frozenset(wire.to_domain() for wire in body.required_wires), + parameter_overrides_schema=body.parameter_overrides_schema, + drawing=body.drawing.to_domain() if body.drawing is not None else None, + version=body.version, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/version_assembly/tool.py b/apps/api/src/cora/equipment/features/version_assembly/tool.py new file mode 100644 index 000000000..c82361f93 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_assembly/tool.py @@ -0,0 +1,96 @@ +"""MCP tool for the `version_assembly` 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._drawing_body import DrawingBody +from cora.equipment._template_slot_body import TemplateSlotBody +from cora.equipment._template_wire_body import TemplateWireBody +from cora.equipment.aggregates.assembly import ASSEMBLY_NAME_MAX_LENGTH +from cora.equipment.features.version_assembly.command import VersionAssembly +from cora.equipment.features.version_assembly.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 VersionAssemblyOutput(BaseModel): + """Empty output - the version succeeded if no isError surfaces.""" + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + @mcp.tool( + name="version_assembly", + description=( + "Publish a new revision snapshot of an existing Assembly. " + "Replace-on-version: the args carry the FULL canonical " + "structural subset (slots, wires, schema, drawing, version " + "label, presents_as_family_id), NOT a diff. Multi-source " + "FSM: Defined and Versioned both accept new revisions; " + "Deprecated rejects (operators must fork via define_assembly " + "with a fresh id). Re-attesting the same content produces " + "a fresh event with the same content_hash." + ), + ) + async def version_assembly_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + assembly_id: Annotated[ + UUID, + Field(description="The target Assembly's UUID."), + ], + name: Annotated[ + str, + Field( + min_length=1, + max_length=ASSEMBLY_NAME_MAX_LENGTH, + description="Display name for this revision.", + ), + ], + presents_as_family_id: Annotated[ + UUID, + Field(description="FamilyId the instantiated Assembly stands in for."), + ], + required_slots: Annotated[ + list[TemplateSlotBody], + Field(description="Full slot set for this revision."), + ] = [], # noqa: B006 + required_wires: Annotated[ + list[TemplateWireBody], + Field(description="Full wire set for this revision."), + ] = [], # noqa: B006 + parameter_overrides_schema: Annotated[ + dict[str, Any] | None, + Field( + description="Optional JSON Schema subset for parameter_overrides.", + ), + ] = None, + drawing: Annotated[ + DrawingBody | None, + Field(description="Optional engineering reference for this revision."), + ] = None, + version: Annotated[ + str | None, + Field(description="Operator-curated free-form version label."), + ] = None, + ) -> VersionAssemblyOutput: + handler = get_handler() + await handler( + VersionAssembly( + assembly_id=assembly_id, + name=name, + presents_as_family_id=presents_as_family_id, + required_slots=frozenset(s.to_domain() for s in required_slots), + required_wires=frozenset(w.to_domain() for w in required_wires), + parameter_overrides_schema=parameter_overrides_schema, + drawing=drawing.to_domain() if drawing is not None else None, + version=version, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return VersionAssemblyOutput() diff --git a/apps/api/src/cora/equipment/projections/assembly_summary.py b/apps/api/src/cora/equipment/projections/assembly_summary.py index c5126a08b..5a43f6709 100644 --- a/apps/api/src/cora/equipment/projections/assembly_summary.py +++ b/apps/api/src/cora/equipment/projections/assembly_summary.py @@ -1,16 +1,20 @@ """AssemblySummaryProjection: folds the Assembly aggregate's lifecycle events into the `proj_equipment_assembly_summary` read model. -v1 ships ONLY the `AssemblyDefined` arm. The `AssemblyVersioned` -and `AssemblyDeprecated` arms land with their respective slices -to keep the slice-per-commit gate-review discipline intact (no -projector arms without a matching emitter slice). - -Subscribed events (v1, scaffold): - - AssemblyDefined -> INSERT (status=Defined, version=NULL on - payload absence; content_hash from payload) - -All branches idempotent (INSERT uses ON CONFLICT DO NOTHING). +Subscribed events (per slice): + - AssemblyDefined -> INSERT (status=Defined, version + content_hash + from payload). Shipped with B.0 + scaffold. + - AssemblyVersioned -> UPDATE status=Versioned + name + + presents_as_family_id + version + + content_hash. Replace-on-version + semantic mirrors the aggregate state. + Shipped with version_assembly slice. + - AssemblyDeprecated -> UPDATE status=Deprecated (added with + deprecate_assembly slice). + +All branches idempotent (INSERT uses ON CONFLICT DO NOTHING; UPDATEs +write fixed values per event type so re-application is a no-op). Mirrors FamilySummaryProjection's shape. """ @@ -35,12 +39,28 @@ def _id(payload: dict[str, object]) -> UUID: ON CONFLICT (assembly_id) DO NOTHING """ +_UPDATE_VERSIONED_SQL = """ +UPDATE proj_equipment_assembly_summary +SET status = 'Versioned', + name = $2, + presents_as_family_id = $3, + version = $4, + content_hash = $5, + updated_at = now() +WHERE assembly_id = $1 +""" + class AssemblySummaryProjection: """Maintains the `proj_equipment_assembly_summary` read model.""" name = "proj_equipment_assembly_summary" - subscribed_event_types = frozenset({"AssemblyDefined"}) + subscribed_event_types = frozenset( + { + "AssemblyDefined", + "AssemblyVersioned", + } + ) async def apply( self, @@ -59,6 +79,16 @@ async def apply( payload["content_hash"], datetime.fromisoformat(str(payload["occurred_at"])), ) + case "AssemblyVersioned": + payload = event.payload + await conn.execute( + _UPDATE_VERSIONED_SQL, + _id(payload), + payload["name"], + UUID(str(payload["presents_as_family_id"])), + payload.get("version"), + payload["content_hash"], + ) case _: pass diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index cfe6ab1a5..a5e80de42 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -171,6 +171,7 @@ update_family_settings_schema, update_frame_placement, update_mount_placement, + version_assembly, version_family, version_model, ) @@ -285,6 +286,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(install_asset.router) app.include_router(uninstall_asset.router) app.include_router(define_assembly.router) + app.include_router(version_assembly.router) for validation_cls in ( InvalidAffordanceError, InvalidFamilyNameError, diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index ff84eb985..4bf73aa90 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -67,6 +67,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_assembly import tool as version_assembly_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 @@ -243,3 +244,7 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().define_assembly, ) + version_assembly_tool.register( + mcp, + get_handler=lambda: get_handlers().version_assembly, + ) diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 91975238f..ae2f8354a 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -70,6 +70,7 @@ update_family_settings_schema, update_frame_placement, update_mount_placement, + version_assembly, version_family, version_model, ) @@ -151,6 +152,7 @@ class EquipmentHandlers: install_asset: install_asset.Handler uninstall_asset: uninstall_asset.Handler define_assembly: define_assembly.IdempotentHandler + version_assembly: version_assembly.Handler def wire_equipment(deps: Kernel) -> EquipmentHandlers: @@ -411,4 +413,9 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DefineAssembly", bc=_BC, ), + version_assembly=with_tracing( + version_assembly.bind(deps), + command_name="VersionAssembly", + bc=_BC, + ), ) diff --git a/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py index 989592570..0a9b1c944 100644 --- a/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py +++ b/apps/api/tests/architecture/test_no_assembly_asset_level_literal.py @@ -65,11 +65,23 @@ "apps/api/src/cora/equipment/features/define_assembly/handler.py", "apps/api/src/cora/equipment/features/define_assembly/route.py", "apps/api/src/cora/equipment/features/define_assembly/tool.py", + "apps/api/src/cora/equipment/features/version_assembly/__init__.py", + "apps/api/src/cora/equipment/features/version_assembly/command.py", + "apps/api/src/cora/equipment/features/version_assembly/context.py", + "apps/api/src/cora/equipment/features/version_assembly/decider.py", + "apps/api/src/cora/equipment/features/version_assembly/handler.py", + "apps/api/src/cora/equipment/features/version_assembly/route.py", + "apps/api/src/cora/equipment/features/version_assembly/tool.py", "apps/api/src/cora/equipment/projections/assembly_summary.py", "apps/api/tests/contract/test_assemblies_endpoint.py", + "apps/api/tests/contract/test_assembly_versions_endpoint.py", "apps/api/tests/contract/test_define_assembly_mcp_tool.py", + "apps/api/tests/contract/test_version_assembly_mcp_tool.py", "apps/api/tests/integration/test_define_assembly_handler_postgres.py", + "apps/api/tests/integration/test_version_assembly_handler_postgres.py", "apps/api/tests/unit/equipment/test_define_assembly_decider_properties.py", + "apps/api/tests/unit/equipment/test_version_assembly_decider.py", + "apps/api/tests/unit/equipment/test_version_assembly_decider_properties.py", "apps/api/tests/unit/equipment/test_assembly_content_hash.py", "apps/api/tests/unit/equipment/test_assembly_events.py", "apps/api/tests/unit/equipment/test_assembly_evolver.py", diff --git a/apps/api/tests/contract/test_assembly_versions_endpoint.py b/apps/api/tests/contract/test_assembly_versions_endpoint.py new file mode 100644 index 000000000..4c77a5af2 --- /dev/null +++ b/apps/api/tests/contract/test_assembly_versions_endpoint.py @@ -0,0 +1,226 @@ +"""Contract tests for `POST /assemblies/{assembly_id}/versions`. + +Covers replace-on-version semantics, multi-source FSM (Defined and +Versioned both accept revisions), Deprecated rejection (deferred to +the deprecate_assembly slice landing; until then no Deprecated state +is reachable), AssemblyNotFound 404, and FamilyNotFound 404 on a +re-pointed presents_as_family_id. +""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + + +def _define_family(client: TestClient, name: str = "Detector") -> UUID: + response = client.post( + "/families", + json={"name": name, "affordances": []}, + ) + assert response.status_code == 201, response.text + return UUID(response.json()["family_id"]) + + +def _define_assembly( + client: TestClient, + family_id: UUID, + *, + name: str = "MCTOptics", +) -> UUID: + response = client.post( + "/assemblies", + json={ + "name": name, + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + ) + assert response.status_code == 201, response.text + return UUID(response.json()["assembly_id"]) + + +@pytest.mark.contract +def test_post_assembly_version_returns_204_for_minimal_revision() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + response = client.post( + f"/assemblies/{assembly_id}/versions", + json={ + "name": "MCTOptics-rev2", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + ) + assert response.status_code == 204, response.text + + +@pytest.mark.contract +def test_post_assembly_version_allows_multiple_revisions_on_same_stream() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + for tag in ("v1", "v2", "v3"): + response = client.post( + f"/assemblies/{assembly_id}/versions", + json={ + "name": "MCTOptics", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + "version": tag, + }, + ) + assert response.status_code == 204, response.text + + +@pytest.mark.contract +def test_post_assembly_version_allows_re_attestation_with_identical_body() -> None: + """Re-attestation: posting the same body twice succeeds. Each call + emits a fresh event capturing the audit moment.""" + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + body: dict[str, object] = { + "name": "MCTOptics", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + "version": "v1.0.0", + } + first = client.post(f"/assemblies/{assembly_id}/versions", json=body) + assert first.status_code == 204, first.text + second = client.post(f"/assemblies/{assembly_id}/versions", json=body) + assert second.status_code == 204, second.text + + +@pytest.mark.contract +def test_post_assembly_version_returns_404_for_unknown_assembly() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + response = client.post( + f"/assemblies/{uuid4()}/versions", + json={ + "name": "X", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + ) + assert response.status_code == 404, response.text + + +@pytest.mark.contract +def test_post_assembly_version_returns_404_for_unknown_presents_as_family_id() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + response = client.post( + f"/assemblies/{assembly_id}/versions", + json={ + "name": "X", + "presents_as_family_id": str(uuid4()), + "required_slots": [], + "required_wires": [], + }, + ) + assert response.status_code == 404, response.text + + +@pytest.mark.contract +def test_post_assembly_version_returns_400_when_wire_references_unknown_slot() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + camera_family = _define_family(client, "Camera") + response = client.post( + f"/assemblies/{assembly_id}/versions", + json={ + "name": "X", + "presents_as_family_id": str(family_id), + "required_slots": [ + { + "slot_name": "camera", + "required_families": [str(camera_family)], + "cardinality": "Exactly1", + } + ], + "required_wires": [ + { + "source_slot_name": "missing_slot", + "source_port_name": "out", + "target_slot_name": "camera", + "target_port_name": "in", + } + ], + }, + ) + assert response.status_code == 400, response.text + assert "missing_slot" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_assembly_version_returns_400_for_invalid_parameter_overrides_schema() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + response = client.post( + f"/assemblies/{assembly_id}/versions", + json={ + "name": "X", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + "parameter_overrides_schema": {"oneOf": [{"type": "object"}]}, + }, + ) + assert response.status_code == 400, response.text + + +@pytest.mark.contract +def test_post_assembly_version_returns_422_for_missing_name() -> None: + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + response = client.post( + f"/assemblies/{assembly_id}/versions", + json={"presents_as_family_id": str(family_id)}, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assembly_version_replaces_structural_fields() -> None: + """Replace-on-version: new slot set wholesale replaces the old.""" + with TestClient(create_app()) as client: + family_id = _define_family(client) + assembly_id = _define_assembly(client, family_id) + camera_family = _define_family(client, "Camera") + scintillator_family = _define_family(client, "Scintillator") + response = client.post( + f"/assemblies/{assembly_id}/versions", + json={ + "name": "Detector", + "presents_as_family_id": str(family_id), + "required_slots": [ + { + "slot_name": "camera", + "required_families": [str(camera_family)], + "cardinality": "Exactly1", + }, + { + "slot_name": "scintillator", + "required_families": [str(scintillator_family)], + "cardinality": "Exactly1", + }, + ], + "required_wires": [], + "version": "v0.2.0", + }, + ) + assert response.status_code == 204, response.text diff --git a/apps/api/tests/contract/test_version_assembly_mcp_tool.py b/apps/api/tests/contract/test_version_assembly_mcp_tool.py new file mode 100644 index 000000000..7d305becf --- /dev/null +++ b/apps/api/tests/contract/test_version_assembly_mcp_tool.py @@ -0,0 +1,167 @@ +"""Contract tests for the `version_assembly` 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 + + +def _define_family_via_tool( + client: TestClient, + headers: dict[str, str], + name: str = "Detector", +) -> UUID: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "define_family", + "arguments": {"name": name, "affordances": []}, + }, + }, + headers=headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + return UUID(body["result"]["structuredContent"]["family_id"]) + + +def _define_assembly_via_tool( + client: TestClient, + headers: dict[str, str], + family_id: UUID, + *, + name: str = "MCTOptics", +) -> UUID: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "define_assembly", + "arguments": { + "name": name, + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + }, + }, + headers=headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + return UUID(body["result"]["structuredContent"]["assembly_id"]) + + +@pytest.mark.contract +def test_mcp_lists_version_assembly_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 "version_assembly" in tool_names + + +@pytest.mark.contract +def test_mcp_version_assembly_tool_succeeds_for_defined_assembly() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + family_id = _define_family_via_tool(client, headers) + assembly_id = _define_assembly_via_tool(client, headers, family_id) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "version_assembly", + "arguments": { + "assembly_id": str(assembly_id), + "name": "MCTOptics", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + "version": "v0.2.0", + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is False + + +@pytest.mark.contract +def test_mcp_version_assembly_tool_returns_iserror_for_unknown_assembly() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + family_id = _define_family_via_tool(client, headers) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "version_assembly", + "arguments": { + "assembly_id": str(uuid4()), + "name": "X", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + }, + }, + }, + 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_version_assembly_tool_succeeds_on_versioned_state() -> None: + """Multi-source FSM: Versioned -> Versioned is accepted.""" + with TestClient(create_app()) as client: + headers = open_session(client) + family_id = _define_family_via_tool(client, headers) + assembly_id = _define_assembly_via_tool(client, headers, family_id) + for tag in ("v1", "v2"): + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "version_assembly", + "arguments": { + "assembly_id": str(assembly_id), + "name": "MCTOptics", + "presents_as_family_id": str(family_id), + "required_slots": [], + "required_wires": [], + "version": tag, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is False diff --git a/apps/api/tests/integration/test_version_assembly_handler_postgres.py b/apps/api/tests/integration/test_version_assembly_handler_postgres.py new file mode 100644 index 000000000..02c7fc24d --- /dev/null +++ b/apps/api/tests/integration/test_version_assembly_handler_postgres.py @@ -0,0 +1,86 @@ +"""End-to-end integration test: version_assembly handler against Postgres. + +The Assembly stream accumulates one AssemblyDefined event followed +by one AssemblyVersioned event. The handler verifies the Family +referenced by `presents_as_family_id` still resolves, then appends +at the captured optimistic-concurrency version. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment.features import define_assembly, define_family, version_assembly +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.version_assembly import VersionAssembly +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 14, 0, 0, tzinfo=UTC) +_FAMILY_ID = UUID("01900000-0000-7000-8000-00000054cd01") +_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cd0e") +_ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054cd02") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cd1e") +_VERSIONED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cd2e") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000cc") + + +@pytest.mark.integration +async def test_version_assembly_appends_versioned_event_to_postgres( + db_pool: asyncpg.Pool, +) -> None: + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + _FAMILY_ID, + _FAMILY_EVENT_ID, + _ASSEMBLY_ID, + _DEFINED_EVENT_ID, + _VERSIONED_EVENT_ID, + ], + ) + + family_id = await define_family.bind(deps)( + DefineFamily(name="Detector", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assembly_id = await define_assembly.bind(deps)( + DefineAssembly(name="MCTOptics", presents_as_family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await version_assembly.bind(deps)( + VersionAssembly( + assembly_id=assembly_id, + name="MCTOptics-rev2", + presents_as_family_id=family_id, + version="v0.2.0", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Assembly", assembly_id) + assert version == 2 + assert len(events) == 2 + defined_event = events[0] + versioned_event = events[1] + assert defined_event.event_type == "AssemblyDefined" + assert versioned_event.event_type == "AssemblyVersioned" + assert versioned_event.event_id == _VERSIONED_EVENT_ID + + versioned_payload = versioned_event.payload + assert versioned_payload["assembly_id"] == str(assembly_id) + assert versioned_payload["name"] == "MCTOptics-rev2" + assert versioned_payload["presents_as_family_id"] == str(family_id) + assert versioned_payload["version"] == "v0.2.0" + assert versioned_payload["previous_content_hash"] == defined_event.payload["content_hash"] + assert len(versioned_payload["content_hash"]) == 64 + assert versioned_event.metadata == {"command": "VersionAssembly"} + assert versioned_event.occurred_at == _NOW diff --git a/apps/api/tests/unit/equipment/test_assembly_summary_projection.py b/apps/api/tests/unit/equipment/test_assembly_summary_projection.py index 0591e28e0..53bf6bdb0 100644 --- a/apps/api/tests/unit/equipment/test_assembly_summary_projection.py +++ b/apps/api/tests/unit/equipment/test_assembly_summary_projection.py @@ -44,16 +44,19 @@ def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: def test_projection_metadata() -> None: proj = AssemblySummaryProjection() assert proj.name == "proj_equipment_assembly_summary" - assert proj.subscribed_event_types == frozenset({"AssemblyDefined"}) + assert proj.subscribed_event_types == frozenset( + { + "AssemblyDefined", + "AssemblyVersioned", + } + ) @pytest.mark.unit -def test_projection_does_not_subscribe_to_versioned_or_deprecated_in_v1() -> None: - """v1 ships AssemblyDefined arm only; the Versioned and Deprecated - arms land with their respective slices per the slice-per-commit - discipline.""" +def test_projection_does_not_subscribe_to_deprecated_yet() -> None: + """The Deprecated arm lands with the deprecate_assembly slice + per the slice-per-commit discipline.""" proj = AssemblySummaryProjection() - assert "AssemblyVersioned" not in proj.subscribed_event_types assert "AssemblyDeprecated" not in proj.subscribed_event_types @@ -114,6 +117,39 @@ async def test_assembly_defined_handles_null_version() -> None: assert args.args[4] is None # version +@pytest.mark.unit +async def test_assembly_versioned_updates_status_name_family_version_hash() -> None: + proj = AssemblySummaryProjection() + conn = AsyncMock() + new_family_id = uuid4() + event = _stored( + "AssemblyVersioned", + { + "assembly_id": str(_ASSEMBLY_ID), + "name": "MCTOptics-rev2", + "presents_as_family_id": str(new_family_id), + "required_slots": [], + "required_wires": [], + "parameter_overrides_schema": None, + "drawing": None, + "version": "v0.2.0", + "content_hash": "c" * 64, + "previous_content_hash": "a" * 64, + "occurred_at": _NOW.isoformat(), + }, + ) + await proj.apply(event, conn) + args = conn.execute.await_args + assert args is not None + # Positional args after the SQL: assembly_id, name, + # presents_as_family_id, version, content_hash. + assert args.args[1] == _ASSEMBLY_ID + assert args.args[2] == "MCTOptics-rev2" + assert args.args[3] == new_family_id + assert args.args[4] == "v0.2.0" + assert args.args[5] == "c" * 64 + + @pytest.mark.unit async def test_unrelated_event_type_is_silently_ignored() -> None: """Out-of-subscription events return without raising; the projector diff --git a/apps/api/tests/unit/equipment/test_version_assembly_decider.py b/apps/api/tests/unit/equipment/test_version_assembly_decider.py new file mode 100644 index 000000000..2112161d8 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_assembly_decider.py @@ -0,0 +1,273 @@ +"""Unit tests for the `version_assembly` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.assembly import ( + Assembly, + AssemblyCannotVersionError, + AssemblyName, + AssemblyNotFoundError, + AssemblyStatus, + AssemblyVersioned, + FamilyNotFoundForAssemblyError, + InvalidAssemblyNameError, + InvalidParameterOverridesSchemaError, + SlotCardinality, + SlotName, + TemplateSlot, + TemplateWire, + WireReferencesUnknownSlotError, +) +from cora.equipment.features import version_assembly +from cora.equipment.features.version_assembly import ( + VersionAssembly, + VersionAssemblyContext, +) + +_NOW = datetime(2026, 6, 2, 13, 0, 0, tzinfo=UTC) + + +def _slot(name: str = "camera", family_id: object = None) -> TemplateSlot: + return TemplateSlot( + slot_name=SlotName(name), + required_families=frozenset({family_id or uuid4()}), # type: ignore[arg-type] + cardinality=SlotCardinality.EXACTLY_1, + ) + + +def _state( + assembly_id: object, + family_id: object, + *, + status: AssemblyStatus = AssemblyStatus.DEFINED, + content_hash: str = "a" * 64, +) -> Assembly: + return Assembly( + id=assembly_id, # type: ignore[arg-type] + name=AssemblyName("Initial"), + presents_as_family_id=family_id, # type: ignore[arg-type] + status=status, + content_hash=content_hash, + ) + + +@pytest.mark.unit +def test_decide_emits_assembly_versioned_from_defined_state() -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id, status=AssemblyStatus.DEFINED) + events = version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name="Detector", + presents_as_family_id=family_id, + version="v0.2.0", + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + assert len(events) == 1 + event = events[0] + assert isinstance(event, AssemblyVersioned) + assert event.assembly_id == assembly_id + assert event.previous_content_hash == "a" * 64 + assert event.version == "v0.2.0" + assert event.occurred_at == _NOW + assert len(event.content_hash) == 64 + + +@pytest.mark.unit +def test_decide_emits_assembly_versioned_from_versioned_state() -> None: + """Multi-source FSM: Versioned -> Versioned is also valid.""" + assembly_id = uuid4() + family_id = uuid4() + state = _state( + assembly_id, + family_id, + status=AssemblyStatus.VERSIONED, + content_hash="b" * 64, + ) + events = version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name="Detector", + presents_as_family_id=family_id, + version="v0.3.0", + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + assert len(events) == 1 + assert events[0].previous_content_hash == "b" * 64 + + +@pytest.mark.unit +def test_decide_rejects_none_state_with_assembly_not_found() -> None: + target_id = uuid4() + with pytest.raises(AssemblyNotFoundError) as exc_info: + version_assembly.decide( + state=None, + command=VersionAssembly( + assembly_id=target_id, + name="X", + presents_as_family_id=uuid4(), + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + assert exc_info.value.assembly_id == target_id + + +@pytest.mark.unit +def test_decide_rejects_deprecated_state_with_cannot_version() -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id, status=AssemblyStatus.DEPRECATED) + with pytest.raises(AssemblyCannotVersionError) as exc_info: + version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name="X", + presents_as_family_id=family_id, + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + assert exc_info.value.assembly_id == assembly_id + assert "Deprecated" in exc_info.value.reason + + +@pytest.mark.unit +def test_decide_rejects_missing_family_with_family_not_found() -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id) + missing_family = uuid4() + with pytest.raises(FamilyNotFoundForAssemblyError) as exc_info: + version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name="X", + presents_as_family_id=missing_family, + ), + context=VersionAssemblyContext(missing_family_ids=frozenset({missing_family})), + now=_NOW, + ) + assert exc_info.value.family_id == missing_family + + +@pytest.mark.unit +def test_decide_rejects_invalid_name_via_vo() -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id) + with pytest.raises(InvalidAssemblyNameError): + version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name=" ", + presents_as_family_id=family_id, + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_wire_referencing_unknown_slot() -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id) + wire = TemplateWire( + source_slot_name="missing", + source_port_name="out", + target_slot_name="camera", + target_port_name="in", + ) + camera_slot = _slot("camera") + with pytest.raises(WireReferencesUnknownSlotError) as exc_info: + version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name="X", + presents_as_family_id=family_id, + required_slots=frozenset({camera_slot}), + required_wires=frozenset({wire}), + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + assert exc_info.value.slot_name == "missing" + + +@pytest.mark.unit +def test_decide_rejects_invalid_parameter_overrides_schema() -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id) + with pytest.raises(InvalidParameterOverridesSchemaError): + version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name="X", + presents_as_family_id=family_id, + parameter_overrides_schema={"oneOf": [{"type": "object"}]}, + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_allows_re_attestation_with_same_content() -> None: + """Same structural content yields the same content_hash but + emits a fresh AssemblyVersioned event; the decider does not + refuse re-attestation.""" + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id, content_hash="prev" + "0" * 60) + command = VersionAssembly( + assembly_id=assembly_id, + name="Stable", + presents_as_family_id=family_id, + version="v1.0.0", + ) + context = VersionAssemblyContext(missing_family_ids=frozenset()) + events_a = version_assembly.decide(state, command, context=context, now=_NOW) + events_b = version_assembly.decide(state, command, context=context, now=_NOW) + assert len(events_a) == 1 + assert len(events_b) == 1 + # Same content -> same content_hash. Each call still emits a + # fresh event (the audit moment is captured per call). + assert events_a[0].content_hash == events_b[0].content_hash + assert events_a[0].previous_content_hash == "prev" + "0" * 60 + + +@pytest.mark.unit +def test_decide_replace_on_version_carries_full_new_slot_set() -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id) + new_slots = frozenset({_slot("camera"), _slot("scintillator"), _slot("trigger_source")}) + events = version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name="Detector", + presents_as_family_id=family_id, + required_slots=new_slots, + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=_NOW, + ) + assert events[0].required_slots == new_slots diff --git a/apps/api/tests/unit/equipment/test_version_assembly_decider_properties.py b/apps/api/tests/unit/equipment/test_version_assembly_decider_properties.py new file mode 100644 index 000000000..160bb83df --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_assembly_decider_properties.py @@ -0,0 +1,185 @@ +"""Property-based tests for `version_assembly.decide` (Equipment BC). + +Mirrors `test_define_assembly_decider_properties.py` on the +update-style command. Universal claims across generated inputs: + + - state=None always raises AssemblyNotFoundError carrying the + command's assembly_id. + - state.status=Deprecated always raises AssemblyCannotVersionError. + - state=Defined or Versioned + empty missing_family_ids emits a + single AssemblyVersioned with the injected now and + previous_content_hash = state.content_hash. + - state=Defined/Versioned + non-empty missing_family_ids raises + FamilyNotFoundForAssemblyError carrying the sorted-first id. + - Pure: same (state, command, context, now) returns the same + events (and the same content_hash). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.assembly import ( + ASSEMBLY_NAME_MAX_LENGTH, + Assembly, + AssemblyCannotVersionError, + AssemblyName, + AssemblyNotFoundError, + AssemblyStatus, + AssemblyVersioned, + FamilyNotFoundForAssemblyError, +) +from cora.equipment.features import version_assembly +from cora.equipment.features.version_assembly import ( + VersionAssembly, + VersionAssemblyContext, +) +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=ASSEMBLY_NAME_MAX_LENGTH) + +_VERSIONABLE_STATUS = st.sampled_from((AssemblyStatus.DEFINED, AssemblyStatus.VERSIONED)) + + +def _state(assembly_id: UUID, family_id: UUID, status: AssemblyStatus) -> Assembly: + return Assembly( + id=assembly_id, + name=AssemblyName("Initial"), + presents_as_family_id=family_id, + status=status, + content_hash="a" * 64, + ) + + +@pytest.mark.unit +@given(name=_NAME, status=_VERSIONABLE_STATUS, now=aware_datetimes()) +def test_decide_versionable_state_emits_versioned_event( + name: str, + status: AssemblyStatus, + now: datetime, +) -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id, status) + events = version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name=name, + presents_as_family_id=family_id, + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=now, + ) + assert len(events) == 1 + event = events[0] + assert isinstance(event, AssemblyVersioned) + assert event.assembly_id == assembly_id + assert event.previous_content_hash == "a" * 64 + assert event.occurred_at == now + assert len(event.content_hash) == 64 + + +@pytest.mark.unit +@given(name=_NAME, now=aware_datetimes()) +def test_decide_none_state_always_raises_not_found( + name: str, + now: datetime, +) -> None: + target_id = uuid4() + with pytest.raises(AssemblyNotFoundError) as exc_info: + version_assembly.decide( + state=None, + command=VersionAssembly( + assembly_id=target_id, + name=name, + presents_as_family_id=uuid4(), + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=now, + ) + assert exc_info.value.assembly_id == target_id + + +@pytest.mark.unit +@given(name=_NAME, now=aware_datetimes()) +def test_decide_deprecated_state_always_raises_cannot_version( + name: str, + now: datetime, +) -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id, AssemblyStatus.DEPRECATED) + with pytest.raises(AssemblyCannotVersionError) as exc_info: + version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name=name, + presents_as_family_id=family_id, + ), + context=VersionAssemblyContext(missing_family_ids=frozenset()), + now=now, + ) + assert exc_info.value.assembly_id == assembly_id + + +@pytest.mark.unit +@given( + name=_NAME, + status=_VERSIONABLE_STATUS, + missing_count=st.integers(min_value=1, max_value=5), + now=aware_datetimes(), +) +def test_decide_versionable_state_with_missing_families_raises_family_not_found( + name: str, + status: AssemblyStatus, + missing_count: int, + now: datetime, +) -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id, status) + missing = frozenset(uuid4() for _ in range(missing_count)) + with pytest.raises(FamilyNotFoundForAssemblyError) as exc_info: + version_assembly.decide( + state=state, + command=VersionAssembly( + assembly_id=assembly_id, + name=name, + presents_as_family_id=family_id, + ), + context=VersionAssemblyContext(missing_family_ids=missing), + now=now, + ) + assert exc_info.value.family_id in missing + + +@pytest.mark.unit +@given(name=_NAME, status=_VERSIONABLE_STATUS, now=aware_datetimes()) +def test_decide_is_pure_same_inputs_yield_same_events( + name: str, + status: AssemblyStatus, + now: datetime, +) -> None: + assembly_id = uuid4() + family_id = uuid4() + state = _state(assembly_id, family_id, status) + command = VersionAssembly( + assembly_id=assembly_id, + name=name, + presents_as_family_id=family_id, + ) + context = VersionAssemblyContext(missing_family_ids=frozenset()) + events_a = version_assembly.decide(state, command, context=context, now=now) + events_b = version_assembly.decide(state, command, context=context, now=now) + assert events_a == events_b + assert events_a[0].content_hash == events_b[0].content_hash From 1949ec89a66df28a47a20c6465fc92bd2da8f5ce Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 2 Jun 2026 23:15:07 +0300 Subject: [PATCH 5/5] fix(equipment): complete required_families -> required_family_ids rename + register Assembly aggregate in slice-verb fitness Post-cherry-pick cleanup. The TemplateSlot.required_families field was renamed to required_family_ids in the scaffold-Assembly commit (per Phase 5's UUID-collection-suffix convention; the test_uuid_collection_field_suffix fitness caught it). The rename applied to aggregate state + events + 5 unit tests in that commit, but the define_assembly slice (B.1) and version_assembly slice (B.2) that landed after still referenced the old name in their command, decider, handler, route, helpers, and contract / MCP tests. This commit completes the rename across those 12 files plus the OpenAPI snapshot regen. Also extends the Phase 5 slice-verb fitness's `_AGGREGATE_NAMES` to include the new `assembly` aggregate. Without this entry the `define_assembly` and `version_assembly` slices would fail test_slice_dir_carries_subject the moment the Assembly aggregate lands on main. 21674 unit + arch tests + the openapi-drift fitness all pass. --- apps/api/openapi.json | 10 +++++----- .../src/cora/equipment/_template_slot_body.py | 6 +++--- .../features/define_assembly/command.py | 4 ++-- .../features/define_assembly/decider.py | 2 +- .../features/define_assembly/handler.py | 6 +++--- .../equipment/features/define_assembly/route.py | 2 +- .../features/version_assembly/command.py | 2 +- .../features/version_assembly/handler.py | 6 +++--- .../equipment/features/version_assembly/route.py | 2 +- .../test_slice_verb_names_subject.py | 1 + .../tests/contract/test_assemblies_endpoint.py | 16 ++++++++-------- .../contract/test_assembly_versions_endpoint.py | 6 +++--- .../contract/test_define_assembly_mcp_tool.py | 4 ++-- .../equipment/test_define_assembly_decider.py | 2 +- .../equipment/test_version_assembly_decider.py | 2 +- 15 files changed, 36 insertions(+), 35 deletions(-) diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 84dee5059..9958cf33e 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -10850,14 +10850,14 @@ "description": "Optional template defaults applied at instantiation unless overridden by the instantiator.", "title": "Default Settings" }, - "required_families": { + "required_family_ids": { "description": "Set of FamilyIds an instantiated Asset must include at least one of. MUST be non-empty.", "items": { "format": "uuid", "type": "string" }, "minItems": 1, - "title": "Required Families", + "title": "Required Family Ids", "type": "array", "uniqueItems": true }, @@ -10871,7 +10871,7 @@ }, "required": [ "slot_name", - "required_families", + "required_family_ids", "cardinality" ], "title": "TemplateSlotBody", @@ -13807,7 +13807,7 @@ } } }, - "description": "A referenced FamilyId (presents_as_family_id or any slot's required_families member) does not resolve to a defined Family." + "description": "A referenced FamilyId (presents_as_family_id or any slot's required_family_ids member) does not resolve to a defined Family." }, "409": { "content": { @@ -13907,7 +13907,7 @@ } } }, - "description": "Assembly with the given assembly_id does not exist, OR a referenced FamilyId (presents_as_family_id or any slot's required_families member) does not resolve to a defined Family." + "description": "Assembly with the given assembly_id does not exist, OR a referenced FamilyId (presents_as_family_id or any slot's required_family_ids member) does not resolve to a defined Family." }, "409": { "content": { diff --git a/apps/api/src/cora/equipment/_template_slot_body.py b/apps/api/src/cora/equipment/_template_slot_body.py index 1bed68d4a..1454f47b4 100644 --- a/apps/api/src/cora/equipment/_template_slot_body.py +++ b/apps/api/src/cora/equipment/_template_slot_body.py @@ -8,7 +8,7 @@ `InvalidPlacementError` during construction). Pydantic enforces field-shape rules at the API boundary; domain -invariants (non-empty `required_families`, closed-enum +invariants (non-empty `required_family_ids`, closed-enum `cardinality`, NaN-rejecting `default_placement`) surface from the domain VO constructors and map to HTTP 400 through the BC's exception handler. @@ -44,7 +44,7 @@ class TemplateSlotBody(BaseModel): "(e.g., 'camera', 'rotary', 'trigger_source')." ), ) - required_families: frozenset[UUID] = Field( + required_family_ids: frozenset[UUID] = Field( ..., min_length=1, description=( @@ -75,7 +75,7 @@ def to_domain(self) -> TemplateSlot: """Convert this wire body to the domain TemplateSlot VO.""" return TemplateSlot( slot_name=SlotName(self.slot_name), - required_families=self.required_families, + required_family_ids=self.required_family_ids, cardinality=self.cardinality, default_settings=self.default_settings, default_placement=( diff --git a/apps/api/src/cora/equipment/features/define_assembly/command.py b/apps/api/src/cora/equipment/features/define_assembly/command.py index 0b541072c..d192695cf 100644 --- a/apps/api/src/cora/equipment/features/define_assembly/command.py +++ b/apps/api/src/cora/equipment/features/define_assembly/command.py @@ -12,7 +12,7 @@ Slots and wires arrive as fully-constructed domain VOs (the route layer's TemplateSlotBody / TemplateWireBody call .to_domain() before the command is built); structural VO-level invariants (slot-name -length, cardinality enum membership, non-empty required_families, +length, cardinality enum membership, non-empty required_family_ids, wire-port-name length, degenerate-full-self-loop rejection, wire- endpoints-reference-declared-slots closure via Assembly.__post_init__ when state is constructed in the evolver) all fire at VO construction @@ -20,7 +20,7 @@ Cross-aggregate references checked by the handler before the decider: - presents_as_family_id must resolve to a defined Family. - - Every FamilyId in every slot's required_families must resolve. + - Every FamilyId in every slot's required_family_ids must resolve. Cross-aggregate references NOT checked: - Asset existence (Assembly is a template; the Assets do not exist diff --git a/apps/api/src/cora/equipment/features/define_assembly/decider.py b/apps/api/src/cora/equipment/features/define_assembly/decider.py index f0f3c55b3..c60559b28 100644 --- a/apps/api/src/cora/equipment/features/define_assembly/decider.py +++ b/apps/api/src/cora/equipment/features/define_assembly/decider.py @@ -23,7 +23,7 @@ validator). Structural VO invariants on `required_slots` / `required_wires` -(slot-name length, cardinality enum, non-empty required_families, +(slot-name length, cardinality enum, non-empty required_family_ids, wire-port-name length, full-self-loop rejection) fire at VO construction time in the route / tool layers, never inside the decider. Internal closure (every wire endpoint references a declared diff --git a/apps/api/src/cora/equipment/features/define_assembly/handler.py b/apps/api/src/cora/equipment/features/define_assembly/handler.py index 15aecb3ba..5db3f91dc 100644 --- a/apps/api/src/cora/equipment/features/define_assembly/handler.py +++ b/apps/api/src/cora/equipment/features/define_assembly/handler.py @@ -5,7 +5,7 @@ 1. Authz check (Deny -> UnauthorizedError). 2. Load Family aggregate for `presents_as_family_id` + every - FamilyId across the slot set's required_families; collect the + FamilyId across the slot set's required_family_ids; collect the missing ones into context. 3. Call pure decider with state=None + context + command + now + new_id. @@ -71,13 +71,13 @@ async def __call__( def _referenced_family_ids(command: DefineAssembly) -> frozenset[UUID]: """Collect every FamilyId the Assembly references at define time. - Union of `presents_as_family_id` and every slot's required_families. + Union of `presents_as_family_id` and every slot's required_family_ids. Returned as a frozenset so handler loads are de-duplicated when multiple slots share a Family. """ ids: set[UUID] = {command.presents_as_family_id} for slot in command.required_slots: - ids.update(slot.required_families) + ids.update(slot.required_family_ids) return frozenset(ids) diff --git a/apps/api/src/cora/equipment/features/define_assembly/route.py b/apps/api/src/cora/equipment/features/define_assembly/route.py index be9475455..6b12f682a 100644 --- a/apps/api/src/cora/equipment/features/define_assembly/route.py +++ b/apps/api/src/cora/equipment/features/define_assembly/route.py @@ -128,7 +128,7 @@ def _get_handler(request: Request) -> IdempotentHandler: "model": ErrorResponse, "description": ( "A referenced FamilyId (presents_as_family_id or any " - "slot's required_families member) does not resolve to " + "slot's required_family_ids member) does not resolve to " "a defined Family." ), }, diff --git a/apps/api/src/cora/equipment/features/version_assembly/command.py b/apps/api/src/cora/equipment/features/version_assembly/command.py index 30db26a2d..0b021d444 100644 --- a/apps/api/src/cora/equipment/features/version_assembly/command.py +++ b/apps/api/src/cora/equipment/features/version_assembly/command.py @@ -11,7 +11,7 @@ (e.g., DCM-revisited may move from `Monochromator` to a wider `BeamConditioning` Family). The handler re-checks Family existence for every referenced FamilyId (presents_as_family_id + every slot's -required_families). +required_family_ids). Multi-source FSM transition: Defined -> Versioned AND Versioned -> Versioned are both valid; only Deprecated rejects. diff --git a/apps/api/src/cora/equipment/features/version_assembly/handler.py b/apps/api/src/cora/equipment/features/version_assembly/handler.py index 9da291a97..8ac67afb9 100644 --- a/apps/api/src/cora/equipment/features/version_assembly/handler.py +++ b/apps/api/src/cora/equipment/features/version_assembly/handler.py @@ -10,7 +10,7 @@ optimistic-concurrency append (matches decommission_mount's single-load shape). 3. Load Family aggregate for `presents_as_family_id` + every - FamilyId across the slot set's required_families (concurrent + FamilyId across the slot set's required_family_ids (concurrent via asyncio.gather, de-duplicated via frozenset). Build context with missing_family_ids. 4. Call pure decider with state + context + command + now. @@ -62,13 +62,13 @@ async def __call__( def _referenced_family_ids(command: VersionAssembly) -> frozenset[UUID]: """Collect every FamilyId the new version references. - Union of `presents_as_family_id` and every slot's required_families. + Union of `presents_as_family_id` and every slot's required_family_ids. Returned as a frozenset so handler loads are de-duplicated when multiple slots share a Family. """ ids: set[UUID] = {command.presents_as_family_id} for slot in command.required_slots: - ids.update(slot.required_families) + ids.update(slot.required_family_ids) return frozenset(ids) diff --git a/apps/api/src/cora/equipment/features/version_assembly/route.py b/apps/api/src/cora/equipment/features/version_assembly/route.py index bf5d41463..60a7cec3d 100644 --- a/apps/api/src/cora/equipment/features/version_assembly/route.py +++ b/apps/api/src/cora/equipment/features/version_assembly/route.py @@ -117,7 +117,7 @@ def _get_handler(request: Request) -> Handler: "description": ( "Assembly with the given assembly_id does not exist, " "OR a referenced FamilyId (presents_as_family_id or " - "any slot's required_families member) does not resolve " + "any slot's required_family_ids member) does not resolve " "to a defined Family." ), }, diff --git a/apps/api/tests/architecture/test_slice_verb_names_subject.py b/apps/api/tests/architecture/test_slice_verb_names_subject.py index 41f2e87d8..48e7bdb25 100644 --- a/apps/api/tests/architecture/test_slice_verb_names_subject.py +++ b/apps/api/tests/architecture/test_slice_verb_names_subject.py @@ -40,6 +40,7 @@ { "actor", "agent", + "assembly", "asset", "calibration", "campaign", diff --git a/apps/api/tests/contract/test_assemblies_endpoint.py b/apps/api/tests/contract/test_assemblies_endpoint.py index bdf19a7e3..48e2a5f1a 100644 --- a/apps/api/tests/contract/test_assemblies_endpoint.py +++ b/apps/api/tests/contract/test_assemblies_endpoint.py @@ -63,12 +63,12 @@ def test_post_assemblies_returns_201_with_slots_and_wires() -> None: "required_slots": [ { "slot_name": "camera", - "required_families": [str(camera_family)], + "required_family_ids": [str(camera_family)], "cardinality": "Exactly1", }, { "slot_name": "trigger_source", - "required_families": [str(trigger_family)], + "required_family_ids": [str(trigger_family)], "cardinality": "Exactly1", }, ], @@ -113,7 +113,7 @@ def test_post_assemblies_returns_404_when_slot_required_family_missing() -> None "required_slots": [ { "slot_name": "camera", - "required_families": [str(uuid4())], + "required_family_ids": [str(uuid4())], "cardinality": "Exactly1", } ], @@ -154,7 +154,7 @@ def test_post_assemblies_returns_400_when_wire_references_unknown_slot() -> None "required_slots": [ { "slot_name": "camera", - "required_families": [str(camera_family)], + "required_family_ids": [str(camera_family)], "cardinality": "Exactly1", } ], @@ -185,7 +185,7 @@ def test_post_assemblies_returns_400_for_degenerate_full_self_loop_wire() -> Non "required_slots": [ { "slot_name": "lut", - "required_families": [str(lut_family)], + "required_family_ids": [str(lut_family)], "cardinality": "Exactly1", } ], @@ -238,7 +238,7 @@ def test_post_assemblies_returns_422_for_unknown_cardinality() -> None: "required_slots": [ { "slot_name": "camera", - "required_families": [str(family_id)], + "required_family_ids": [str(family_id)], "cardinality": "Bogus", } ], @@ -249,7 +249,7 @@ def test_post_assemblies_returns_422_for_unknown_cardinality() -> None: @pytest.mark.contract -def test_post_assemblies_returns_422_for_empty_required_families() -> None: +def test_post_assemblies_returns_422_for_empty_required_family_ids() -> None: with TestClient(create_app()) as client: family_id = _define_family(client) response = client.post( @@ -260,7 +260,7 @@ def test_post_assemblies_returns_422_for_empty_required_families() -> None: "required_slots": [ { "slot_name": "orphan", - "required_families": [], + "required_family_ids": [], "cardinality": "ZeroOrMore", } ], diff --git a/apps/api/tests/contract/test_assembly_versions_endpoint.py b/apps/api/tests/contract/test_assembly_versions_endpoint.py index 4c77a5af2..4465e5dfb 100644 --- a/apps/api/tests/contract/test_assembly_versions_endpoint.py +++ b/apps/api/tests/contract/test_assembly_versions_endpoint.py @@ -146,7 +146,7 @@ def test_post_assembly_version_returns_400_when_wire_references_unknown_slot() - "required_slots": [ { "slot_name": "camera", - "required_families": [str(camera_family)], + "required_family_ids": [str(camera_family)], "cardinality": "Exactly1", } ], @@ -210,12 +210,12 @@ def test_post_assembly_version_replaces_structural_fields() -> None: "required_slots": [ { "slot_name": "camera", - "required_families": [str(camera_family)], + "required_family_ids": [str(camera_family)], "cardinality": "Exactly1", }, { "slot_name": "scintillator", - "required_families": [str(scintillator_family)], + "required_family_ids": [str(scintillator_family)], "cardinality": "Exactly1", }, ], diff --git a/apps/api/tests/contract/test_define_assembly_mcp_tool.py b/apps/api/tests/contract/test_define_assembly_mcp_tool.py index 1ebcb59e4..4075767f9 100644 --- a/apps/api/tests/contract/test_define_assembly_mcp_tool.py +++ b/apps/api/tests/contract/test_define_assembly_mcp_tool.py @@ -128,12 +128,12 @@ def test_mcp_define_assembly_tool_succeeds_with_slot_and_wire() -> None: "required_slots": [ { "slot_name": "camera", - "required_families": [str(camera_family)], + "required_family_ids": [str(camera_family)], "cardinality": "Exactly1", }, { "slot_name": "trigger_source", - "required_families": [str(trigger_family)], + "required_family_ids": [str(trigger_family)], "cardinality": "Exactly1", }, ], diff --git a/apps/api/tests/unit/equipment/test_define_assembly_decider.py b/apps/api/tests/unit/equipment/test_define_assembly_decider.py index 0075fe3ea..8d20c4bed 100644 --- a/apps/api/tests/unit/equipment/test_define_assembly_decider.py +++ b/apps/api/tests/unit/equipment/test_define_assembly_decider.py @@ -31,7 +31,7 @@ def _slot(name: str = "camera", family_id: object = None) -> TemplateSlot: return TemplateSlot( slot_name=SlotName(name), - required_families=frozenset({family_id or uuid4()}), # type: ignore[arg-type] + required_family_ids=frozenset({family_id or uuid4()}), # type: ignore[arg-type] cardinality=SlotCardinality.EXACTLY_1, ) diff --git a/apps/api/tests/unit/equipment/test_version_assembly_decider.py b/apps/api/tests/unit/equipment/test_version_assembly_decider.py index 2112161d8..d556b2f08 100644 --- a/apps/api/tests/unit/equipment/test_version_assembly_decider.py +++ b/apps/api/tests/unit/equipment/test_version_assembly_decider.py @@ -33,7 +33,7 @@ def _slot(name: str = "camera", family_id: object = None) -> TemplateSlot: return TemplateSlot( slot_name=SlotName(name), - required_families=frozenset({family_id or uuid4()}), # type: ignore[arg-type] + required_family_ids=frozenset({family_id or uuid4()}), # type: ignore[arg-type] cardinality=SlotCardinality.EXACTLY_1, )