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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3688,6 +3688,23 @@
"title": "ClearanceTemplateSummaryDTO",
"type": "object"
},
"CloseVisitPresenceRequest": {
"additionalProperties": false,
"description": "Body for `POST /visits/{visit_id}/close-presence`.\n\nUnlike check-in and check-out, this one DOES name an actor, because\nclosing somebody else's record is the whole point of the command. The\ncaller is still the envelope's `principal_id`, so the record shows both\nwho was present and who ended it.",
"properties": {
"actor_id": {
"description": "Actor whose open presence entry is closed.",
"format": "uuid",
"title": "Actor Id",
"type": "string"
}
},
"required": [
"actor_id"
],
"title": "CloseVisitPresenceRequest",
"type": "object"
},
"CompleteSealRepublishingBody": {
"additionalProperties": false,
"description": "Optional complete-seal-republishing request body.",
Expand Down Expand Up @@ -45714,6 +45731,86 @@
]
}
},
"/visits/{visit_id}/close-presence": {
"post": {
"operationId": "post_visits_close_presence_visits__visit_id__close_presence_post",
"parameters": [
{
"description": "Target Visit's id.",
"in": "path",
"name": "visit_id",
"required": true,
"schema": {
"description": "Target Visit's id.",
"format": "uuid",
"title": "Visit 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/CloseVisitPresenceRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Successful Response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Authorize port denied the command."
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "No Visit exists with the given id, OR the named actor has no open presence entry."
},
"422": {
"description": "Request body failed schema validation."
}
},
"summary": "Close another actor's presence entry on a Visit",
"tags": [
"trust"
]
}
},
"/visits/{visit_id}/complete": {
"post": {
"operationId": "post_visits_complete_visits__visit_id__complete_post",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cora/trust/aggregates/visit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
VisitCompleted,
VisitEvent,
VisitHeld,
VisitPresenceClosed,
VisitRegistered,
VisitResumed,
VisitStarted,
Expand Down Expand Up @@ -97,6 +98,7 @@
"VisitNotFoundError",
"VisitParentMismatchedSurfaceError",
"VisitParentNotFoundError",
"VisitPresenceClosed",
"VisitRegistered",
"VisitResumed",
"VisitStarted",
Expand Down
44 changes: 42 additions & 2 deletions apps/api/src/cora/trust/aggregates/visit/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
- `VisitVoided` -- any non-terminal -> Voided (+ reason)
FHIR `entered-in-error` analog.

Presence events: `VisitCheckedIn` / `VisitCheckedOut`. Surface-control
Presence events: `VisitCheckedIn` / `VisitCheckedOut` /
`VisitPresenceClosed`. Surface-control
events: `VisitSurfaceControlTaken` / `VisitSurfaceControlReleased`.

`VisitRegistered.permitted_*` lists become `frozenset` on state in the
Expand Down Expand Up @@ -178,6 +179,31 @@ class VisitCheckedOut:
occurred_at: datetime


@dataclass(frozen=True)
class VisitPresenceClosed:
"""Somebody else ended this actor's presence on the Visit.

Structurally identical to `VisitCheckedOut`: the open `PresenceEntry`
gains a `check_out_at` by the same frozen-replace, and the evolver
handles both in one arm. A distinct type because the two acts differ in
CAUSE, the way `VisitCancelled` / `VisitAborted` / `VisitVoided` all
reach a terminal status and stay separate events.

That distinction has to survive in the event TYPE rather than only in
the envelope's `command_name`, because presence is read as evidence of
who was at a beamline: "did this person leave, or did somebody close
their record for them" should be one predicate over the stream, not a
join against envelope metadata.

The actor who did the closing is the envelope's `principal_id` and is
deliberately not duplicated into the payload.
"""

visit_id: UUID
actor_id: UUID
occurred_at: datetime


@dataclass(frozen=True)
class VisitSurfaceControlTaken:
"""The Visit took operational control of the Surface.
Expand Down Expand Up @@ -228,6 +254,7 @@ class VisitSurfaceControlReleased:
| VisitVoided
| VisitCheckedIn
| VisitCheckedOut
| VisitPresenceClosed
| VisitSurfaceControlTaken
| VisitSurfaceControlReleased
)
Expand Down Expand Up @@ -329,7 +356,10 @@ def to_payload(event: VisitEvent) -> dict[str, Any]:
"mode": mode,
"occurred_at": occurred_at.isoformat(),
}
case VisitCheckedOut(visit_id=visit_id, actor_id=actor_id, occurred_at=occurred_at):
case (
VisitCheckedOut(visit_id=visit_id, actor_id=actor_id, occurred_at=occurred_at)
| VisitPresenceClosed(visit_id=visit_id, actor_id=actor_id, occurred_at=occurred_at)
):
return {
"visit_id": str(visit_id),
"actor_id": str(actor_id),
Expand Down Expand Up @@ -463,6 +493,15 @@ def _build_visit_registered() -> VisitRegistered:
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
),
)
case "VisitPresenceClosed":
return deserialize_or_raise(
"VisitPresenceClosed",
lambda: VisitPresenceClosed(
visit_id=UUID(payload["visit_id"]),
actor_id=UUID(payload["actor_id"]),
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
),
)
case "VisitSurfaceControlTaken":
return deserialize_or_raise(
"VisitSurfaceControlTaken",
Expand Down Expand Up @@ -495,6 +534,7 @@ def _build_visit_registered() -> VisitRegistered:
"VisitCompleted",
"VisitEvent",
"VisitHeld",
"VisitPresenceClosed",
"VisitRegistered",
"VisitResumed",
"VisitStarted",
Expand Down
67 changes: 57 additions & 10 deletions apps/api/src/cora/trust/aggregates/visit/evolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from collections.abc import Sequence
from dataclasses import replace
from datetime import datetime
from typing import assert_never

from cora.trust.aggregates.visit.events import (
Expand All @@ -24,6 +25,7 @@
VisitCompleted,
VisitEvent,
VisitHeld,
VisitPresenceClosed,
VisitRegistered,
VisitResumed,
VisitStarted,
Expand All @@ -40,6 +42,27 @@
)


def _closed_at(
entries: frozenset[PresenceEntry], occurred_at: datetime
) -> frozenset[PresenceEntry]:
"""Close every open presence entry at `occurred_at`.

A Visit reaching a terminal state ends presence by implication: nobody is
at a beamline for a Visit that has completed, been cancelled, aborted, or
voided. Deriving that here rather than emitting per-actor close events
keeps the terminal deciders ignorant of presence, and means the closing
timestamp is exactly the transition's own, with no second clock reading.

Same frozen-replace shape as the `VisitCheckedOut` arm. Returns `entries`
unchanged when nothing is open, so the common case allocates nothing.
"""
open_entries = {e for e in entries if e.check_out_at is None}
if not open_entries:
return entries
closed = {replace(e, check_out_at=occurred_at) for e in open_entries}
return (entries - open_entries) | closed


def evolve(state: Visit | None, event: VisitEvent) -> Visit:
"""Apply one event to the current state."""
match event:
Expand Down Expand Up @@ -79,18 +102,37 @@ def evolve(state: Visit | None, event: VisitEvent) -> Visit:
assert state is not None, "VisitResumed requires prior state"
# Preserve last_status_reason audit breadcrumb across resume.
return replace(state, status=VisitStatus.IN_PROGRESS)
case VisitCompleted():
case VisitCompleted(occurred_at=occurred_at):
assert state is not None, "VisitCompleted requires prior state"
return replace(state, status=VisitStatus.COMPLETED)
case VisitCancelled(reason=reason):
return replace(
state,
status=VisitStatus.COMPLETED,
presence_entries=_closed_at(state.presence_entries, occurred_at),
)
case VisitCancelled(reason=reason, occurred_at=occurred_at):
assert state is not None, "VisitCancelled requires prior state"
return replace(state, status=VisitStatus.CANCELLED, last_status_reason=reason)
case VisitAborted(reason=reason):
return replace(
state,
status=VisitStatus.CANCELLED,
last_status_reason=reason,
presence_entries=_closed_at(state.presence_entries, occurred_at),
)
case VisitAborted(reason=reason, occurred_at=occurred_at):
assert state is not None, "VisitAborted requires prior state"
return replace(state, status=VisitStatus.ABORTED, last_status_reason=reason)
case VisitVoided(reason=reason):
return replace(
state,
status=VisitStatus.ABORTED,
last_status_reason=reason,
presence_entries=_closed_at(state.presence_entries, occurred_at),
)
case VisitVoided(reason=reason, occurred_at=occurred_at):
assert state is not None, "VisitVoided requires prior state"
return replace(state, status=VisitStatus.VOIDED, last_status_reason=reason)
return replace(
state,
status=VisitStatus.VOIDED,
last_status_reason=reason,
presence_entries=_closed_at(state.presence_entries, occurred_at),
)
case VisitCheckedIn(actor_id=actor_id, mode=mode, occurred_at=occurred_at):
assert state is not None, "VisitCheckedIn requires prior state"
# Set-union add. Decider has already guarded against open-entry duplicates;
Expand All @@ -102,8 +144,13 @@ def evolve(state: Visit | None, event: VisitEvent) -> Visit:
check_out_at=None,
)
return replace(state, presence_entries=state.presence_entries | {new_entry})
case VisitCheckedOut(actor_id=actor_id, occurred_at=occurred_at):
assert state is not None, "VisitCheckedOut requires prior state"
case (
VisitCheckedOut(actor_id=actor_id, occurred_at=occurred_at)
| VisitPresenceClosed(actor_id=actor_id, occurred_at=occurred_at)
):
# One arm for both: the state change is identical and only the
# cause differs, which the event TYPE already carries.
assert state is not None, "presence-closing event requires prior state"
# Frozen-replace: find the actor's OPEN entry, remove it, insert a new
# entry with check_out_at populated. Old + new are distinct frozenset
# members because PresenceEntry's hash covers all 4 fields. Decider
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Vertical slice for the `CloseVisitPresence` command."""

from cora.trust.features.close_visit_presence import tool
from cora.trust.features.close_visit_presence.command import CloseVisitPresence
from cora.trust.features.close_visit_presence.decider import decide
from cora.trust.features.close_visit_presence.handler import Handler, bind
from cora.trust.features.close_visit_presence.route import router

__all__ = ["CloseVisitPresence", "Handler", "bind", "decide", "router", "tool"]
28 changes: 28 additions & 0 deletions apps/api/src/cora/trust/features/close_visit_presence/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""The `CloseVisitPresence` command -- intent dataclass.

Closes ANOTHER actor's open presence entry. This is the deliberate
counterpart to `CheckOutVisit`, which closes only the caller's own.

The two are separate commands, not one command with a nullable actor, so
that closing somebody else's record needs its own Policy grant. Somebody
who forgot to check out and went home leaves an entry that only this
command can close while the Visit is still running; once the Visit reaches
a terminal state the evolver closes every open entry anyway, so this exists
for the mid-Visit case.

Attribution is the event envelope's, not a payload field: the envelope
already carries `principal_id` and `command_name`, so a `VisitCheckedOut`
recorded under `CloseVisitPresence` is distinguishable from a self-checkout
without duplicating the caller into the payload.
"""

from dataclasses import dataclass
from uuid import UUID


@dataclass(frozen=True)
class CloseVisitPresence:
"""Close `actor_id`'s currently-open presence entry on the Visit."""

visit_id: UUID
actor_id: UUID
Loading
Loading