diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 4449d2f7fa..754eab1f63 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1420,6 +1420,55 @@ "title": "AssignAssetPersistentIdResponse", "type": "object" }, + "AssignFixturePersistentIdRequest": { + "description": "Body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`.\n\n`scheme` selects the PID scheme; v1 supports DOI and HANDLE.\n`suffix` is the optional operator-supplied local part; when absent\nthe configured `DoiMinter` adapter auto-generates one. No `value`\nfield per the server-mint posture.", + "properties": { + "scheme": { + "$ref": "#/components/schemas/PersistentIdentifierScheme", + "description": "Closed PIDINST Property 1 scheme: DOI or HANDLE." + }, + "suffix": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional operator-supplied local part. When absent, the configured DoiMinter adapter auto-generates the suffix.", + "title": "Suffix" + } + }, + "required": [ + "scheme" + ], + "title": "AssignFixturePersistentIdRequest", + "type": "object" + }, + "AssignFixturePersistentIdResponse": { + "description": "Response body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`.\n\nEchoes the server-minted `(scheme, value)` pair so the operator\nlearns the assigned identifier without a follow-up GET. Mirrors the\nAsset-tier sibling exactly; the value is server-minted and not\nderivable from the request alone.", + "properties": { + "scheme": { + "description": "Assigned PIDINST Property 1 scheme value (DOI or Handle).", + "title": "Scheme", + "type": "string" + }, + "value": { + "description": "Authority-assigned persistent identifier string.", + "title": "Value", + "type": "string" + } + }, + "required": [ + "scheme", + "value" + ], + "title": "AssignFixturePersistentIdResponse", + "type": "object" + }, "AttachAssetToFixtureRequest": { "description": "Body for `POST /assets/{asset_id}/attach-to-fixture`.", "properties": { @@ -25815,6 +25864,123 @@ ] } }, + "/fixtures/{fixture_id}/assign-persistent-identifier": { + "post": { + "operationId": "post_fixtures_assign_persistent_identifier_fixtures__fixture_id__assign_persistent_identifier_post", + "parameters": [ + { + "description": "Target fixture's id.", + "in": "path", + "name": "fixture_id", + "required": true, + "schema": { + "description": "Target fixture's id.", + "format": "uuid", + "title": "Fixture 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/AssignFixturePersistentIdRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignFixturePersistentIdResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "PersistentIdentifier VO validation failed: empty or whitespace-only value, or value over the max-length bound (InvalidPersistentIdentifierValueError)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize policy denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No fixture exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Fixture cannot accept the persistent identifier: the fixture already carries a persistent_id (set-once: FixturePersistentIdAlreadyAssignedError), OR a concurrent write to the same fixture stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation (missing field, malformed UUID, scheme outside the closed enum, suffix length out of bounds at the wire layer)." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The external mint authority (DataCite or Handle.net) failed to assign a persistent identifier (PersistentIdentifierMintError)." + } + }, + "summary": "Assign a PIDINST persistent identifier to an existing Fixture", + "tags": [ + "equipment" + ] + } + }, "/fixtures/{fixture_id}/pidinst": { "get": { "operationId": "get_fixture_pidinst_fixtures__fixture_id__pidinst_get", diff --git a/apps/api/src/cora/equipment/_fixture_persistent_identifier_body.py b/apps/api/src/cora/equipment/_fixture_persistent_identifier_body.py new file mode 100644 index 0000000000..8f655758b2 --- /dev/null +++ b/apps/api/src/cora/equipment/_fixture_persistent_identifier_body.py @@ -0,0 +1,71 @@ +"""Shared Pydantic wire-format for the `assign_fixture_persistent_id` slice. + +Mirrors `_asset_persistent_identifier_body` at the BC root. Per Section +6.5 of [[project-fixture-pidinst-design]], slice F at the Fixture tier +does NOT carry a domain-VO wire mirror (the `PersistentIdentifier` VO +is never parsed at the wire; it is server-minted inside the handler). +This module instead carries the request + response wire surface for the +single POST endpoint: + + - `AssignFixturePersistentIdRequest`: `(scheme, suffix | None)` + operator intent. The handler resolves the suffix into a full + `PersistentIdentifier` via the configured `DoiMinter` port. + - `AssignFixturePersistentIdResponse`: `(scheme, value)` echoed back + so the operator learns the server-minted identifier without a + follow-up GET (Section 6.5 deviation from the empty-201 convention + for Fixture mutations). + +Reuses `PersistentIdentifierScheme` + `PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH` +from the Asset module per Locks 2-4 of the design memo (the VO + enum +ship at the Asset tier and the Fixture tier imports them unchanged). +""" + +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.asset import ( + PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + PersistentIdentifierScheme, +) + + +class AssignFixturePersistentIdRequest(BaseModel): + """Body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`. + + `scheme` selects the PID scheme; v1 supports DOI and HANDLE. + `suffix` is the optional operator-supplied local part; when absent + the configured `DoiMinter` adapter auto-generates one. No `value` + field per the server-mint posture. + """ + + scheme: PersistentIdentifierScheme = Field( + ..., + description="Closed PIDINST Property 1 scheme: DOI or HANDLE.", + ) + suffix: str | None = Field( + None, + min_length=1, + max_length=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + description=( + "Optional operator-supplied local part. When absent, the " + "configured DoiMinter adapter auto-generates the suffix." + ), + ) + + +class AssignFixturePersistentIdResponse(BaseModel): + """Response body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`. + + Echoes the server-minted `(scheme, value)` pair so the operator + learns the assigned identifier without a follow-up GET. Mirrors the + Asset-tier sibling exactly; the value is server-minted and not + derivable from the request alone. + """ + + scheme: str = Field( + ..., + description="Assigned PIDINST Property 1 scheme value (DOI or Handle).", + ) + value: str = Field( + ..., + description="Authority-assigned persistent identifier string.", + ) diff --git a/apps/api/src/cora/equipment/aggregates/fixture/__init__.py b/apps/api/src/cora/equipment/aggregates/fixture/__init__.py index a055f0d539..a704b7f44c 100644 --- a/apps/api/src/cora/equipment/aggregates/fixture/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/fixture/__init__.py @@ -12,6 +12,7 @@ from cora.equipment.aggregates.fixture.events import ( FixtureEvent, + FixturePersistentIdAssigned, FixtureRegistered, event_type_name, from_stored, @@ -23,6 +24,8 @@ Fixture, FixtureAlreadyExistsError, FixtureNotFoundError, + FixturePersistentIdAlreadyAssignedError, + MalformedFixturePersistentIdentifierError, PersistentIdentifier, SlotAssetBinding, ) @@ -32,7 +35,10 @@ "FixtureAlreadyExistsError", "FixtureEvent", "FixtureNotFoundError", + "FixturePersistentIdAlreadyAssignedError", + "FixturePersistentIdAssigned", "FixtureRegistered", + "MalformedFixturePersistentIdentifierError", "PersistentIdentifier", "SlotAssetBinding", "event_type_name", diff --git a/apps/api/src/cora/equipment/aggregates/fixture/events.py b/apps/api/src/cora/equipment/aggregates/fixture/events.py index 8aa53ebef8..765e8f3d34 100644 --- a/apps/api/src/cora/equipment/aggregates/fixture/events.py +++ b/apps/api/src/cora/equipment/aggregates/fixture/events.py @@ -25,7 +25,11 @@ from typing import Any, assert_never from uuid import UUID -from cora.equipment.aggregates.fixture.state import SlotAssetBinding +from cora.equipment.aggregates.asset import PersistentIdentifierScheme +from cora.equipment.aggregates.fixture.state import ( + MalformedFixturePersistentIdentifierError, + SlotAssetBinding, +) from cora.infrastructure.event_payload import deserialize_or_raise from cora.infrastructure.ports.event_store import StoredEvent @@ -63,7 +67,32 @@ class FixtureRegistered: occurred_at: datetime -FixtureEvent = FixtureRegistered +@dataclass(frozen=True) +class FixturePersistentIdAssigned: + """A persistent identifier (PIDINST v1.0 Property 1) was assigned to a Fixture. + + Single-assign event. Set-once at the aggregate level: the + decider's `FixturePersistentIdAlreadyAssignedError` enforces "must + currently be absent" at command time, so the stream can contain + AT MOST ONE `FixturePersistentIdAssigned` event per Fixture. + + The full `PersistentIdentifier` VO (scheme + value) travels in the + payload as two primitives, mirroring `AssetPersistentIdAssigned`: + scheme is the StrEnum value, value is the trimmed string. This + lets `from_stored` rebuild the VO without reading prior state. + + No `withdrawn_at` / `withdrawal_reason` on this event: this slice does + not model withdrawal. A future slice owns the withdrawal sibling + event when operator demand fires. + """ + + fixture_id: UUID + persistent_id_scheme: str + persistent_id_value: str + occurred_at: datetime + + +FixtureEvent = FixtureRegistered | FixturePersistentIdAssigned def event_type_name(event: FixtureEvent) -> str: @@ -95,6 +124,18 @@ def to_payload(event: FixtureEvent) -> dict[str, Any]: "parameter_overrides": parameter_overrides, "occurred_at": occurred_at.isoformat(), } + case FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme=scheme, + persistent_id_value=value, + occurred_at=occurred_at, + ): + return { + "fixture_id": str(fixture_id), + "persistent_id_scheme": scheme, + "persistent_id_value": value, + "occurred_at": occurred_at.isoformat(), + } case _: # pragma: no cover # exhaustiveness guard assert_never(event) @@ -119,6 +160,27 @@ def _build() -> FixtureRegistered: ) return deserialize_or_raise("FixtureRegistered", _build) + case "FixturePersistentIdAssigned": + + def _build_persistent_id_assigned() -> FixturePersistentIdAssigned: + scheme = PersistentIdentifierScheme(payload["persistent_id_scheme"]) + value = payload["persistent_id_value"] + if not isinstance(value, str) or not value.strip(): + raise MalformedFixturePersistentIdentifierError( + f"persistent_id_value must be a non-empty string (got: {value!r})" + ) + return FixturePersistentIdAssigned( + fixture_id=UUID(payload["fixture_id"]), + persistent_id_scheme=scheme.value, + persistent_id_value=value, + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + return deserialize_or_raise( + "FixturePersistentIdAssigned", + _build_persistent_id_assigned, + extra=(ValueError, MalformedFixturePersistentIdentifierError), + ) case _: msg = f"Unknown FixtureEvent event_type: {stored.event_type!r}" raise ValueError(msg) @@ -126,6 +188,7 @@ def _build() -> FixtureRegistered: __all__ = [ "FixtureEvent", + "FixturePersistentIdAssigned", "FixtureRegistered", "event_type_name", "from_stored", diff --git a/apps/api/src/cora/equipment/aggregates/fixture/evolver.py b/apps/api/src/cora/equipment/aggregates/fixture/evolver.py index 809c33da43..b93ca9bbe9 100644 --- a/apps/api/src/cora/equipment/aggregates/fixture/evolver.py +++ b/apps/api/src/cora/equipment/aggregates/fixture/evolver.py @@ -1,20 +1,28 @@ """Evolver: replay events to reconstruct Fixture state. -Single-event aggregate per Visit-instance pattern: one stream per +Genesis is single-event per Visit-instance pattern: one stream per fixture_id, exactly one `FixtureRegistered` event per stream. The -evolver therefore only handles the genesis case. +stream stays append-only-monotonic: events are FixtureRegistered +exactly once, then optionally one FixturePersistentIdAssigned. """ from collections.abc import Sequence +from dataclasses import replace from typing import assert_never +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) from cora.equipment.aggregates.fixture.events import ( FixtureEvent, + FixturePersistentIdAssigned, FixtureRegistered, ) from cora.equipment.aggregates.fixture.state import ( Fixture, FixtureAlreadyExistsError, + FixtureNotFoundError, ) @@ -24,10 +32,11 @@ def evolve( ) -> Fixture: """Apply one event to the current state. - Genesis-only: the Visit-instance pattern guarantees exactly one - `FixtureRegistered` event per stream. If a duplicate ever lands - (replay bug, malformed migration), raise rather than silently - overwrite the prior state. + Genesis: `FixtureRegistered` lands exactly once per stream and a + duplicate raises rather than silently overwriting prior state. + Post-genesis: `FixturePersistentIdAssigned` folds set-once into + `state.persistent_id`; the decider guards strict-not-idempotent + semantics at command time, so the evolver trusts the input. """ match event: case FixtureRegistered( @@ -50,6 +59,20 @@ def evolve( parameter_overrides=parameter_overrides, registered_at=occurred_at, ) + case FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme=scheme, + persistent_id_value=value, + ): + if state is None: + raise FixtureNotFoundError(fixture_id) + return replace( + state, + persistent_id=PersistentIdentifier( + scheme=PersistentIdentifierScheme(scheme), + value=value, + ), + ) case _: # pragma: no cover # exhaustiveness guard assert_never(event) diff --git a/apps/api/src/cora/equipment/aggregates/fixture/state.py b/apps/api/src/cora/equipment/aggregates/fixture/state.py index db08fb4fee..fe124af5a3 100644 --- a/apps/api/src/cora/equipment/aggregates/fixture/state.py +++ b/apps/api/src/cora/equipment/aggregates/fixture/state.py @@ -109,10 +109,56 @@ def __init__(self, fixture_id: UUID) -> None: self.fixture_id = fixture_id +class FixturePersistentIdAlreadyAssignedError(Exception): + """Attempted to assign a persistent_id to a Fixture that already carries one. + + Set-once at the aggregate level per PIDINST v1.0 F3.3 Findable + immutability: once `Fixture.persistent_id` is set, no further + FixturePersistentIdAssigned event can land. Both the same-value and + different-value retry shapes collapse here; the diagnostic fields + carry the current and attempted PersistentIdentifier so operators + see which assign collided. Mirrors + `AssetPersistentIdAlreadyAssignedError` on the sibling Asset + aggregate. + """ + + def __init__( + self, + fixture_id: UUID, + *, + current: "PersistentIdentifier", + attempted: "PersistentIdentifier", + ) -> None: + super().__init__( + f"Fixture {fixture_id} already has persistent identifier " + f"{current.scheme.value}={current.value!r}; " + f"attempted to assign {attempted.scheme.value}={attempted.value!r}; " + "persistent_id is set-once" + ) + self.fixture_id = fixture_id + self.current = current + self.attempted = attempted + + +class MalformedFixturePersistentIdentifierError(Exception): + """A stored FixturePersistentIdAssigned payload failed deserialization. + + Wraps any underlying `ValueError` raised by + `PersistentIdentifierScheme(...)` or `PersistentIdentifier(...)` at + `from_stored` time, per the [[project-from-stored-wrap-convention]] + precedent (mirrors `MalformedPersistentIdentifierError` on the + sibling Asset aggregate). The evolver itself never raises; it + trusts that `from_stored` already wrapped any malformed payload as + this error class. + """ + + __all__ = [ "Fixture", "FixtureAlreadyExistsError", "FixtureNotFoundError", + "FixturePersistentIdAlreadyAssignedError", + "MalformedFixturePersistentIdentifierError", "PersistentIdentifier", "SlotAssetBinding", ] diff --git a/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/__init__.py b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/__init__.py new file mode 100644 index 0000000000..ca370135dc --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/__init__.py @@ -0,0 +1,43 @@ +"""Vertical slice for the `AssignFixturePersistentId` command. + +Assigns a `PersistentIdentifier` (PIDINST v1.0 Property 1) to an +existing Fixture. Set-once at the aggregate level: a second assign +raises `FixturePersistentIdAlreadyAssignedError`. Fixture has no +lifecycle FSM today, so there is no decommission-style gate (per +Section 2.4 of [[project-fixture-pidinst-design]]). + +Server-mint posture per Lock 5 of the design memo (reuses the +Asset-tier `DoiMinter` port unchanged): the route forwards +`(fixture_id, scheme, suffix)` to the handler, and the handler +closure resolves the `PersistentIdentifier` from the shared +`DoiMinter` port before invoking the pure decider. + +Module-as-namespace surface: + + from cora.equipment.features import assign_fixture_persistent_id + + cmd = assign_fixture_persistent_id.AssignFixturePersistentId( + fixture_id=..., + scheme=PersistentIdentifierScheme.DOI, + suffix="APS-2BM-FIX-001", + ) + handler = assign_fixture_persistent_id.bind(deps) + persistent_id = await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.assign_fixture_persistent_id import tool +from cora.equipment.features.assign_fixture_persistent_id.command import ( + AssignFixturePersistentId, +) +from cora.equipment.features.assign_fixture_persistent_id.decider import decide +from cora.equipment.features.assign_fixture_persistent_id.handler import Handler, bind +from cora.equipment.features.assign_fixture_persistent_id.route import router + +__all__ = [ + "AssignFixturePersistentId", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/command.py b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/command.py new file mode 100644 index 0000000000..a6cb1c13bf --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/command.py @@ -0,0 +1,32 @@ +"""The `AssignFixturePersistentId` command, intent dataclass for this slice. + +`fixture_id` is the target Fixture aggregate. `scheme` selects the PID +scheme (DOI or HANDLE). `suffix` is the optional operator-supplied +local part; when absent the configured `DoiMinter` adapter auto- +generates one. The command does NOT carry the resolved +`PersistentIdentifier` VO (server-mint posture per Lock 5 of +[[project-fixture-pidinst-design]]): the handler resolves the minter +call and forwards the resolved VO into the pure decider. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.asset import PersistentIdentifierScheme + + +@dataclass(frozen=True) +class AssignFixturePersistentId: + """Assign a persistent identifier (PIDINST Property 1) to an existing Fixture. + + Set-once at the aggregate level: a second assign on a Fixture that + already carries a `persistent_id` is rejected by the decider. There + is no Fixture lifecycle gate today (Fixture has no Decommissioned + state today). The handler resolves `(scheme, suffix)` through the + `DoiMinter` port into the full `PersistentIdentifier` before invoking + the pure decider. + """ + + fixture_id: UUID + scheme: PersistentIdentifierScheme + suffix: str | None = None diff --git a/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/decider.py b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/decider.py new file mode 100644 index 0000000000..9d94ea997d --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/decider.py @@ -0,0 +1,76 @@ +"""Pure decider for the `AssignFixturePersistentId` command. + +The decider sees `(state, command)` with the resolved +`PersistentIdentifier` passed as a keyword-only argument (server-mint +posture per Lock 5 of [[project-fixture-pidinst-design]]: the handler +resolves the minter call and forwards the resolved VO here). The +decider is PURE: it does NOT call the DoiMinter, does NOT read the +wall clock (caller injects `now`), and does NOT touch any I/O. Non- +determinism is captured in the handler closure per +[[project-non-determinism-principle]]. + +Two disqualifying conditions surface as dedicated error classes: + + - state is None (no Fixture exists with the given id) -> + `FixtureNotFoundError` + - `state.persistent_id is not None` (set-once; rejects same OR + different value) -> `FixturePersistentIdAlreadyAssignedError` + +There is NO lifecycle-forbidden error class today: Fixture has no +Decommissioned state today (Section 2.4 of the design memo). A +future retire-fixture slice owns its own forbidden-state error. + +P2-FITNESS pin: this module MUST NOT import from +`cora.equipment.ports` or `cora.equipment.adapters`. Any future +refactor that quietly moves the mint into the decider will fail the +architecture test (mirrors the Asset-tier +`test_assign_asset_persistent_id_decider_does_not_import_doi_minter_or_adapters` +fitness). +""" + +from datetime import datetime + +from cora.equipment.aggregates.asset import PersistentIdentifier +from cora.equipment.aggregates.fixture import ( + Fixture, + FixtureNotFoundError, + FixturePersistentIdAlreadyAssignedError, + FixturePersistentIdAssigned, +) +from cora.equipment.features.assign_fixture_persistent_id.command import ( + AssignFixturePersistentId, +) + + +def decide( + state: Fixture | None, + command: AssignFixturePersistentId, + *, + persistent_id: PersistentIdentifier, + now: datetime, +) -> list[FixturePersistentIdAssigned]: + """Decide the events produced by assigning a persistent identifier. + + Invariants: + - State must not be None -> FixtureNotFoundError + - state.persistent_id must be None (set-once) -> + FixturePersistentIdAlreadyAssignedError + """ + if state is None: + raise FixtureNotFoundError(command.fixture_id) + + if state.persistent_id is not None: + raise FixturePersistentIdAlreadyAssignedError( + state.id, + current=state.persistent_id, + attempted=persistent_id, + ) + + return [ + FixturePersistentIdAssigned( + fixture_id=state.id, + persistent_id_scheme=persistent_id.scheme.value, + persistent_id_value=persistent_id.value, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/handler.py b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/handler.py new file mode 100644 index 0000000000..e8422beccb --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/handler.py @@ -0,0 +1,131 @@ +"""Application handler for the `assign_fixture_persistent_id` slice. + +Server-mint posture per Lock 5 of [[project-fixture-pidinst-design]]: +the route forwards `(fixture_id, scheme, suffix)` to the handler, and +the handler closure resolves the `PersistentIdentifier` from the shared +`DoiMinter` port BEFORE invoking the pure decider. Non-determinism +(the minter call) is captured in the handler closure per +[[project-non-determinism-principle]], NOT at the route layer. One +minter call site (this handler), not two (route + MCP tool). + +Returns the assigned `PersistentIdentifier` so the route layer can echo +it in the 201 response body (Section 6.5 of the design memo): the +operator needs the authority-minted DOI string immediately; reading +back through `GET /fixtures/{id}/pidinst` is a wasteful round-trip and +is subject to projection lag. + +NOT idempotency-wrapped at the CORA layer: set-once at the decider +PLUS DOI-string-as-dedup-key at DataCite (F5.2 PUT /dois/{id} upsert +semantics) covers retry safely without a CORA idempotency_key +(mirrors slice F at the Asset tier). + +Mint failures bubble as `PersistentIdentifierMintError` from the port +(raised by the production adapter); the route layer maps it to HTTP +502 via the standard exception-handler registration in +`equipment/routes.py` (shared with the Asset-tier mint flow per Lock 5; +one mapping serves both callers). + +Per Section 6.4 of the design memo, this slice is the FIRST Fixture- +stream mutation; per the rule-of-three convention behind +`_asset_update_handler.py` (hoisted only AFTER multiple byte-identical +longhand handlers existed), this slice ships the longhand call to +`make_update_handler` directly. A future second Fixture-stream +mutation owns hoisting the per-aggregate factory. +""" + +from collections.abc import Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Protocol, cast +from uuid import UUID + +from cora.equipment.aggregates.asset import PersistentIdentifier +from cora.equipment.aggregates.fixture import ( + Fixture, + FixtureEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.assign_fixture_persistent_id.command import ( + AssignFixturePersistentId, +) +from cora.equipment.features.assign_fixture_persistent_id.decider import decide +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.infrastructure.update_handler import make_update_handler + +if TYPE_CHECKING: + from cora.equipment.ports.doi_minter import DoiMinter + + +class Handler(Protocol): + """Callable interface every assign_fixture_persistent_id handler implements.""" + + async def __call__( + self, + command: AssignFixturePersistentId, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> PersistentIdentifier: ... + + +def bind(deps: Kernel) -> Handler: + """Build an assign_fixture_persistent_id handler closed over the shared deps. + + Reads the BC-tier `DoiMinter` from `deps.equipment.doi_minter` + (shared with the Asset-tier slice per Lock 5; same SimpleNamespace + stash wired by `wire_equipment(deps)`). Calls `minter.mint(scheme, + suffix)` to resolve the `PersistentIdentifier`, then runs the pure + decider through the cross-BC `make_update_handler` factory directly + (no per-aggregate Fixture factory ships in this slice per Section 6.4). + Returns the assigned `PersistentIdentifier` so the route can echo + it in the 201 body. + """ + minter = cast("DoiMinter", deps.equipment.doi_minter) # type: ignore[attr-defined] + + async def handler( + command: AssignFixturePersistentId, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> PersistentIdentifier: + persistent_id = await minter.mint(scheme=command.scheme, suffix=command.suffix) + + def decide_with_resolved( + *, + state: Fixture | None, + command: AssignFixturePersistentId, + now: datetime, + ) -> Sequence[FixtureEvent]: + return decide(state, command, persistent_id=persistent_id, now=now) + + inner = make_update_handler( + deps, + stream_type="Fixture", + target_id_attr="fixture_id", + from_stored=from_stored, + to_payload=to_payload, + event_type_name=event_type_name, + fold=fold, + unauthorized_error=UnauthorizedError, + command_name="AssignFixturePersistentId", + log_prefix="assign_fixture_persistent_id", + decide_fn=decide_with_resolved, + ) + await inner( + command, + principal_id=principal_id, + correlation_id=correlation_id, + causation_id=causation_id, + surface_id=surface_id, + ) + return persistent_id + + return handler diff --git a/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/route.py b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/route.py new file mode 100644 index 0000000000..bb9133d9f5 --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/route.py @@ -0,0 +1,116 @@ +"""HTTP route for the `assign_fixture_persistent_id` slice. + +Action endpoint at `POST /fixtures/{fixture_id}/assign-persistent-identifier`. +Thin wire layer: forwards `(fixture_id, scheme, suffix)` to the handler, +which resolves the `DoiMinter` call and runs the pure decider. The +route itself does NOT depend on the `DoiMinter` port (server-mint +posture per Lock 5 of [[project-fixture-pidinst-design]] keeps +non-determinism in the handler closure only). + +201 Created on success with `AssignFixturePersistentIdResponse(scheme, +value)` in the body so the operator learns the server-minted +identifier without a follow-up GET (Section 6.5 deviation from the +empty-201 convention for Fixture mutations). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status + +from cora.equipment._fixture_persistent_identifier_body import ( + AssignFixturePersistentIdRequest, + AssignFixturePersistentIdResponse, +) +from cora.equipment.features.assign_fixture_persistent_id.command import ( + AssignFixturePersistentId, +) +from cora.equipment.features.assign_fixture_persistent_id.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.assign_fixture_persistent_id + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/fixtures/{fixture_id}/assign-persistent-identifier", + status_code=status.HTTP_201_CREATED, + response_model=AssignFixturePersistentIdResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "PersistentIdentifier VO validation failed: empty or " + "whitespace-only value, or value over the max-length " + "bound (InvalidPersistentIdentifierValueError)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize policy denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No fixture exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Fixture cannot accept the persistent identifier: the " + "fixture already carries a persistent_id (set-once: " + "FixturePersistentIdAlreadyAssignedError), OR a " + "concurrent write to the same fixture stream conflicted " + "(optimistic concurrency)." + ), + }, + status.HTTP_502_BAD_GATEWAY: { + "model": ErrorResponse, + "description": ( + "The external mint authority (DataCite or Handle.net) " + "failed to assign a persistent identifier " + "(PersistentIdentifierMintError)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Path parameter or request body failed schema " + "validation (missing field, malformed UUID, scheme " + "outside the closed enum, suffix length out of bounds " + "at the wire layer)." + ), + }, + }, + summary="Assign a PIDINST persistent identifier to an existing Fixture", +) +async def post_fixtures_assign_persistent_identifier( + fixture_id: Annotated[UUID, Path(description="Target fixture's id.")], + body: AssignFixturePersistentIdRequest, + 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)], +) -> AssignFixturePersistentIdResponse: + persistent_id = await handler( + AssignFixturePersistentId( + fixture_id=fixture_id, + scheme=body.scheme, + suffix=body.suffix, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) + return AssignFixturePersistentIdResponse( + scheme=persistent_id.scheme.value, + value=persistent_id.value, + ) diff --git a/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/tool.py b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/tool.py new file mode 100644 index 0000000000..1e31621ab5 --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_fixture_persistent_id/tool.py @@ -0,0 +1,56 @@ +"""MCP tool for the `assign_fixture_persistent_id` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import Field + +from cora.equipment.aggregates.asset import PersistentIdentifierScheme +from cora.equipment.features.assign_fixture_persistent_id.command import ( + AssignFixturePersistentId, +) +from cora.equipment.features.assign_fixture_persistent_id.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `assign_fixture_persistent_id` tool on the given MCP server.""" + + @mcp.tool( + name="assign_fixture_persistent_id", + description=( + "Assign a persistent identifier (PIDINST v1.0 Property 1, " + "DOI or Handle) to an existing Fixture. Set-once: rejects " + "when the Fixture already carries a persistent_id. Calls " + "DataCite (or the configured DoiMinter adapter) to mint " + "the identifier server-side; returns the assigned " + "(scheme, value) pair." + ), + ) + async def assign_fixture_persistent_id_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + fixture_id: Annotated[ + UUID, + Field(description="Target fixture's id."), + ], + scheme: Annotated[ + PersistentIdentifierScheme, + Field(description="Closed PIDINST Property 1 scheme: DOI or HANDLE."), + ], + suffix: Annotated[ + str | None, + Field(description="Optional operator-supplied local part."), + ] = None, + ) -> dict[str, str]: + handler = get_handler() + persistent_id = await handler( + AssignFixturePersistentId(fixture_id=fixture_id, scheme=scheme, suffix=suffix), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return {"scheme": persistent_id.scheme.value, "value": persistent_id.value} diff --git a/apps/api/src/cora/equipment/projections/fixture_summary.py b/apps/api/src/cora/equipment/projections/fixture_summary.py index 8c8b6488ed..af8c3c491d 100644 --- a/apps/api/src/cora/equipment/projections/fixture_summary.py +++ b/apps/api/src/cora/equipment/projections/fixture_summary.py @@ -1,13 +1,19 @@ -"""FixtureSummaryProjection: folds the Fixture aggregate's single -genesis event into the `proj_equipment_fixture_summary` read model. +"""FixtureSummaryProjection: folds Fixture stream events into the +`proj_equipment_fixture_summary` read model. Subscribed events (v1): - - FixtureRegistered -> INSERT (snapshot of assembly_content_hash, - surface_id, plus binding_count and - override_count for cheap summary reads). + - FixtureRegistered -> INSERT (snapshot of assembly_content_hash, + surface_id, plus binding_count and + override_count for cheap summary reads). + - FixturePersistentIdAssigned -> UPDATE persistent_id JSONB with + {scheme, value} for the assigned + PIDINST v1.0 Property 1 identifier + (Fixture-tier PIDINST integration). -Single-event genesis: idempotent via ON CONFLICT DO NOTHING. The -full slot_asset_bindings stays in the event payload; this read +Genesis is single-event (FixtureRegistered, idempotent via ON CONFLICT +DO NOTHING). Subsequent mutations stay append-only-monotonic; the +PIDINST assign is the first Fixture-stream mutation past genesis. +The full slot_asset_bindings stays in the event payload; this read model is summary-only by design. """ @@ -29,11 +35,27 @@ """ +_UPDATE_FIXTURE_PERSISTENT_ID_ASSIGNED_SQL = """ +UPDATE proj_equipment_fixture_summary +SET persistent_id = jsonb_build_object( + 'scheme', $2::text, + 'value', $3::text + ), + updated_at = now() +WHERE fixture_id = $1 +""" + + class FixtureSummaryProjection: """Maintains the `proj_equipment_fixture_summary` read model.""" name = "proj_equipment_fixture_summary" - subscribed_event_types = frozenset({"FixtureRegistered"}) + subscribed_event_types = frozenset( + { + "FixtureRegistered", + "FixturePersistentIdAssigned", + } + ) async def apply( self, @@ -55,6 +77,13 @@ async def apply( len(overrides), datetime.fromisoformat(str(payload["occurred_at"])), ) + case "FixturePersistentIdAssigned": + await conn.execute( + _UPDATE_FIXTURE_PERSISTENT_ID_ASSIGNED_SQL, + UUID(str(event.payload["fixture_id"])), + event.payload["persistent_id_scheme"], + event.payload["persistent_id_value"], + ) case _: pass diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 68e476348f..592e0c6f35 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -109,7 +109,12 @@ InvalidFamilySettingsSchemaError, InvalidFamilyVersionTagError, ) -from cora.equipment.aggregates.fixture import FixtureAlreadyExistsError, FixtureNotFoundError +from cora.equipment.aggregates.fixture import ( + FixtureAlreadyExistsError, + FixtureNotFoundError, + FixturePersistentIdAlreadyAssignedError, + MalformedFixturePersistentIdentifierError, +) from cora.equipment.aggregates.frame import ( FrameAlreadyExistsError, FrameCannotDecommissionError, @@ -174,6 +179,7 @@ add_asset_port, add_model_family, assign_asset_persistent_id, + assign_fixture_persistent_id, attach_asset_to_fixture, decommission_asset, decommission_frame, @@ -323,13 +329,15 @@ async def _handle_persistent_identifier_mint_error( async def _handle_malformed_stored_event(request: Request, exc: Exception) -> JSONResponse: """500 handler for malformed-stored-event deserialization escapes. - Maps `MalformedPersistentIdentifierError`: a stored - `AssetPersistentIdAssigned` payload could not be reconstructed - because the `persistent_id_value` is empty or non-string. The - `from_stored` wrap convention normally re-raises as `ValueError` - via `deserialize_or_raise`, so this handler is defense-in-depth - for the unwrapped path. 500 because this signals a data-integrity - bug in the event store, not a client error. + Maps `MalformedPersistentIdentifierError` (Asset tier) and + `MalformedFixturePersistentIdentifierError` (Fixture tier): a + stored `AssetPersistentIdAssigned` or `FixturePersistentIdAssigned` + payload could not be reconstructed because the + `persistent_id_value` is empty or non-string. The `from_stored` + wrap convention normally re-raises as `ValueError` via + `deserialize_or_raise`, so this handler is defense-in-depth for + the unwrapped path. 500 because this signals a data-integrity bug + in the event store, not a client error. """ _ = request return JSONResponse( @@ -426,6 +434,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(register_fixture.router) app.include_router(attach_asset_to_fixture.router) app.include_router(detach_asset_from_fixture.router) + app.include_router(assign_fixture_persistent_id.router) app.include_router(get_fixture.router) app.include_router(get_fixture_pidinst.router) app.include_router(list_fixtures.router) @@ -516,6 +525,7 @@ def register_equipment_routes(app: FastAPI) -> None: AssetCannotAddOwnerError, AssetPersistentIdAlreadyAssignedError, AssetPersistentIdAssignmentForbiddenError, + FixturePersistentIdAlreadyAssignedError, AssetModelMismatchError, FamilyCannotVersionError, FamilyCannotDeprecateError, @@ -567,4 +577,7 @@ def register_equipment_routes(app: FastAPI) -> None: PersistentIdentifierMintError, _handle_persistent_identifier_mint_error ) app.add_exception_handler(MalformedPersistentIdentifierError, _handle_malformed_stored_event) + app.add_exception_handler( + MalformedFixturePersistentIdentifierError, _handle_malformed_stored_event + ) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index 491b4b622c..2d38a762e3 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -24,6 +24,9 @@ from cora.equipment.features.assign_asset_persistent_id import ( tool as assign_asset_persistent_id_tool, ) +from cora.equipment.features.assign_fixture_persistent_id import ( + tool as assign_fixture_persistent_id_tool, +) from cora.equipment.features.attach_asset_to_fixture import tool as attach_asset_to_fixture_tool from cora.equipment.features.decommission_asset import tool as decommission_asset_tool from cora.equipment.features.decommission_frame import tool as decommission_frame_tool @@ -295,6 +298,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().detach_asset_from_fixture, ) + assign_fixture_persistent_id_tool.register( + mcp, + get_handler=lambda: get_handlers().assign_fixture_persistent_id, + ) get_fixture_tool.register( mcp, get_handler=lambda: get_handlers().get_fixture, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 63ae1e614f..313ce8d8da 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -42,6 +42,7 @@ add_asset_port, add_model_family, assign_asset_persistent_id, + assign_fixture_persistent_id, attach_asset_to_fixture, decommission_asset, decommission_frame, @@ -176,6 +177,7 @@ class EquipmentHandlers: register_fixture: register_fixture.IdempotentHandler attach_asset_to_fixture: attach_asset_to_fixture.Handler detach_asset_from_fixture: detach_asset_from_fixture.Handler + assign_fixture_persistent_id: assign_fixture_persistent_id.Handler get_fixture: get_fixture.Handler get_fixture_pidinst: get_fixture_pidinst.Handler list_fixtures: list_fixtures.Handler @@ -526,6 +528,11 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DetachAssetFromFixture", bc=_BC, ), + assign_fixture_persistent_id=with_tracing( + assign_fixture_persistent_id.bind(deps), + command_name="AssignFixturePersistentId", + bc=_BC, + ), get_fixture=with_tracing( get_fixture.bind(deps), command_name="GetFixture", diff --git a/apps/api/tests/contract/test_assign_fixture_persistent_id_openapi.py b/apps/api/tests/contract/test_assign_fixture_persistent_id_openapi.py new file mode 100644 index 0000000000..b6bb172310 --- /dev/null +++ b/apps/api/tests/contract/test_assign_fixture_persistent_id_openapi.py @@ -0,0 +1,79 @@ +"""OpenAPI surface pins for `POST /fixtures/{fixture_id}/assign-persistent-identifier`. + +Per Section 15.3 of project_fixture_pidinst_design: pin the OpenAPI +schema for the Fixture-tier assign-persistent-identifier endpoint. +Mirrors the surface-pin posture of +`test_assign_asset_persistent_id_openapi.py`. Guards Lock 6 (route +path `POST /fixtures/{fixture_id}/assign-persistent-identifier`), +Lock 17 (response echoes scheme + value), Lock 12 (server-mint posture: +request carries scheme + optional suffix, never `value`), and the +Section 13 status-code map (201 success; 400/403/404/409/502 domain ++ authorization failures; 422 wire-layer validation failures). +""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +pytestmark = pytest.mark.timeout(60, method="thread") + +_ROUTE_PATH = "/fixtures/{fixture_id}/assign-persistent-identifier" + + +@pytest.mark.contract +def test_openapi_has_assign_fixture_persistent_identifier_endpoint() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + assert _ROUTE_PATH in openapi["paths"], ( + f"OpenAPI must expose {_ROUTE_PATH} for the assign_fixture_persistent_id slice" + ) + assert "post" in openapi["paths"][_ROUTE_PATH], f"{_ROUTE_PATH} must register a POST operation" + + +@pytest.mark.contract +def test_openapi_request_body_exposes_scheme_and_suffix_only() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + request_component = openapi["components"]["schemas"]["AssignFixturePersistentIdRequest"] + properties = request_component["properties"] + assert "scheme" in properties, "request body must expose scheme" + assert "suffix" in properties, "request body must expose suffix" + assert "scheme" in request_component["required"], "scheme is REQUIRED at the wire" + assert "suffix" not in request_component.get("required", []), ( + "suffix is OPTIONAL per Lock 22 (auto-generated when absent)" + ) + assert "value" not in properties, ( + "request body MUST NOT carry a value field per Lock 12 server-mint posture" + ) + + +@pytest.mark.contract +def test_openapi_response_body_echoes_scheme_and_value() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + response_component = openapi["components"]["schemas"]["AssignFixturePersistentIdResponse"] + properties = response_component["properties"] + assert "scheme" in properties, "response body must echo the assigned scheme" + assert "value" in properties, "response body must echo the assigned value" + required = response_component["required"] + assert "scheme" in required, "response scheme is REQUIRED (always present)" + assert "value" in required, "response value is REQUIRED (always present)" + + +@pytest.mark.contract +def test_openapi_documents_201_400_403_404_409_502_422_responses() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + operation = openapi["paths"][_ROUTE_PATH]["post"] + responses = operation["responses"] + expected = {"201", "400", "403", "404", "409", "502", "422"} + missing = expected - set(responses.keys()) + assert not missing, ( + "OpenAPI must document all status codes from Section 13 / Lock 19;" + f" missing: {sorted(missing)}" + ) diff --git a/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_route.py b/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_route.py new file mode 100644 index 0000000000..e02733b829 --- /dev/null +++ b/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_route.py @@ -0,0 +1,331 @@ +"""HTTP route tests for `POST /fixtures/{fixture_id}/assign-persistent-identifier`. + +Full status-code matrix per [[project-fixture-pidinst-design]] Section +15.2 + Locks L17 / L18 / L19: + + - 201 happy paths (DOI suffix, DOI auto-suffix, Handle suffix) + - 201 response body echoes (scheme, value) verbatim + - 404 when the fixture stream is missing + - 409 when the fixture already carries a persistent_id (set-once) + - 422 wire-layer validation (empty suffix, missing scheme) + - 502 when the upstream DoiMinter raises PersistentIdentifierMintError + (RaisingDoiMinter fixture from conftest, swapped onto the bound + handler via dataclasses.replace per slice F Lock 10) + - Event-store persistence pin (the FixturePersistentIdAssigned event + lands on the Fixture stream) + - Projection-shape lock (the persisted event payload carries the + keys the FixtureSummaryProjection consumes; projection worker is + not booted in test mode per the Asset-tier sibling precedent) + +Sibling pattern to `test_assign_asset_persistent_id_route.py`; reuses +the conftest `raising_doi_minter` fixture introduced for the Asset +slice F 502 path. + +In-memory adapters: `APP_ENV=test` is set in `tests/conftest.py`, so +`create_app()` builds the kernel with `InMemoryEventStore` and the +Equipment BC wires `StubDoiMinter` by default. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingTypeStubs=false + +import asyncio +from dataclasses import replace +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.features import assign_fixture_persistent_id +from tests.integration.equipment.conftest import RaisingDoiMinter + +if TYPE_CHECKING: + from cora.equipment.wire import EquipmentHandlers + +pytestmark = pytest.mark.timeout(60, method="thread") + +_STUB_DOI_PREFIX = "10.0000/cora-stub" +_STUB_HANDLE_PREFIX = "20.500.0000/cora-stub" + + +def _define_family(client: TestClient, *, name: str = "Camera") -> str: + response = client.post( + "/families", + json={"name": name, "affordances": []}, + ) + assert response.status_code == 201, response.text + family_id: str = response.json()["family_id"] + return family_id + + +def _register_asset(client: TestClient, family_id: str, *, name: str = "Camera-1") -> str: + response = client.post( + "/assets", + json={"name": name, "level": "Device", "parent_id": str(uuid4())}, + ) + assert response.status_code == 201, response.text + asset_id: str = response.json()["asset_id"] + add_family = client.post( + f"/assets/{asset_id}/add-family", + json={"family_id": family_id}, + ) + assert add_family.status_code == 204, add_family.text + return asset_id + + +def _define_assembly(client: TestClient, family_id: str, *, name: str = "MCTOptics") -> str: + body = { + "name": name, + "presents_as_family_id": family_id, + "required_slots": [ + { + "slot_name": "camera", + "required_family_ids": [family_id], + "cardinality": "Exactly1", + } + ], + "required_wires": [], + } + response = client.post("/assemblies", json=body) + assert response.status_code == 201, response.text + assembly_id: str = response.json()["assembly_id"] + return assembly_id + + +def _register_fixture(client: TestClient) -> str: + """Seed Family + Asset + Assembly through the HTTP routes, return fixture_id.""" + family_id = _define_family(client) + asset_id = _register_asset(client, family_id) + assembly_id = _define_assembly(client, family_id) + response = client.post( + f"/assemblies/{assembly_id}/fixtures", + json={ + "slot_asset_bindings": [ + {"slot_name": "camera", "asset_id": asset_id}, + ], + "parameter_overrides": {}, + }, + ) + assert response.status_code == 201, response.text + fixture_id: str = response.json()["fixture_id"] + return fixture_id + + +def _swap_doi_minter(app: FastAPI, minter: object) -> None: + """Rebuild the assign_fixture_persistent_id handler over a swapped minter. + + The handler closes over `deps.equipment.doi_minter` at bind time, so + mutating the SimpleNamespace alone is not enough: the live handler + on `app.state.equipment.assign_fixture_persistent_id` was bound + before the swap. We mutate the BC-local namespace AND rebind the + handler, then drop a fresh `EquipmentHandlers` onto + `app.state.equipment` so the route's + `request.app.state.equipment.assign_fixture_persistent_id` resolves + to the rebound closure. + """ + deps = app.state.deps + object.__setattr__(deps.equipment, "doi_minter", minter) + rebound = assign_fixture_persistent_id.bind(deps) + handlers: EquipmentHandlers = app.state.equipment + app.state.equipment = replace(handlers, assign_fixture_persistent_id=rebound, doi_minter=minter) + + +@pytest.mark.integration +def test_post_assign_fixture_pid_with_doi_scheme_and_suffix_returns_201_and_echoes_value() -> None: + with TestClient(create_app()) as client: + fixture_id = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "APS-2BM-FIX-001"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["scheme"] == "DOI" + assert body["value"] == f"{_STUB_DOI_PREFIX}/APS-2BM-FIX-001" + + +@pytest.mark.integration +def test_post_assign_fixture_pid_with_doi_scheme_and_no_suffix_uses_stub_uuid_suffix() -> None: + with TestClient(create_app()) as client: + fixture_id = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "DOI"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["scheme"] == "DOI" + assert body["value"].startswith(f"{_STUB_DOI_PREFIX}/") + suffix = body["value"].removeprefix(f"{_STUB_DOI_PREFIX}/") + assert len(suffix) == 36 + + +@pytest.mark.integration +def test_post_assign_fixture_pid_with_handle_scheme_returns_201_with_handle_test_prefix() -> None: + with TestClient(create_app()) as client: + fixture_id = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "Handle", "suffix": "12345"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["scheme"] == "Handle" + assert body["value"] == f"{_STUB_HANDLE_PREFIX}/12345" + + +@pytest.mark.integration +def test_post_assign_fixture_pid_endpoint_201_response_body_echoes_scheme_and_value_exactly() -> ( + None +): + """201 body equals {"scheme": ..., "value": ...} byte-for-byte. + + Catches the regression class where the handler returns the right VO + but the route's `AssignFixturePersistentIdResponse(...)` drops or + renames a field. Complements the contract-tier OpenAPI shape test. + """ + with TestClient(create_app()) as client: + fixture_id = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "FIX-EXACT-ECHO"}, + ) + assert response.status_code == 201, response.text + assert response.json() == { + "scheme": "DOI", + "value": f"{_STUB_DOI_PREFIX}/FIX-EXACT-ECHO", + } + + +@pytest.mark.integration +def test_post_assign_fixture_persistent_id_with_unknown_fixture_returns_404() -> None: + missing = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/fixtures/{missing}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "X"}, + ) + assert response.status_code == 404 + assert missing in response.json()["detail"] + + +@pytest.mark.integration +def test_post_assign_fixture_persistent_id_with_already_assigned_fixture_returns_409() -> None: + with TestClient(create_app()) as client: + fixture_id = _register_fixture(client) + first = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "FIRST"}, + ) + assert first.status_code == 201, first.text + second = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "SECOND"}, + ) + assert second.status_code == 409 + body = second.json() + assert "detail" in body + assert "FIRST" in body["detail"] or "SECOND" in body["detail"] + + +@pytest.mark.integration +def test_post_assign_fixture_persistent_id_with_empty_suffix_returns_422() -> None: + with TestClient(create_app()) as client: + fixture_id = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": ""}, + ) + assert response.status_code == 422 + + +@pytest.mark.integration +def test_post_assign_fixture_persistent_id_with_missing_scheme_returns_422() -> None: + with TestClient(create_app()) as client: + fixture_id = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"suffix": "X"}, + ) + assert response.status_code == 422 + + +@pytest.mark.integration +def test_post_assign_fixture_persistent_id_persists_event_to_event_store() -> None: + """The FixturePersistentIdAssigned event lands on the Fixture stream.""" + app = create_app() + with TestClient(app) as client: + fixture_id_str = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id_str}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "PERSIST"}, + ) + assert response.status_code == 201, response.text + fixture_id = UUID(fixture_id_str) + events, _ = asyncio.run(app.state.deps.event_store.load("Fixture", fixture_id)) + event_types = [event.event_type for event in events] + assert "FixturePersistentIdAssigned" in event_types + assigned = next(event for event in events if event.event_type == "FixturePersistentIdAssigned") + assert assigned.payload["persistent_id_scheme"] == "DOI" + assert assigned.payload["persistent_id_value"] == f"{_STUB_DOI_PREFIX}/PERSIST" + + +@pytest.mark.integration +def test_post_assign_fixture_persistent_id_writes_persistent_id_to_projection_after_replay() -> ( + None +): + """Pin the integration contract: persisted event shape feeds the projection. + + In test mode the projection worker does not run (no Postgres), so + the projection's behavior is verified at the unit tier. This + route-tier test asserts the event the route persists is SHAPED + CORRECTLY for `FixtureSummaryProjection` to consume (scheme + value + primitives, fixture_id matching the stream). Combined with the + unit projection test, the end-to-end replay path is covered. + """ + app = create_app() + with TestClient(app) as client: + fixture_id_str = _register_fixture(client) + response = client.post( + f"/fixtures/{fixture_id_str}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "REPLAY"}, + ) + assert response.status_code == 201, response.text + fixture_id = UUID(fixture_id_str) + events, _ = asyncio.run(app.state.deps.event_store.load("Fixture", fixture_id)) + assigned = next(event for event in events if event.event_type == "FixturePersistentIdAssigned") + assert assigned.payload["fixture_id"] == fixture_id_str + assert set(assigned.payload.keys()) >= { + "fixture_id", + "persistent_id_scheme", + "persistent_id_value", + "occurred_at", + } + + +@pytest.mark.integration +def test_post_assign_fixture_persistent_id_with_raising_minter_returns_502( + raising_doi_minter: RaisingDoiMinter, +) -> None: + """Override the bound minter with RaisingDoiMinter and assert 502. + + Verifies the L19 mapping wires correctly: a + `PersistentIdentifierMintError` raised by the upstream port surfaces + as HTTP 502 with a `{"detail": ...}` body per L18 BC-uniform shape. + The 502 exception handler is shared between Asset- and Fixture-tier + mint flows per Lock 5 (one mapping serves both callers). + """ + app = create_app() + with TestClient(app) as client: + fixture_id = _register_fixture(client) + _swap_doi_minter(app, raising_doi_minter) + response = client.post( + f"/fixtures/{fixture_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "WILL-FAIL"}, + ) + assert response.status_code == 502 + body = response.json() + assert "detail" in body + assert "upstream stub failure" in body["detail"] diff --git a/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_tool.py b/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_tool.py new file mode 100644 index 0000000000..94219c43c4 --- /dev/null +++ b/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_tool.py @@ -0,0 +1,403 @@ +"""Integration tests for the `assign_fixture_persistent_id` MCP tool against real Postgres. + +Registers the slice's tool on a fresh FastMCP server bound to a +Postgres-backed handler closure, then exercises the registered tool +function directly so the test does not need to stand up a streamable- +http request context. The integration tier owns the end-to-end chain: +MCP tool input parsing through the slice handler through the DoiMinter +port through the Postgres event store. The happy-path and domain- +error mappings are covered here; the wire-protocol envelope (SSE / +JSON-RPC) is the contract-tier suite's concern. + +Mirrors the Asset slice F `test_assign_asset_persistent_id_tool.py` +MCP registration / invocation pattern. Reuses the Fixture seeding +recipe from `test_get_fixture_pidinst_tool.py` (define_family + +define_model + register_asset + add_asset_family + add_asset_owner + +define_assembly + register_fixture). Reuses the `raising_doi_minter` +fixture from `tests/integration/equipment/conftest.py` for the +upstream mint-failure path. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg +import pytest +from mcp.server.fastmcp import FastMCP + +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter +from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot +from cora.equipment.aggregates.asset import ( + AssetLevel, + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.fixture import ( + FixtureNotFoundError, + FixturePersistentIdAlreadyAssignedError, + SlotAssetBinding, +) +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_asset_family, + add_asset_owner, + assign_fixture_persistent_id, + define_assembly, + define_family, + define_model, + register_asset, + register_fixture, +) +from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.add_asset_owner import AddAssetOwner +from cora.equipment.features.assign_fixture_persistent_id import AssignFixturePersistentId +from cora.equipment.features.assign_fixture_persistent_id.handler import Handler +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.register_fixture import RegisterFixture +from cora.equipment.ports.doi_minter import PersistentIdentifierMintError +from cora.infrastructure.kernel import Kernel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps +from tests.integration.equipment.conftest import RaisingDoiMinter + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-0000ee010000") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _build_deps( + db_pool: asyncpg.Pool, + *, + ids: list[UUID] | None = None, + now: datetime = _NOW, +) -> Kernel: + return build_postgres_deps(db_pool, ids=ids or [], now=now) + + +def _attach_doi_minter(deps: Kernel, minter: object) -> Kernel: + """Replicate `wire_equipment`'s BC-local namespace stamp. + + The slice handler reads `deps.equipment.doi_minter`; the + integration helper does not run `wire_equipment`, so the test + stamps the namespace directly to keep the handler bind path + Postgres-only without dragging the full Equipment handler bundle + into the test. + """ + from types import SimpleNamespace + + object.__setattr__(deps, "equipment", SimpleNamespace(doi_minter=minter)) + return deps + + +class _StubMcpContext: + """Minimal FastMCP-Context stand-in for outside-request invocation. + + The slice tool's body calls `get_mcp_principal_id(ctx)`, which + walks `ctx.request_context.request`; raising AttributeError on + the descriptor makes the helper fall through to + `SYSTEM_PRINCIPAL_ID`, matching the stdio-transport behavior the + helper documents. + """ + + @property + def request_context(self) -> Any: + raise AttributeError("no request context outside streamable-http") + + +def _registered_tool_fn(handler: Handler) -> Any: + mcp = FastMCP("assign-fixture-persistent-id-integration") + assign_fixture_persistent_id.tool.register(mcp, get_handler=lambda: handler) + tools = mcp._tool_manager._tools # pyright: ignore[reportPrivateUsage] + return tools["assign_fixture_persistent_id"].fn + + +def _hzb_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@hzb.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aerotech_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/04bw7nh07"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +async def _seed_fixture(db_pool: asyncpg.Pool) -> UUID: + """Seed a Fixture with one bound Asset carrying owner + model. + + Mirrors `test_get_fixture_pidinst_tool.py::_seed_minted_fixture` + so the Fixture is in a shape the read-side serializer can publish, + even though this suite only exercises the write-side assign tool. + """ + family_deps = _build_deps(db_pool, ids=[uuid4(), uuid4()]) + family_id = await define_family.bind(family_deps)( + DefineFamily(name="Camera", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + deps = _build_deps( + db_pool, + ids=[uuid4() for _ in range(20)], + ) + model_id = await define_model.bind(deps)( + DefineModel( + name="ANT130-L", + manufacturer=_aerotech_manufacturer(), + part_number="ANT130-L-RM", + declared_family_ids=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + asset_id = await register_asset.bind(deps)( + RegisterAsset( + name="Camera-1", + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_owner.bind(deps)( + AddAssetOwner(asset_id=asset_id, owner=_hzb_owner()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assembly_id = await define_assembly.bind(deps)( + DefineAssembly( + name="MCTOptics", + presents_as_family_id=family_id, + required_slots=frozenset( + { + TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + fixture_id = await register_fixture.bind(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)} + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return fixture_id + + +@pytest.mark.integration +async def test_assign_fixture_persistent_id_tool_with_doi_scheme_returns_scheme_and_value( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture(db_pool) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="APS-2BM-FIX-001", + ) + assert body == {"scheme": "DOI", "value": "10.0000/cora-stub/APS-2BM-FIX-001"} + + +@pytest.mark.integration +async def test_assign_fixture_persistent_id_tool_with_handle_scheme_returns_handle_prefix( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture(db_pool) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.HANDLE, + suffix="HZB-fixture-001", + ) + assert body == { + "scheme": "Handle", + "value": "20.500.0000/cora-stub/HZB-fixture-001", + } + + +@pytest.mark.integration +async def test_assign_fixture_persistent_id_tool_with_no_suffix_creates_uuid_suffix( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture(db_pool) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + ) + assert body["scheme"] == "DOI" + assert body["value"].startswith("10.0000/cora-stub/") + # UUID4 string is 36 chars after the trailing slash + assert len(body["value"].split("/")[-1]) == 36 + + +@pytest.mark.integration +async def test_assign_fixture_persistent_id_tool_with_unknown_fixture_raises_not_found( + db_pool: asyncpg.Pool, +) -> None: + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(handler_deps)) + + with pytest.raises(FixtureNotFoundError): + await tool_fn( + _StubMcpContext(), + fixture_id=uuid4(), + scheme=PersistentIdentifierScheme.DOI, + suffix="ghost-fixture", + ) + + +@pytest.mark.integration +async def test_assign_fixture_pid_tool_on_already_assigned_fixture_raises_already_assigned( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture(db_pool) + first_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + first_handler = assign_fixture_persistent_id.bind(first_deps) + await first_handler( + AssignFixturePersistentId( + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="first-mint", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + second_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(second_deps)) + with pytest.raises(FixturePersistentIdAlreadyAssignedError): + await tool_fn( + _StubMcpContext(), + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="second-mint", + ) + + +@pytest.mark.integration +async def test_assign_fixture_persistent_id_tool_with_raising_minter_raises_mint_error( + db_pool: asyncpg.Pool, + raising_doi_minter: RaisingDoiMinter, +) -> None: + fixture_id = await _seed_fixture(db_pool) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), raising_doi_minter) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(handler_deps)) + + with pytest.raises(PersistentIdentifierMintError): + await tool_fn( + _StubMcpContext(), + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="upstream-fails", + ) + + +@pytest.mark.integration +async def test_assign_fixture_persistent_id_tool_persists_assigned_event_to_event_store( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture(db_pool) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(handler_deps)) + + await tool_fn( + _StubMcpContext(), + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="persisted-doi", + ) + + async with db_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT event_type, payload FROM events " + "WHERE stream_type = 'Fixture' AND stream_id = $1 " + "ORDER BY version ASC", + fixture_id, + ) + event_types = [row["event_type"] for row in rows] + assert "FixturePersistentIdAssigned" in event_types + import json as _json + + assigned = next( + _json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"] + for row in rows + if row["event_type"] == "FixturePersistentIdAssigned" + ) + assert assigned["persistent_id_scheme"] == "DOI" + assert assigned["persistent_id_value"] == "10.0000/cora-stub/persisted-doi" + + +@pytest.mark.integration +async def test_assign_fixture_persistent_id_tool_returned_value_round_trips_through_state( + db_pool: asyncpg.Pool, +) -> None: + from cora.equipment.aggregates.fixture import load_fixture + + fixture_id = await _seed_fixture(db_pool) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_fixture_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="round-trip", + ) + + state = await load_fixture(handler_deps.event_store, fixture_id) + assert state is not None + assert state.persistent_id == PersistentIdentifier( + scheme=PersistentIdentifierScheme(body["scheme"]), + value=body["value"], + ) diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_after_assign.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_after_assign.py new file mode 100644 index 0000000000..142d308bff --- /dev/null +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_after_assign.py @@ -0,0 +1,357 @@ +"""Closure-proof integration suite: get_fixture_pidinst observes Fixture.persistent_id end-to-end. + +Pins the URN-fallback to DOI / Handle swap (Section 15.2 + Lock 16 of +[[project-fixture-pidinst-design]]) that the +`_pidinst_serializer._build_fixture_identifier` extension performs when +`view.persistent_id` is populated. The Fixture stream is mutated by +`assign_fixture_persistent_id` (server-mint via the inert +`StubDoiMinter` wired by `wire_equipment`), then `get_fixture_pidinst` +reads the stream + folds + the route runs `to_fixture_pidinst_record`; +the resulting `PidinstIdentifier` should carry the DOI or Handle +scheme byte-for-byte rather than the URN fallback emitted before any +persistent identifier is assigned. + +The without-assign baseline test pins the URN fallback path on the +same fixture shape, defending the pre-assign serializer contract +against silent regression now that assign wiring is live. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment._pidinst_serializer import to_fixture_pidinst_record +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter +from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot +from cora.equipment.aggregates.asset import ( + AssetLevel, + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_asset_family, + add_asset_owner, + assign_fixture_persistent_id, + define_assembly, + define_family, + define_model, + get_fixture_pidinst, + register_asset, + register_fixture, +) +from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.add_asset_owner import AddAssetOwner +from cora.equipment.features.assign_fixture_persistent_id import AssignFixturePersistentId +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.register_fixture import RegisterFixture +from cora.infrastructure.config import Settings +from cora.infrastructure.kernel import Kernel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2024, 7, 4, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-0000ee010000") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_LANDING_TEMPLATE = "https://cora.example/assets/{asset_id}/landing" +_PUBLISHER = "Argonne National Laboratory" + + +def _override_settings(deps: Kernel, **overrides: object) -> Kernel: + """Construct a sibling Kernel sharing every dep except settings.""" + settings_data = deps.settings.model_dump() + settings_data.update(overrides) + new_settings = Settings(**settings_data) # type: ignore[arg-type] + from dataclasses import replace + + return replace(deps, settings=new_settings) + + +def _build_deps( + db_pool: asyncpg.Pool, + *, + ids: list[UUID], + now: datetime = _NOW, +) -> Kernel: + deps = build_postgres_deps(db_pool, ids=ids, now=now) + deps = _override_settings( + deps, + facility_publisher=_PUBLISHER, + landing_page_template=_LANDING_TEMPLATE, + ) + # The assign_fixture_persistent_id handler reads `deps.equipment.doi_minter`; + # mirror what `wire_equipment` registers when no DataCite credentials are + # present (parity with test_get_fixture_pidinst_handler_postgres.py). + object.__setattr__(deps, "equipment", SimpleNamespace(doi_minter=StubDoiMinter())) + return deps + + +def _hzb_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@hzb.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aerotech_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/04bw7nh07"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +async def _seed_family(db_pool: asyncpg.Pool, *, name: str) -> UUID: + family_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[family_id, define_event_id]) + await define_family.bind(deps)( + DefineFamily(name=name, affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + return family_id + + +async def _seed_model(db_pool: asyncpg.Pool, *, declared_family_ids: frozenset[UUID]) -> UUID: + model_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[model_id, define_event_id]) + return await define_model.bind(deps)( + DefineModel( + name="ANT130-L", + manufacturer=_aerotech_manufacturer(), + part_number="ANT130-L-RM", + declared_family_ids=declared_family_ids, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _add_family_to_asset(db_pool: asyncpg.Pool, *, asset_id: UUID, family_id: UUID) -> None: + event_id = uuid4() + deps = _build_deps(db_pool, ids=[event_id]) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_asset_with_owner_and_model( + db_pool: asyncpg.Pool, + *, + family_id: UUID, + model_id: UUID, + name: str, + owner: AssetOwner, +) -> UUID: + asset_id = uuid4() + register_event_id = uuid4() + deps = _build_deps(db_pool, ids=[asset_id, register_event_id]) + await register_asset.bind(deps)( + RegisterAsset( + name=name, + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _add_family_to_asset(db_pool, asset_id=asset_id, family_id=family_id) + owner_event_id = uuid4() + owner_deps = _build_deps(db_pool, ids=[owner_event_id]) + await add_asset_owner.bind(owner_deps)( + AddAssetOwner(asset_id=asset_id, owner=owner), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return asset_id + + +async def _seed_assembly_one_slot( + db_pool: asyncpg.Pool, *, family_id: UUID, name: str = "MCTOptics" +) -> UUID: + assembly_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[assembly_id, define_event_id]) + return await define_assembly.bind(deps)( + DefineAssembly( + name=name, + presents_as_family_id=family_id, + required_slots=frozenset( + { + TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_fixture( + db_pool: asyncpg.Pool, + *, + assembly_id: UUID, + asset_id: UUID, +) -> UUID: + fixture_id = uuid4() + fixture_event_id = uuid4() + deps = _build_deps(db_pool, ids=[fixture_id, fixture_event_id]) + return await register_fixture.bind(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)} + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_fixture_with_owners_and_model(db_pool: asyncpg.Pool) -> UUID: + family_id = await _seed_family(db_pool, name="Camera") + model_id = await _seed_model(db_pool, declared_family_ids=frozenset({family_id})) + asset_id = await _seed_asset_with_owner_and_model( + db_pool, + family_id=family_id, + model_id=model_id, + name="Camera-A", + owner=_hzb_owner(), + ) + assembly_id = await _seed_assembly_one_slot(db_pool, family_id=family_id) + return await _seed_fixture(db_pool, assembly_id=assembly_id, asset_id=asset_id) + + +async def _assign_fixture_persistent_id( + db_pool: asyncpg.Pool, + *, + fixture_id: UUID, + scheme: PersistentIdentifierScheme, + suffix: str, +) -> None: + event_id = uuid4() + deps = _build_deps(db_pool, ids=[event_id]) + await assign_fixture_persistent_id.bind(deps)( + AssignFixturePersistentId(fixture_id=fixture_id, scheme=scheme, suffix=suffix), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +def _pidinst_handler(deps: Kernel) -> get_fixture_pidinst.Handler: + return get_fixture_pidinst.bind(deps) + + +def _landing_page_url(fixture_id: UUID) -> str: + return f"https://cora.example/fixtures/{fixture_id}/landing" + + +@pytest.mark.integration +async def test_get_fixture_pidinst_after_assign_emits_doi_identifier_not_urn( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture_with_owners_and_model(db_pool) + await _assign_fixture_persistent_id( + db_pool, + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="APS-2BM-FIX-001", + ) + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + view = await handler( + fixture_id, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert view is not None + record = to_fixture_pidinst_record( + view, + landing_page_url=_landing_page_url(fixture_id), + publisher=_PUBLISHER, + ) + assert record.identifier.scheme.value == "DOI" + assert record.identifier.value == "10.0000/cora-stub/APS-2BM-FIX-001" + assert not record.identifier.value.startswith("urn:uuid:") + + +@pytest.mark.integration +async def test_get_fixture_pidinst_without_assign_still_emits_urn_fallback( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture_with_owners_and_model(db_pool) + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + view = await handler( + fixture_id, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert view is not None + assert view.persistent_id is None + record = to_fixture_pidinst_record( + view, + landing_page_url=_landing_page_url(fixture_id), + publisher=_PUBLISHER, + ) + assert record.identifier.scheme.value == "URN" + assert record.identifier.value == f"urn:uuid:{fixture_id}" + + +@pytest.mark.integration +async def test_get_fixture_pidinst_with_handle_scheme_after_assign_emits_handle_identifier( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_fixture_with_owners_and_model(db_pool) + await _assign_fixture_persistent_id( + db_pool, + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.HANDLE, + suffix="12345", + ) + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + view = await handler( + fixture_id, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert view is not None + record = to_fixture_pidinst_record( + view, + landing_page_url=_landing_page_url(fixture_id), + publisher=_PUBLISHER, + ) + assert record.identifier.scheme.value == "Handle" + assert record.identifier.value == "20.500.0000/cora-stub/12345" + assert not record.identifier.value.startswith("urn:uuid:") diff --git a/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_decider.py b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_decider.py new file mode 100644 index 0000000000..75dc712ce0 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_decider.py @@ -0,0 +1,122 @@ +"""Unit tests for the `assign_fixture_persistent_id` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.fixture import ( + Fixture, + FixtureNotFoundError, + FixturePersistentIdAlreadyAssignedError, + FixturePersistentIdAssigned, + SlotAssetBinding, +) +from cora.equipment.features import assign_fixture_persistent_id +from cora.equipment.features.assign_fixture_persistent_id.command import ( + AssignFixturePersistentId, +) + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) + + +def _fixture( + fixture_id: UUID, + *, + persistent_id: PersistentIdentifier | None = None, + bound_asset_ids: frozenset[UUID] = frozenset(), +) -> Fixture: + return Fixture( + id=fixture_id, + assembly_id=uuid4(), + assembly_content_hash="a" * 64, + surface_id=uuid4(), + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name="camera", asset_id=aid) for aid in bound_asset_ids + ), + registered_at=_NOW, + persistent_id=persistent_id, + ) + + +def _doi(value: str = "10.5281/zenodo.7654321") -> PersistentIdentifier: + return PersistentIdentifier(scheme=PersistentIdentifierScheme.DOI, value=value) + + +def _cmd(fixture_id: UUID) -> AssignFixturePersistentId: + return AssignFixturePersistentId( + fixture_id=fixture_id, + scheme=PersistentIdentifierScheme.DOI, + ) + + +def test_decider_with_state_none_raises_fixture_not_found_error() -> None: + target_id = uuid4() + identifier = _doi() + with pytest.raises(FixtureNotFoundError) as exc_info: + assign_fixture_persistent_id.decide( + None, + _cmd(target_id), + persistent_id=identifier, + now=_NOW, + ) + assert exc_info.value.fixture_id == target_id + + +def test_decider_with_persistent_id_set_raises_fixture_persistent_id_already_assigned_error() -> ( + None +): + existing = _doi("10.5281/zenodo.1111111") + attempted = _doi("10.5281/zenodo.2222222") + fixture_id = uuid4() + state = _fixture(fixture_id, persistent_id=existing) + with pytest.raises(FixturePersistentIdAlreadyAssignedError) as exc_info: + assign_fixture_persistent_id.decide( + state, + _cmd(fixture_id), + persistent_id=attempted, + now=_NOW, + ) + assert exc_info.value.fixture_id == fixture_id + assert exc_info.value.current == existing + assert exc_info.value.attempted == attempted + + +def test_decider_with_no_prior_assign_happy_path_emits_one_assigned_event() -> None: + fixture_id = uuid4() + state = _fixture(fixture_id) + identifier = _doi() + events = assign_fixture_persistent_id.decide( + state, + _cmd(fixture_id), + persistent_id=identifier, + now=_NOW, + ) + assert len(events) == 1 + assert isinstance(events[0], FixturePersistentIdAssigned) + + +def test_decider_emitted_event_carries_correct_fixture_id_scheme_value_now() -> None: + fixture_id = uuid4() + state = _fixture(fixture_id) + identifier = _doi("10.5281/zenodo.9876543") + events = assign_fixture_persistent_id.decide( + state, + _cmd(fixture_id), + persistent_id=identifier, + now=_NOW, + ) + assert events == [ + FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme=identifier.scheme.value, + persistent_id_value=identifier.value, + occurred_at=_NOW, + ) + ] diff --git a/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_decider_properties.py b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_decider_properties.py new file mode 100644 index 0000000000..e7351635bb --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_decider_properties.py @@ -0,0 +1,209 @@ +"""Property-based tests for `assign_fixture_persistent_id.decide`. + +Required PBT per the `test_decider_changes_require_paired_pbt` +architecture fitness. Mirrors the sibling +`test_assign_asset_persistent_id_decider_properties.py` shape: +Hypothesis strategies generate `(scheme, value)` pairs spanning the +full closed `PersistentIdentifierScheme` enum, plus prior-state +variants (absent vs already-assigned). Fixture has no lifecycle FSM +today, so there is no lifecycle-forbidden property here. + +Properties asserted (per memo section 15.2): + - assign_with_valid_inputs_emits_one_event: purity + single-event + invariant on the happy path + - assign_with_state_persistent_id_set_always_raises_already_assigned: + set-once per Section 2.3 + - emitted_event_scheme_and_value_match_resolved_persistent_id: + event-shape invariant per L11 + - decider_deterministic_given_state_and_args: purity (no clock, + no minter) +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.equipment.aggregates.asset import ( + PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.fixture import ( + Fixture, + FixturePersistentIdAlreadyAssignedError, + FixturePersistentIdAssigned, +) +from cora.equipment.features import assign_fixture_persistent_id +from cora.equipment.features.assign_fixture_persistent_id.command import ( + AssignFixturePersistentId, +) + +if TYPE_CHECKING: + from uuid import UUID + +pytestmark = pytest.mark.timeout(60, method="thread") + +_SCHEME = st.sampled_from(list(PersistentIdentifierScheme)) +_VALID_VALUE = st.text( + alphabet=st.characters(min_codepoint=0x21, max_codepoint=0x7E), + min_size=1, + max_size=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, +) +_DT_BASE = datetime(2026, 6, 5, 0, 0, 0, tzinfo=UTC) + + +@st.composite +def _persistent_identifier(draw: st.DrawFn) -> PersistentIdentifier: + return PersistentIdentifier(scheme=draw(_SCHEME), value=draw(_VALID_VALUE)) + + +def _fixture( + fixture_id: UUID, + *, + persistent_id: PersistentIdentifier | None, +) -> Fixture: + return Fixture( + id=fixture_id, + assembly_id=uuid4(), + assembly_content_hash="a" * 64, + surface_id=uuid4(), + registered_at=_DT_BASE, + persistent_id=persistent_id, + ) + + +@pytest.mark.unit +@given( + fixture_id=st.uuids(), + persistent_id=_persistent_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_assign_with_valid_inputs_emits_one_event( + fixture_id: UUID, + persistent_id: PersistentIdentifier, + seconds_offset: int, +) -> None: + state = _fixture(fixture_id, persistent_id=None) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignFixturePersistentId( + fixture_id=fixture_id, + scheme=persistent_id.scheme, + ) + events = assign_fixture_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + assert events == [ + FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme=persistent_id.scheme.value, + persistent_id_value=persistent_id.value, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + fixture_id=st.uuids(), + current=_persistent_identifier(), + attempted=_persistent_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_assign_with_state_persistent_id_set_always_raises_already_assigned( + fixture_id: UUID, + current: PersistentIdentifier, + attempted: PersistentIdentifier, + seconds_offset: int, +) -> None: + state = _fixture(fixture_id, persistent_id=current) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignFixturePersistentId( + fixture_id=fixture_id, + scheme=attempted.scheme, + ) + with pytest.raises(FixturePersistentIdAlreadyAssignedError) as exc: + assign_fixture_persistent_id.decide( + state, + command, + persistent_id=attempted, + now=now, + ) + assert exc.value.fixture_id == fixture_id + assert exc.value.current == current + assert exc.value.attempted == attempted + + +@pytest.mark.unit +@given( + fixture_id=st.uuids(), + persistent_id=_persistent_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_emitted_event_scheme_and_value_match_resolved_persistent_id( + fixture_id: UUID, + persistent_id: PersistentIdentifier, + seconds_offset: int, +) -> None: + state = _fixture(fixture_id, persistent_id=None) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignFixturePersistentId( + fixture_id=fixture_id, + scheme=persistent_id.scheme, + ) + events = assign_fixture_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + assert len(events) == 1 + event = events[0] + assert isinstance(event, FixturePersistentIdAssigned) + assert event.persistent_id_scheme == persistent_id.scheme.value + assert event.persistent_id_value == persistent_id.value + assert event.fixture_id == fixture_id + assert event.occurred_at == now + + +@pytest.mark.unit +@given( + fixture_id=st.uuids(), + persistent_id=_persistent_identifier(), + other=_persistent_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_decider_is_deterministic_given_state_and_args( + fixture_id: UUID, + persistent_id: PersistentIdentifier, + other: PersistentIdentifier, + seconds_offset: int, +) -> None: + assume(persistent_id != other) + state = _fixture(fixture_id, persistent_id=None) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignFixturePersistentId( + fixture_id=fixture_id, + scheme=persistent_id.scheme, + ) + first = assign_fixture_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + second = assign_fixture_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_events.py b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_events.py new file mode 100644 index 0000000000..fa8d514daf --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_events.py @@ -0,0 +1,244 @@ +"""Unit tests for FixturePersistentIdAssigned (de)serialization helpers. + +Covers `to_payload` <-> `from_stored` round-trip for both supported +schemes (DOI + Handle) and the `Malformed*` wrap convention for +deserialization failures. Mirrors the Asset slice F sibling test +file `test_assign_asset_persistent_id_events.py`. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.fixture import ( + FixturePersistentIdAssigned, + MalformedFixturePersistentIdentifierError, + event_type_name, + from_stored, + to_payload, +) +from cora.infrastructure.ports.event_store import StoredEvent + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) + + +def _stored( + event_type: str, + payload: dict[str, object], + *, + stream_id: object | None = None, +) -> StoredEvent: + """Build a StoredEvent shell; only event_type + payload are read by from_stored.""" + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Fixture", + stream_id=stream_id or uuid4(), # type: ignore[arg-type] + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +def test_event_type_name_returns_fixture_persistent_id_assigned_class_name() -> None: + event = FixturePersistentIdAssigned( + fixture_id=uuid4(), + persistent_id_scheme="DOI", + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_NOW, + ) + assert event_type_name(event) == "FixturePersistentIdAssigned" + + +def test_to_payload_serializes_fixture_persistent_id_assigned_with_doi_scheme() -> None: + fixture_id = uuid4() + event = FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme="DOI", + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_NOW, + ) + assert to_payload(event) == { + "fixture_id": str(fixture_id), + "persistent_id_scheme": "DOI", + "persistent_id_value": "10.5281/zenodo.7654321", + "occurred_at": _NOW.isoformat(), + } + + +def test_to_payload_serializes_fixture_persistent_id_assigned_with_handle_scheme() -> None: + fixture_id = uuid4() + event = FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme="Handle", + persistent_id_value="20.500.12613/54321", + occurred_at=_NOW, + ) + assert to_payload(event) == { + "fixture_id": str(fixture_id), + "persistent_id_scheme": "Handle", + "persistent_id_value": "20.500.12613/54321", + "occurred_at": _NOW.isoformat(), + } + + +def test_from_stored_rebuilds_fixture_persistent_id_assigned_with_doi_scheme() -> None: + fixture_id = uuid4() + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(fixture_id), + "persistent_id_scheme": "DOI", + "persistent_id_value": "10.5281/zenodo.7654321", + "occurred_at": _NOW.isoformat(), + }, + ) + rebuilt = from_stored(stored) + assert rebuilt == FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme="DOI", + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_NOW, + ) + + +def test_from_stored_rebuilds_fixture_persistent_id_assigned_with_handle_scheme() -> None: + fixture_id = uuid4() + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(fixture_id), + "persistent_id_scheme": "Handle", + "persistent_id_value": "20.500.12613/54321", + "occurred_at": _NOW.isoformat(), + }, + ) + rebuilt = from_stored(stored) + assert rebuilt == FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme="Handle", + persistent_id_value="20.500.12613/54321", + occurred_at=_NOW, + ) + + +def test_fixture_persistent_id_assigned_round_trips_through_to_payload_and_from_stored() -> None: + original = FixturePersistentIdAssigned( + fixture_id=uuid4(), + persistent_id_scheme="DOI", + persistent_id_value="10.13139/OLCF/9876", + occurred_at=_NOW, + ) + stored = _stored("FixturePersistentIdAssigned", to_payload(original)) + assert from_stored(stored) == original + + +def test_fixture_persistent_id_assigned_with_handle_scheme_round_trips() -> None: + original = FixturePersistentIdAssigned( + fixture_id=uuid4(), + persistent_id_scheme="Handle", + persistent_id_value="20.500.12613/54321", + occurred_at=_NOW, + ) + stored = _stored("FixturePersistentIdAssigned", to_payload(original)) + assert from_stored(stored) == original + + +def test_fixture_persistent_id_assigned_from_stored_with_unknown_scheme_raises_malformed() -> None: + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(uuid4()), + "persistent_id_scheme": "ARK", + "persistent_id_value": "ark:/12345/abc", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed FixturePersistentIdAssigned payload"): + from_stored(stored) + + +def test_fixture_persistent_id_assigned_from_stored_with_missing_scheme_key_raises_malformed() -> ( + None +): + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(uuid4()), + "persistent_id_value": "10.5281/zenodo.7654321", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed FixturePersistentIdAssigned payload"): + from_stored(stored) + + +def test_fixture_persistent_id_assigned_from_stored_with_missing_value_key_raises_malformed() -> ( + None +): + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(uuid4()), + "persistent_id_scheme": "DOI", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed FixturePersistentIdAssigned payload"): + from_stored(stored) + + +def test_fixture_persistent_id_assigned_from_stored_with_empty_value_raises_malformed() -> None: + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(uuid4()), + "persistent_id_scheme": "DOI", + "persistent_id_value": "", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises( + ValueError, match="Malformed FixturePersistentIdAssigned payload" + ) as excinfo: + from_stored(stored) + assert isinstance(excinfo.value.__cause__, MalformedFixturePersistentIdentifierError) + + +def test_fixture_pid_assigned_from_stored_with_whitespace_only_value_raises_malformed() -> None: + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(uuid4()), + "persistent_id_scheme": "DOI", + "persistent_id_value": " ", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises( + ValueError, match="Malformed FixturePersistentIdAssigned payload" + ) as excinfo: + from_stored(stored) + assert isinstance(excinfo.value.__cause__, MalformedFixturePersistentIdentifierError) + + +def test_fixture_pid_assigned_from_stored_with_malformed_fixture_id_raises_malformed() -> None: + stored = _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": "not-a-uuid", + "persistent_id_scheme": "DOI", + "persistent_id_value": "10.5281/zenodo.7654321", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed FixturePersistentIdAssigned payload"): + from_stored(stored) diff --git a/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_evolver.py b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_evolver.py new file mode 100644 index 0000000000..6da6bb6217 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_evolver.py @@ -0,0 +1,237 @@ +"""Unit tests for the FixturePersistentIdAssigned evolver arm. + +Folding FixturePersistentIdAssigned over a Fixture state flips +`state.persistent_id` from None to PersistentIdentifier(scheme, value). +Replay-safe at the evolver layer; set-once is enforced at the decider. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.fixture import ( + Fixture, + FixtureNotFoundError, + FixturePersistentIdAssigned, + FixtureRegistered, + SlotAssetBinding, + evolve, + fold, +) + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) +_LATER = datetime(2026, 6, 5, 12, 30, 0, tzinfo=UTC) + +_DOI = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.7654321", +) +_HANDLE = PersistentIdentifier( + scheme=PersistentIdentifierScheme.HANDLE, + value="20.500.12613/98765", +) + + +def _prior( + *, + persistent_id: PersistentIdentifier | None = None, +) -> Fixture: + return Fixture( + id=uuid4(), + assembly_id=uuid4(), + assembly_content_hash="b" * 64, + surface_id=uuid4(), + slot_asset_bindings=frozenset(), + parameter_overrides={}, + registered_at=_NOW, + persistent_id=persistent_id, + ) + + +@pytest.mark.unit +def test_evolver_folds_fixture_persistent_id_assigned_into_state() -> None: + prior = _prior() + assert prior.persistent_id is None + state = evolve( + prior, + FixturePersistentIdAssigned( + fixture_id=prior.id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_LATER, + ), + ) + assert state.persistent_id == _DOI + + +@pytest.mark.unit +def test_evolver_folds_handle_scheme_correctly() -> None: + prior = _prior() + state = evolve( + prior, + FixturePersistentIdAssigned( + fixture_id=prior.id, + persistent_id_scheme=PersistentIdentifierScheme.HANDLE.value, + persistent_id_value="20.500.12613/98765", + occurred_at=_LATER, + ), + ) + assert state.persistent_id == _HANDLE + assert state.persistent_id is not None + assert state.persistent_id.scheme is PersistentIdentifierScheme.HANDLE + + +@pytest.mark.unit +def test_evolver_preserves_unrelated_state_fields() -> None: + """Persistent-id mutation only touches `persistent_id`; every other + facet (id, assembly_id, assembly_content_hash, surface_id, + slot_asset_bindings, parameter_overrides, registered_at) carries + through. Pin against the evolver explicitly constructing + Fixture(...) so a future evolver refactor that drops a field is + caught.""" + fixture_id = uuid4() + assembly_id = uuid4() + surface_id = uuid4() + asset_id = uuid4() + bindings = frozenset({SlotAssetBinding(slot_name="camera", asset_id=asset_id)}) + prior = Fixture( + id=fixture_id, + assembly_id=assembly_id, + assembly_content_hash="c" * 64, + surface_id=surface_id, + slot_asset_bindings=bindings, + parameter_overrides={"exposure_ms": 100, "gain": "high"}, + registered_at=_NOW, + ) + state = evolve( + prior, + FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_LATER, + ), + ) + assert state.persistent_id == _DOI + assert state.id == fixture_id + assert state.assembly_id == assembly_id + assert state.assembly_content_hash == "c" * 64 + assert state.surface_id == surface_id + assert state.slot_asset_bindings == bindings + assert state.parameter_overrides == {"exposure_ms": 100, "gain": "high"} + assert state.registered_at == _NOW + + +@pytest.mark.unit +def test_evolver_on_empty_state_raises_fixture_not_found() -> None: + fixture_id = uuid4() + with pytest.raises(FixtureNotFoundError): + evolve( + None, + FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_NOW, + ), + ) + + +@pytest.mark.unit +def test_evolver_replay_with_same_event_keeps_persistent_id_unchanged() -> None: + """Set-once is enforced at the decider; the evolver itself is + forgiving. A replay of the SAME FixturePersistentIdAssigned event + yields the same `persistent_id`, so fold is idempotent at the + evolver layer for the produced-by-decider stream.""" + prior = _prior() + event = FixturePersistentIdAssigned( + fixture_id=prior.id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_LATER, + ) + once = evolve(prior, event) + twice = evolve(once, event) + assert once.persistent_id == _DOI + assert twice.persistent_id == _DOI + + +@pytest.mark.unit +def test_fold_register_then_assign_persistent_id_yields_fixture_with_persistent_id() -> None: + """End-to-end fold: register + assign yields a Fixture whose + `persistent_id` is the assigned VO and `registered_at` carries + through from the genesis event.""" + fixture_id = uuid4() + assembly_id = uuid4() + surface_id = uuid4() + state = fold( + [ + FixtureRegistered( + fixture_id=fixture_id, + assembly_id=assembly_id, + assembly_content_hash="d" * 64, + surface_id=surface_id, + slot_asset_bindings=frozenset(), + parameter_overrides={}, + occurred_at=_NOW, + ), + FixturePersistentIdAssigned( + fixture_id=fixture_id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.7654321", + occurred_at=_LATER, + ), + ] + ) + assert state is not None + assert state.persistent_id == _DOI + assert state.id == fixture_id + assert state.registered_at == _NOW + + +@pytest.mark.unit +def test_evolver_fixture_registered_defaults_persistent_id_to_none() -> None: + """Genesis: FixtureRegistered yields persistent_id=None via the + state default (no synthetic initialization event). Pinned because + legacy streams without persistent_id must fold cleanly via the + additive-state pattern.""" + state = evolve( + None, + FixtureRegistered( + fixture_id=uuid4(), + assembly_id=uuid4(), + assembly_content_hash="e" * 64, + surface_id=uuid4(), + slot_asset_bindings=frozenset(), + parameter_overrides={}, + occurred_at=_NOW, + ), + ) + assert state.persistent_id is None + + +@pytest.mark.unit +def test_evolver_replay_assign_over_prior_persistent_id_overwrites_at_fold_layer() -> None: + """The evolver is forgiving: if a stream somehow carries two + FixturePersistentIdAssigned events (which the decider's set-once + invariant forbids at command time), the evolver folds the second + on top of the first without raising. Pin the forgiving posture so + a future tightening at the evolver layer is a deliberate change.""" + prior = _prior(persistent_id=_DOI) + state = evolve( + prior, + FixturePersistentIdAssigned( + fixture_id=prior.id, + persistent_id_scheme=PersistentIdentifierScheme.HANDLE.value, + persistent_id_value="20.500.12613/98765", + occurred_at=_LATER, + ), + ) + assert state.persistent_id == _HANDLE diff --git a/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_summary_projection.py b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_summary_projection.py new file mode 100644 index 0000000000..fd533494cf --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_fixture_persistent_id_summary_projection.py @@ -0,0 +1,115 @@ +"""Unit tests for FixtureSummaryProjection's FixturePersistentIdAssigned handling. + +Pins the JSONB-shape contract for the persistent_id column written by +the projection when a FixturePersistentIdAssigned event lands. The +neighbor `test_fixture_summary_projection.py` covers the projector's +genesis arm; this file isolates the Phase-2 PIDINST arm so a regression +in the JSONB-build SQL or the (scheme, value) carry surfaces as a +focused failure. Integration-tier real-PG behavior (replay, codecs) +lives in `tests/integration/`. +""" + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from cora.equipment.projections.fixture_summary import FixtureSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_FIXTURE_ID = uuid4() +_EVENT_ID = uuid4() +_CORRELATION_ID = uuid4() +_NOW = datetime(2026, 6, 5, 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="Fixture", + stream_id=_FIXTURE_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, + ) + + +def _persistent_id_assigned( + *, scheme: str = "DOI", value: str = "10.5281/zenodo.7654321" +) -> StoredEvent: + return _stored( + "FixturePersistentIdAssigned", + { + "fixture_id": str(_FIXTURE_ID), + "persistent_id_scheme": scheme, + "persistent_id_value": value, + "occurred_at": _NOW.isoformat(), + }, + ) + + +async def test_apply_assigned_writes_persistent_id_jsonb() -> None: + proj = FixtureSummaryProjection() + conn = AsyncMock() + event = _persistent_id_assigned(scheme="DOI", value="10.5281/zenodo.7654321") + + await proj.apply(event, conn) + + conn.execute.assert_awaited_once() + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_fixture_summary" in sql + assert "SET persistent_id" in sql + assert "jsonb_build_object" in sql + assert "WHERE fixture_id = $1" in sql + assert args.args[1] == _FIXTURE_ID + assert args.args[2] == "DOI" + assert args.args[3] == "10.5281/zenodo.7654321" + + +async def test_jsonb_shape_is_scheme_value_object() -> None: + proj = FixtureSummaryProjection() + conn = AsyncMock() + event = _persistent_id_assigned(scheme="Handle", value="20.500.12613/67890") + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "'scheme'" in sql + assert "'value'" in sql + assert "$2::text" in sql + assert "$3::text" in sql + assert args.args[2] == "Handle" + assert args.args[3] == "20.500.12613/67890" + + +async def test_apply_twice_with_same_event_is_idempotent_at_jsonb_column_level() -> None: + proj = FixtureSummaryProjection() + conn = AsyncMock() + event = _persistent_id_assigned(scheme="DOI", value="10.5281/zenodo.7654321") + + await proj.apply(event, conn) + await proj.apply(event, conn) + + assert conn.execute.await_count == 2 + first, second = conn.execute.await_args_list + assert first.args == second.args + sql = first.args[0] + assert "UPDATE proj_equipment_fixture_summary" in sql + assert "SET persistent_id" in sql + assert first.args[1] == _FIXTURE_ID + assert first.args[2] == "DOI" + assert first.args[3] == "10.5281/zenodo.7654321" diff --git a/apps/api/tests/unit/equipment/test_fixture_summary_projection.py b/apps/api/tests/unit/equipment/test_fixture_summary_projection.py index 924857aa86..adc4a76530 100644 --- a/apps/api/tests/unit/equipment/test_fixture_summary_projection.py +++ b/apps/api/tests/unit/equipment/test_fixture_summary_projection.py @@ -39,7 +39,9 @@ def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: def test_projection_metadata() -> None: proj = FixtureSummaryProjection() assert proj.name == "proj_equipment_fixture_summary" - assert proj.subscribed_event_types == frozenset({"FixtureRegistered"}) + assert proj.subscribed_event_types == frozenset( + {"FixtureRegistered", "FixturePersistentIdAssigned"} + ) @pytest.mark.unit diff --git a/infra/atlas/migrations/20260605100000_add_fixture_summary_persistent_id.sql b/infra/atlas/migrations/20260605100000_add_fixture_summary_persistent_id.sql new file mode 100644 index 0000000000..73e27fb00d --- /dev/null +++ b/infra/atlas/migrations/20260605100000_add_fixture_summary_persistent_id.sql @@ -0,0 +1,32 @@ +-- Reserve the persistent_id JSONB column on proj_equipment_fixture_summary +-- that the Fixture-tier PIDINST Phase 2 write path will populate +-- (PIDINST v1.0 Property 1 DOI/Handle). +-- +-- Per project_fixture_pidinst_design Phase 2 (Section 1.2 #9 + Section 12): +-- +-- - persistent_id is reserved nullable JSONB; ALWAYS NULL until the +-- `assign_fixture_persistent_id` slice mints an identifier. +-- - The extended FixtureSummaryProjection subscribes to the new +-- `FixturePersistentIdAssigned` event and writes the column via +-- `jsonb_build_object('scheme', ..., 'value', ...)` (mirrors the +-- Asset-tier `_UPDATE_PERSISTENT_ID_ASSIGNED_SQL` precedent at +-- `cora.equipment.projections.asset`). +-- +-- Mirrors the Asset-tier `add_asset_summary_persistent_id` migration +-- shape: pure ADD COLUMN with a safe NULL default, no backfill needed. +-- +-- ## Forward-only +-- +-- Greenfield-friendly; the projection rebuilds from the event store and +-- picks up persistent_id on `FixturePersistentIdAssigned` replay. +-- Rollback via a NEW compensating migration per +-- project_forward_only_migrations. +-- +-- ## v1 scope reminders +-- +-- No index on persistent_id (matches the Asset-tier slice E.1 posture). +-- A future bulk-mint operator UX wanting `WHERE persistent_id IS NULL` +-- triggers the index when query latency on a large facility exceeds 1s. + +ALTER TABLE proj_equipment_fixture_summary + ADD COLUMN persistent_id JSONB NULL DEFAULT NULL; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index b3f12aff4d..4d7d52a6b8 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:MRX+aTOooQn6CUXwI/3xl5Ctyk2SBvGcc/pBmhqeJGQ= +h1:RuhGvmbeWfI9aChA9Jvyc3xSf06dMN8kGeTADmpPprQ= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -105,3 +105,4 @@ h1:MRX+aTOooQn6CUXwI/3xl5Ctyk2SBvGcc/pBmhqeJGQ= 20260603120000_add_asset_summary_owners.sql h1:1PpFeb+5/N4KnT2nLlivb5uLQ0nQrYzWh6y5UPOGnWM= 20260603130000_add_asset_summary_persistent_id.sql h1:HBUQrfgyeRHInP77tDsJuzYlmeTawm59lV8e7677frA= 20260605000000_add_proj_decision_summary_choice.sql h1:Bh7IUciWl9DkdMngEJdQu3vfEhWEjB+dbnjJvv6BOFY= +20260605100000_add_fixture_summary_persistent_id.sql h1:TsCwLiP4wmqyjKCIRQ3/9+rlci6iqs9sSHHfxfE/RJI=