diff --git a/apps/api/src/cora/agent/__init__.py b/apps/api/src/cora/agent/__init__.py index 5e9f145475a..e6cebed4ad4 100644 --- a/apps/api/src/cora/agent/__init__.py +++ b/apps/api/src/cora/agent/__init__.py @@ -87,6 +87,10 @@ RUN_SUPERVISOR_AGENT_ID, seed_run_supervisor_agent, ) +from cora.agent.seed_run_witness import ( + RUN_WITNESS_AGENT_ID, + seed_run_witness_agent, +) from cora.agent.tools import register_agent_tools from cora.agent.wire import AgentHandlers, wire_agent @@ -102,6 +106,7 @@ "RATIFICATION_ENFORCER_AGENT_ID", "RUN_INITIATOR_AGENT_ID", "RUN_SUPERVISOR_AGENT_ID", + "RUN_WITNESS_AGENT_ID", "AgentHandlers", "CautionProposalMalformedError", "CautionProposalNotActionableError", @@ -129,5 +134,6 @@ "seed_run_debriefer_agent", "seed_run_initiator_agent", "seed_run_supervisor_agent", + "seed_run_witness_agent", "wire_agent", ] diff --git a/apps/api/src/cora/agent/seed_run_witness.py b/apps/api/src/cora/agent/seed_run_witness.py new file mode 100644 index 00000000000..f215ce5ece6 --- /dev/null +++ b/apps/api/src/cora/agent/seed_run_witness.py @@ -0,0 +1,108 @@ +"""Bootstrap-time seed for the RunWitness Agent. + +The RunWitness runtime (`cora.api._run_witness`) needs an Agent record +(and its co-registered Actor) to exist at the pinned `RUN_WITNESS_AGENT_ID` +so it can issue `RecordWitnessedRun` as an agent-kind principal when it +promotes a BEGUN capture observation to a real witnessed Run. Mirrors +`cora.agent.seed_run_supervisor.seed_run_supervisor_agent` verbatim except +for the per-agent constants below; the shared scaffolding lives in +`cora.agent._agent_seed`. + + - Pinned UUID continues the numeric-mnemonic range RunInitiator opened + at `1111` (the lettered `aaaa`/`bbbb`/`cccc`/`dddd`/`eeee`/`ffff` + blocks are all claimed); deployment-stable forever. + - DETERMINISTIC agent (rule-based, NOT LLM): no prompt template + (`prompt_template_id=None`) and a sentinel `ModelRef` + (`provider="deterministic"`). Never used to build an LLM: the + runtime is a substrate-observation loop, not an LLM subscriber. + - Authorization: the runtime issues `RecordWitnessedRun` through the + Authorize port like any principal. Under the default AllowAllAuthorize + it is permitted; under TrustAuthorize the operator's single configured + Policy must include this principal + {RecordWitnessedRun, ListRuns}. + ListRuns is the restart-rebuild read: without it a restart cannot + rediscover which captures are already open, and would re-promote + them. Without the RecordWitnessedRun grant, a real BEGUN observation + logs `run_witness.promotion_unauthorized` and stays IDLE (retried on + the next BEGUN, same posture as RunInitiator's StartRun grant). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from cora.agent._agent_seed import AgentSeedIdentity, seed_agent +from cora.agent.aggregates.agent import ModelRef + +if TYPE_CHECKING: + from cora.infrastructure.kernel import Kernel + + +# --------------------------------------------------------------------------- +# RunWitness agent identity (deployment-stable constants) +# --------------------------------------------------------------------------- + +# Treat as FOREVER-STABLE. Same change-cost rationale as +# `RUN_SUPERVISOR_AGENT_ID` / `RUN_INITIATOR_AGENT_ID`: changing this +# orphans every prior RunWitness-authored Run's principal_id pointer. +# UUID continues the `1111`-opened numeric range at `2222` (next unclaimed +# block). +RUN_WITNESS_AGENT_ID = UUID("01900000-0000-7000-8000-000022220010") +RUN_WITNESS_AGENT_NAME = "RunWitness" +RUN_WITNESS_AGENT_KIND = "RunWitness" +RUN_WITNESS_AGENT_VERSION = "1.0.0" +RUN_WITNESS_AGENT_DESCRIPTION = ( + "Deterministic in-process runtime: promotes a real Witnessed " + "Run via record_witnessed_run when it observes an external tool " + "(TomoScan) begin a capture, with per-capture-code dedup so a single " + "in-progress capture is never promoted twice. Not a control path: it " + "never drives the substrate, only records that a capture already " + "began." +) + + +# Sentinel model ref: RunWitness is rule-based, not an LLM agent. The +# Agent aggregate requires a ModelRef; this value is never used to build +# an LLM (no subscriber / no build_llm call for this agent). +_DETERMINISTIC_MODEL_REF = ModelRef( + provider="deterministic", + model="agent:RunWitness:v1", + snapshot_pin=None, +) + + +# --------------------------------------------------------------------------- +# Deterministic IDs for the bootstrap write envelope +# --------------------------------------------------------------------------- + +_AGENT_EVENT_ID = UUID("01900000-0000-7000-8000-000022220012") +_ACTOR_EVENT_ID = UUID("01900000-0000-7000-8000-000022220013") +_BOOTSTRAP_CORRELATION_ID = UUID("01900000-0000-7000-8000-000022220014") + + +async def seed_run_witness_agent(kernel: Kernel) -> None: + """Seed the RunWitness Agent + co-registered Actor (idempotent).""" + identity = AgentSeedIdentity( + agent_id=RUN_WITNESS_AGENT_ID, + name=RUN_WITNESS_AGENT_NAME, + kind=RUN_WITNESS_AGENT_KIND, + version=RUN_WITNESS_AGENT_VERSION, + description=RUN_WITNESS_AGENT_DESCRIPTION, + model_ref=_DETERMINISTIC_MODEL_REF, + prompt_template_id=None, + agent_event_id=_AGENT_EVENT_ID, + actor_event_id=_ACTOR_EVENT_ID, + correlation_id=_BOOTSTRAP_CORRELATION_ID, + command_name="SeedRunWitnessAgent", + ) + await seed_agent(kernel, identity) + + +__all__ = [ + "RUN_WITNESS_AGENT_DESCRIPTION", + "RUN_WITNESS_AGENT_ID", + "RUN_WITNESS_AGENT_KIND", + "RUN_WITNESS_AGENT_NAME", + "RUN_WITNESS_AGENT_VERSION", + "seed_run_witness_agent", +] diff --git a/apps/api/src/cora/api/_capture_observer.py b/apps/api/src/cora/api/_capture_observer.py new file mode 100644 index 00000000000..8e87451390b --- /dev/null +++ b/apps/api/src/cora/api/_capture_observer.py @@ -0,0 +1,237 @@ +"""Composition-root bridge: drive the capture observer from ControlPort. + +The Run BC's `CaptureObserver` port is BC-local (`cora.run.ports`) and +the `ControlPort` value-IO it needs is Operation-BC-owned +(`cora.operation.ports`). tach forbids `cora.run -> cora.operation`, so +the adapter that bridges the two lives here at the composition root, +mirroring `_enclosure_permit_observer.py` exactly. If a third cross-BC +`ControlPort` consumer appears, the rule-of-three move is to hoist +`ControlPort` to `cora.infrastructure.ports`. + +Maps each configured capture code's `status` PV to a `CaptureObservation` +by looking its decoded text up in the deployment's declared +`capture_status_phases` table (`classify_capture_status`); a literal +absent from the table classifies `UNRECOGNIZED` rather than being +dropped or coerced into a nearby phase. + +## One deliberate inversion from the Enclosure precedent + +`ControlPortEnclosureObserver` synthesizes an `Unknown` STATUS CLAIM on +disconnect, because a dead permit signal must fail the run-start gate +closed rather than leave a stale `Permitted` standing. There is no such +gate here, and reading a disconnect as a real observation would +fabricate one: it would either assert a phase no substrate reading +backs, or, if mapped to `UNRECOGNIZED`, misrepresent a communication +failure as a vocabulary problem. So `_unreached` here carries NO status +claim at all (`reported_status=None`, `phase=None`), the same shape a +probe-only poll tick already uses, exactly mirroring +`CaptureObservation`'s own port-level contract for the probe-only case. + +## Permit probe trail's sibling-poller shape, reused unchanged + +Same reasoning as the Enclosure adapter: `_poll` is a SIBLING of +`_pump`, not nested inside it, because `_pump` returns as soon as its +subscription ends and `_drain` only re-subscribes after every pump has +returned; a poller living inside `_pump` would die with it and could +never observe the PV's recovery. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from cora.operation.ports.control_port import ControlNotConnectedError +from cora.run.ports.capture_observer import CaptureObservation, CaptureObserverScope, CapturePhase +from cora.shared.reach import ReachTier + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Mapping + + from cora.operation.ports.control_port import ControlPort, Measurement + +_SOURCE_KIND = "EpicsPv" +_STATUS_ROLE = "status" + + +def classify_capture_status(reported_status: str, status_phases: Mapping[str, str]) -> CapturePhase: + """Classify a decoded status literal against the deployment's declared table. + + A literal absent from `status_phases` classifies as + `CapturePhase.UNRECOGNIZED`. This also covers the Bad-quality-reading + case with no special handling needed: an unresolvable or garbled + string is exceedingly unlikely to match a declared literal, so it + naturally falls to UNRECOGNIZED, which is the correct signal ("the + substrate said something CORA cannot classify"), rather than a + fabricated phase. + """ + mapped = status_phases.get(reported_status) + if mapped is None: + return CapturePhase.UNRECOGNIZED + return CapturePhase(mapped) + + +class _PumpDone: + """Per-PV sentinel pushed onto the merge queue when a pump exits.""" + + __slots__ = () + + +_PUMP_DONE = _PumpDone() + + +class ControlPortCaptureObserver: + """`CaptureObserver` over a `ControlPort` (one `status` PV per capture code). + + `capture_pvs` is code -> role -> PV, matching + `Settings.capture_watch_pvs`. Only the `status` role is subscribed + in this slice; a code whose PV set has no `status` entry cannot be + watched and is silently excluded from scope, mirroring the + Enclosure adapter's `if code in self._permit_pvs` filter. The other + declared roles (`server_running`, `abort`, `images_saved`, + `images_collected`) are read by a later slice; declaring them now + costs nothing and lets a deployment's config stabilize ahead of the + code that consumes it. + """ + + def __init__( + self, + *, + control_port: ControlPort, + capture_pvs: Mapping[str, Mapping[str, str]], + status_phases: Mapping[str, str], + tick_seconds: float | None = None, + ) -> None: + self._control_port = control_port + self._status_pvs = { + code: roles[_STATUS_ROLE] + for code, roles in capture_pvs.items() + if _STATUS_ROLE in roles + } + self._status_phases = dict(status_phases) + self._tick_seconds = tick_seconds + + def observe(self, scope: CaptureObserverScope) -> AsyncGenerator[CaptureObservation]: + return self._drain(scope) + + async def _drain(self, scope: CaptureObserverScope) -> AsyncGenerator[CaptureObservation]: + pvs = [ + (code, self._status_pvs[code]) + for code in sorted(scope.capture_codes) + if code in self._status_pvs + ] + if not pvs: + return + queue: asyncio.Queue[CaptureObservation | _PumpDone] = asyncio.Queue() + pump_tasks = [asyncio.create_task(self._pump(code, pv, queue)) for code, pv in pvs] + poll_tasks = ( + [asyncio.create_task(self._poll(code, pv, queue)) for code, pv in pvs] + if self._tick_seconds is not None + else [] + ) + tasks = pump_tasks + poll_tasks + # Only pumps ever signal completion; a poller runs until the + # `finally` below cancels it, so it must not hold this open. + remaining = len(pump_tasks) + try: + while remaining > 0: + item = await queue.get() + if isinstance(item, _PumpDone): + remaining -= 1 + continue + yield item + # Every pump has finished, but a still-running poller can have + # enqueued a probe in the same instant the final _PumpDone was + # read. Drain exactly what is ALREADY queued right now, + # synchronously, into a list before yielding any of it: see + # `ControlPortEnclosureObserver._drain` for the full reasoning. + pending = queue.qsize() + leftover = [queue.get_nowait() for _ in range(pending)] + for item in leftover: + if not isinstance(item, _PumpDone): + yield item + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + async def _pump( + self, + code: str, + pv: str, + queue: asyncio.Queue[CaptureObservation | _PumpDone], + ) -> None: + try: + async for reading in self._control_port.subscribe(pv): + queue.put_nowait(self._from_reading(code, pv, reading)) + # Clean stream end: no status claim, mirroring a disconnect. + queue.put_nowait(self._unreached(code, pv)) + except ControlNotConnectedError: + queue.put_nowait(self._unreached(code, pv)) + finally: + queue.put_nowait(_PUMP_DONE) + + async def _poll( + self, + code: str, + pv: str, + queue: asyncio.Queue[CaptureObservation | _PumpDone], + ) -> None: + """Re-affirm reach to `pv` every `_tick_seconds`, independent of push. + + Never pushes `_PumpDone`: a sibling of `_pump`, not a stage in + its lifecycle. See `ControlPortEnclosureObserver._poll`. + """ + assert self._tick_seconds is not None + while True: + await asyncio.sleep(self._tick_seconds) + try: + await self._control_port.read(pv) + except Exception: # any read failure is a failed probe, not a bug + queue.put_nowait(self._probe_only(code, pv, ReachTier.UNREACHED)) + else: + queue.put_nowait(self._probe_only(code, pv, ReachTier.RELAYED)) + + def _from_reading(self, code: str, pv: str, reading: Measurement) -> CaptureObservation: + reported_status = str(reading.value) + phase = classify_capture_status(reported_status, self._status_phases) + return CaptureObservation( + capture_code=code, + reported_status=reported_status, + phase=phase, + reach_tier=ReachTier.RELAYED, + observed_at=reading.produced_at, + source_kind=_SOURCE_KIND, + source_id=pv, + ) + + def _probe_only(self, code: str, pv: str, reach_tier: ReachTier) -> CaptureObservation: + """A poll tick's result: reach evidence with no status claim.""" + return CaptureObservation( + capture_code=code, + reported_status=None, + phase=None, + reach_tier=reach_tier, + observed_at=None, + source_kind=_SOURCE_KIND, + source_id=pv, + ) + + def _unreached(self, code: str, pv: str) -> CaptureObservation: + """A disconnect or clean stream end: no status claim, no phase. + + See this module's docstring, "One deliberate inversion from the + Enclosure precedent", for why this must not synthesize a phase. + """ + return CaptureObservation( + capture_code=code, + reported_status=None, + phase=None, + reach_tier=ReachTier.UNREACHED, + observed_at=None, + source_kind=_SOURCE_KIND, + source_id=pv, + ) + + +__all__ = ["ControlPortCaptureObserver", "classify_capture_status"] diff --git a/apps/api/src/cora/api/_run_initiator.py b/apps/api/src/cora/api/_run_initiator.py index 76e4fe807ff..5838aafb0b7 100644 --- a/apps/api/src/cora/api/_run_initiator.py +++ b/apps/api/src/cora/api/_run_initiator.py @@ -274,6 +274,15 @@ async def initiate_tick( the steady state. Each start flows through `initiate_run`, so it is authorized, attributed, and Decision-linked exactly as a single agent start. + `running` deliberately counts EVERY Running Run toward `max_in_flight` + regardless of `conduct_mode`: unlike the RunSupervisor's hold / resume / + liveness mechanisms, which skip a Witnessed Run because CORA does not + control it, this cap exists to serialize access to shared single-stage + hardware. A witnessed capture IS occupying that hardware, so excluding + it here would let the initiator start a second, driven Run on the same + stage an external tool is actively driving, the opposite of what the + cap is for. + A per-Subject StartRun denial makes `initiate_run` return None (a logged no-op); that Subject is not added to `started`, so a transient fault is retried next tick. `max_in_flight <= 0` makes the tick inert (returns []); the diff --git a/apps/api/src/cora/api/_run_supervisor.py b/apps/api/src/cora/api/_run_supervisor.py index d6e957cb50a..76c98170f13 100644 --- a/apps/api/src/cora/api/_run_supervisor.py +++ b/apps/api/src/cora/api/_run_supervisor.py @@ -1177,6 +1177,15 @@ async def _supervise_tick( principal_id=RUN_SUPERVISOR_AGENT_ID, reason=str(err), ) from err + # Witnessed Runs are not the supervisor's to hold, resume, + # flag-liveness, truncate, or observe: they are driven by an external + # tool CORA only witnessed at genesis, so every downstream mechanism + # below (hold FSM, gated resume, liveness/truncate, Rule Q/R) would be + # trying to intervene on an act it cannot actually control. Filtering + # here, once, before any mechanism sees the lists, is what keeps a + # future new mechanism from having to remember this on its own. + running = [item for item in running if item.conduct_mode == "Conducted"] + held = [item for item in held if item.conduct_mode == "Conducted"] inflight_ids = {item.run_id for item in running} | {item.run_id for item in held} for run_id in list(memory): if run_id not in inflight_ids: diff --git a/apps/api/src/cora/api/_run_witness.py b/apps/api/src/cora/api/_run_witness.py new file mode 100644 index 00000000000..8ebcba84e67 --- /dev/null +++ b/apps/api/src/cora/api/_run_witness.py @@ -0,0 +1,375 @@ +"""RunWitness runtime: shadow-observe an external tool's captures, and +(behind a second, independent kill switch) promote a BEGUN capture to a +real witnessed Run. + +Background loop draining a `CaptureObserver` and logging each +observation's classified phase. Shadow mode (the default, and the only +behavior until `Settings.run_witness_recording_enabled` is turned on) +writes nothing anywhere, ever: no event append, no entries-table write, +no Run command issued. When recording is enabled, a `BEGUN` observation +for a capture with no open Run promotes one via `record_witnessed_run`, +with per-capture-code dedup so a single in-progress capture is never +promoted twice (see `RunWitnessRecorder`). + +Hosted at the composition root (`cora.api`), like `_run_initiator.py` +and `_enclosure_permit_observer.py`: it composes a Run BC command with +an Agent principal, and only `cora.api` may depend on both. + +## Log lines, one per observation + +- `run_witness.capture_begun` +- `run_witness.capture_progressing` +- `run_witness.capture_ended` +- `run_witness.capture_aborted` +- `run_witness.capture_unrecognized`: `phase` is `UNRECOGNIZED`, meaning + `reported_status` did not match the deployment's declared literal + table. A vocabulary drift (a tool upgrade renaming a status), not + routine progress; worth an operator's attention. +- `run_witness.capture_unreached`: `phase` is `None`, meaning this + observation made no status claim at all (a probe-only re-affirmation + read, or a disconnect the adapter reported with nothing to classify). + +Every line carries `capture_code`, `reported_status`, `source_kind`, +`source_id`, and `observed_at` (nullable; see `CaptureObservation`'s +own docstring on why an adapter must never substitute a synthesized +time for an absent one). These log lines are unconditional: they fire +identically whether or not recording is enabled. + +## Promotion (when run_witness_recording_enabled is True) + +Per capture_code, a small dedup state machine: + + - `BEGUN` while no Run is open for this code: call `record_witnessed_run` + and, on success, remember the returned run_id as OPEN. On failure + (any raised error, including an authorization misconfiguration), + log and stay unopened so the next `BEGUN` retries. + - `BEGUN` while a Run is already open for this code: no-op (already + promoted this capture cycle). + - `ENDED` / `ABORTED` while a Run is open: clear the local dedup entry + (log only, no Run action; terminal recording is a separate future + slice). + - `ENDED` / `ABORTED` while nothing is open, or `PROGRESSING` / + `UNRECOGNIZED` / a `None` phase in any state: no-op. + +`RunWitnessRecorder`'s dedup state is seeded once at boot (see +`rebuild_open_captures`) from every currently-Running Witnessed Run's +`external_refs`, so a still-open capture at process restart is never +re-promoted. + +## Retry + resilience + +Mirrors `run_enclosure_permit_monitor`: `observe()` ending (stream +terminated or raised) triggers a bounded sleep then re-subscribe. A +single bad observation is logged and skipped so the loop survives it. +Cancellation (lifespan shutdown) propagates. + +## No startup-readiness gate + +`enclosure_permit_monitor_lifespan` waits for a settled read before +yielding because a real precondition (the run-start preflight) reads +`permit_status` right after boot. Nothing downstream depends on this +runtime settling, so there is no boot race to close and no wait is +needed. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import TYPE_CHECKING +from uuid import UUID + +from cora.agent.seed_run_witness import RUN_WITNESS_AGENT_ID +from cora.infrastructure.logging import get_logger +from cora.run.errors import UnauthorizedError +from cora.run.features.list_runs.query import ListRuns +from cora.run.features.record_witnessed_run.command import RecordWitnessedRun +from cora.run.ports.capture_observer import CaptureObserverScope, CapturePhase +from cora.shared.identity import MonitorSourceId + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from cora.infrastructure.config import Settings + from cora.infrastructure.kernel import Kernel + from cora.run.aggregates.run.state import Run + from cora.run.features.list_runs.handler import Handler as ListRunsHandler + from cora.run.features.record_witnessed_run.handler import Handler as RecordWitnessedRunHandler + from cora.run.ports.capture_observer import CaptureObservation, CaptureObserver + from cora.shared.identifier import Identifier + +_RECONNECT_DELAY_SECONDS = 5.0 +_PAGE_LIMIT = 100 +_CAPTURE_CODE_SCHEME = "capture-code" + +_log = get_logger(__name__) + +# Single hardcoded literal, mirroring `ENCLOSURE_PERMIT_MONITOR_SOURCE_ID` +# (`cora.enclosure._monitor`) exactly: there is exactly one in-process +# RunWitness per deployment, so no derivation function is needed. +RUN_WITNESS_MONITOR_SOURCE_ID = MonitorSourceId(UUID("01900000-0000-7000-8000-000072756e01")) + +_PHASE_LOG_EVENT: dict[CapturePhase, str] = { + CapturePhase.BEGUN: "run_witness.capture_begun", + CapturePhase.PROGRESSING: "run_witness.capture_progressing", + CapturePhase.ENDED: "run_witness.capture_ended", + CapturePhase.ABORTED: "run_witness.capture_aborted", + CapturePhase.UNRECOGNIZED: "run_witness.capture_unrecognized", +} + +_TERMINAL_PHASES = (CapturePhase.ENDED, CapturePhase.ABORTED) + + +def observe_capture(observation: CaptureObservation) -> None: + """Log one observation. The entire body of shadow mode: no writes.""" + if observation.phase is None: + event = "run_witness.capture_unreached" + else: + event = _PHASE_LOG_EVENT[observation.phase] + _log.info( + event, + capture_code=observation.capture_code, + reported_status=observation.reported_status, + source_kind=observation.source_kind, + source_id=observation.source_id, + observed_at=observation.observed_at.isoformat() if observation.observed_at else None, + ) + + +class RunWitnessRecorder: + """Promotes a BEGUN observation to a witnessed Run when recording is + enabled; a log-only pass-through (today's shadow behavior) otherwise. + + Internally tracks the per-capture-code dedup state: absence of a key + means no Run is open for that capture; presence means the value is + the open Run's id. Seeded once at construction from + `rebuild_open_captures`, then owned exclusively by this instance for + the process's lifetime. + """ + + def __init__( + self, + *, + deps: Kernel, + record_witnessed_run: RecordWitnessedRunHandler, + settings: Settings, + open_captures: dict[str, UUID] | None = None, + ) -> None: + self._deps = deps + self._record_witnessed_run = record_witnessed_run + self._settings = settings + self._open_captures: dict[str, UUID] = dict(open_captures or {}) + + async def observe_capture(self, observation: CaptureObservation) -> None: + observe_capture(observation) + if not self._settings.run_witness_recording_enabled: + return + + code = observation.capture_code + phase = observation.phase + + if phase is CapturePhase.BEGUN: + if code in self._open_captures: + return + await self._promote(observation) + elif phase in _TERMINAL_PHASES: + run_id = self._open_captures.pop(code, None) + if run_id is not None: + _log.info("run_witness.open_capture_cleared", capture_code=code, run_id=str(run_id)) + # PROGRESSING, UNRECOGNIZED, and a None phase make no status + # claim this state machine acts on: no-op regardless of state. + + async def _promote(self, observation: CaptureObservation) -> None: + plan_id = self._settings.capture_watch_plan_id + if plan_id is None: + # Unreachable when `_enforce_run_witness_recording_gate` + # (main.py) has run: it refuses to boot with recording + # enabled and no plan_id set. Defensive no-op here so a + # caller that constructs this class directly (tests) cannot + # crash the loop instead of just not promoting. + _log.error( + "run_witness.recording_enabled_without_plan_id", + capture_code=observation.capture_code, + ) + return + + command = RecordWitnessedRun( + name=f"Witnessed capture {observation.capture_code}", + plan_id=plan_id, + capture_code=observation.capture_code, + monitor_source_id=RUN_WITNESS_MONITOR_SOURCE_ID, + trigger="Monitor", + ) + try: + run_id = await self._record_witnessed_run( + command, + principal_id=RUN_WITNESS_AGENT_ID, + correlation_id=self._deps.id_generator.new_id(), + ) + except asyncio.CancelledError: + raise + except UnauthorizedError: + # Configuration fault: the RunWitness principal is not + # granted RecordWitnessedRun. Log loudly; stay unopened so the + # next BEGUN retries once the grant is fixed (same posture + # as RunInitiator's StartRun grant). + _log.warning( + "run_witness.promotion_unauthorized", + capture_code=observation.capture_code, + ) + return + except Exception: + _log.exception( + "run_witness.promotion_failed", + capture_code=observation.capture_code, + ) + return + self._open_captures[observation.capture_code] = run_id + _log.info( + "run_witness.promoted", + capture_code=observation.capture_code, + run_id=str(run_id), + ) + + +def _extract_capture_code(external_refs: frozenset[Identifier]) -> str | None: + """Find the `Identifier(scheme="capture-code", ...)` entry's value. + + Defensive: `record_witnessed_run`'s decider always stamps exactly one, + so `None` should not happen for a Witnessed Run, but a missing ref + must not crash the boot-time rebuild. + """ + for ref in external_refs: + if ref.scheme == _CAPTURE_CODE_SCHEME: + return ref.value + return None + + +async def rebuild_open_captures(deps: Kernel, *, list_runs: ListRunsHandler) -> dict[str, UUID]: + """Page through every Running, Witnessed Run and return + capture_code -> run_id for each one's `external_refs`. + + Seeds `RunWitnessRecorder`'s dedup state once at boot so a capture + still open at process restart is never re-promoted. Mirrors + `_run_supervisor._drain_runs` / `_run_initiator._drain_running_runs`'s + exact paging shape. + """ + from cora.run.aggregates.run.read import load_run + + open_captures: dict[str, UUID] = {} + cursor: str | None = None + while True: + page = await list_runs( + ListRuns( + status="Running", + conduct_mode="Witnessed", + cursor=cursor, + limit=_PAGE_LIMIT, + ), + principal_id=RUN_WITNESS_AGENT_ID, + correlation_id=deps.id_generator.new_id(), + ) + for item in page.items: + run: Run | None = await load_run(deps.event_store, item.run_id) + if run is None: + continue + capture_code = _extract_capture_code(run.external_refs) + if capture_code is not None: + open_captures[capture_code] = item.run_id + if page.next_cursor is None: + return open_captures + cursor = page.next_cursor + + +async def run_witness_loop( + *, + observer: CaptureObserver, + capture_codes: frozenset[str], + recorder: RunWitnessRecorder | None = None, + reconnect_delay_seconds: float = _RECONNECT_DELAY_SECONDS, +) -> None: + """Drain the observer, logging (and, with a recorder, promoting) + each observation; re-subscribe on stream end.""" + if not capture_codes: + return + scope = CaptureObserverScope(capture_codes=capture_codes) + while True: + try: + async for observation in observer.observe(scope): + try: + if recorder is not None: + await recorder.observe_capture(observation) + else: + observe_capture(observation) + except asyncio.CancelledError: + raise + except Exception: + _log.exception( + "run_witness.record_failed", + capture_code=observation.capture_code, + ) + except asyncio.CancelledError: + raise + except Exception: + _log.exception("run_witness.iteration_failed") + await asyncio.sleep(reconnect_delay_seconds) + + +@contextlib.asynccontextmanager +async def run_witness_lifespan( + *, + observer: CaptureObserver, + capture_codes: frozenset[str], + deps: Kernel | None = None, + record_witnessed_run: RecordWitnessedRunHandler | None = None, + open_captures: dict[str, UUID] | None = None, +) -> AsyncGenerator[None]: + """Run the watcher as a background task for the app's lifetime. + + No-op when `capture_codes` is empty: yields immediately without + starting a task, mirroring `enclosure_permit_monitor_lifespan`'s + no-op-when-unconfigured shape. + + `deps` stays optional (unlike the sibling `run_supervisor_lifespan` + / `run_initiator_lifespan`, which require it) so every existing + shadow-only caller needs no change: recording is the only thing that + needs a Kernel (for id generation), so it is only required when + `record_witnessed_run` is also supplied. + """ + if not capture_codes: + yield + return + if record_witnessed_run is not None and deps is None: + msg = "run_witness_lifespan: record_witnessed_run requires deps" + raise ValueError(msg) + + recorder: RunWitnessRecorder | None = None + if record_witnessed_run is not None: + assert deps is not None # narrowed by the check above + recorder = RunWitnessRecorder( + deps=deps, + record_witnessed_run=record_witnessed_run, + settings=deps.settings, + open_captures=open_captures, + ) + + task = asyncio.create_task( + run_witness_loop(observer=observer, capture_codes=capture_codes, recorder=recorder), + name="run-witness", + ) + try: + yield + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +__all__ = [ + "RUN_WITNESS_MONITOR_SOURCE_ID", + "RunWitnessRecorder", + "observe_capture", + "rebuild_open_captures", + "run_witness_lifespan", + "run_witness_loop", +] diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 858b85b8fca..f67fa08b04a 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -32,6 +32,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dataclasses import replace +from typing import TYPE_CHECKING from fastapi import FastAPI, Request, Response, status from mcp.server.fastmcp import FastMCP @@ -49,6 +50,7 @@ ) from cora.access.adapters import EventStorePrincipalLivenessLookup from cora.agent import ( + RUN_WITNESS_AGENT_ID, AgentHandlers, build_llm, refresh_language_model_pricing, @@ -70,17 +72,20 @@ seed_run_debriefer_agent, seed_run_initiator_agent, seed_run_supervisor_agent, + seed_run_witness_agent, wire_agent, ) from cora.agent.adapters import BudgetSpendGuard, PostgresLanguageModelLookup from cora.api._calibration_watcher import calibration_watcher_lifespan from cora.api._campaign_watcher import campaign_watcher_lifespan +from cora.api._capture_observer import ControlPortCaptureObserver from cora.api._clearance_expirer import clearance_expirer_lifespan from cora.api._clearance_watcher import clearance_watcher_lifespan from cora.api._conduct_run_route import register_conduct_run_routes from cora.api._conduct_run_tool import register_conduct_run_tools from cora.api._edge_conductor import ComputeRunDriver from cora.api._enclosure_permit_observer import ControlPortEnclosureObserver +from cora.api._flag_watcher import probe_read_grant from cora.api._inference_recorder import DelegatingInferenceRecorder from cora.api._procedure_watcher import procedure_watcher_lifespan from cora.api._readiness import ( @@ -91,6 +96,7 @@ ) from cora.api._run_initiator import run_initiator_lifespan from cora.api._run_supervisor import run_supervisor_lifespan +from cora.api._run_witness import rebuild_open_captures, run_witness_lifespan from cora.api.middleware import BodySizeLimitMiddleware from cora.api.protected_resource_metadata import register_protected_resource_metadata_route from cora.budget import ( @@ -222,6 +228,9 @@ register_run_tools, wire_run, ) +from cora.run import ( + UnauthorizedError as RunUnauthorizedError, +) from cora.run.adapters import PostgresRunActorInvolvementLookup from cora.safety import ( SafetyHandlers, @@ -259,6 +268,9 @@ ) from cora.trust.adapters import PostgresConsequenceLookup +if TYPE_CHECKING: + from uuid import UUID + def _settings_for_app() -> Settings: """Load Settings at app construction for one-shot wiring decisions. @@ -401,6 +413,35 @@ def _enforce_production_principal_policy(settings: Settings) -> None: raise RuntimeError(msg) +def _enforce_run_witness_recording_gate(settings: Settings) -> None: + """Refuse to boot with run_witness_recording_enabled=True unless both + prerequisites it depends on are also set. + + run_witness_recording_enabled promotes a BEGUN capture observation to + a real witnessed Run; that promotion needs (a) the shadow witness + itself running (run_witness_enabled) to ever see an observation, and + (b) a target Plan (capture_watch_plan_id) to bind the promoted Run + to. Catching the misconfiguration at boot is cheaper than discovering + it the first time a real capture begins and record_witnessed_run has + nowhere to point. + """ + if not settings.run_witness_recording_enabled: + return + missing: list[str] = [] + if not settings.run_witness_enabled: + missing.append("RUN_WITNESS_ENABLED=true") + if settings.capture_watch_plan_id is None: + missing.append("CAPTURE_WATCH_PLAN_ID=") + if missing: + msg = ( + "RUN_WITNESS_RECORDING_ENABLED=true requires " + f"{' and '.join(missing)}. Promotion has no shadow observer " + "to promote from, or no Plan to bind the promoted Run to, " + "without both." + ) + raise RuntimeError(msg) + + def _signing_factory_display_name(factory: object) -> str: """Name a signing factory for the boot-guard message. @@ -495,6 +536,7 @@ def create_app(*, settings: Settings | None = None) -> FastAPI: """ settings = settings if settings is not None else _settings_for_app() _enforce_production_principal_policy(settings) + _enforce_run_witness_recording_gate(settings) # Signing factories: in-memory stubs by default until the rule-of-two # wire-tier trigger fires (see Settings.allow_insecure_inmemory_signing @@ -965,6 +1007,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # same shape for ExperimentSteerer (deterministic L3 steering agent; # identity + Decision seam now, proactive driver loop in a later slice). await seed_experiment_steerer_agent(deps) + # same shape for RunWitness (deterministic capture-promotion agent). + await seed_run_witness_agent(deps) # Drain Federation-owned projections so the Postgres-backed # FacilityLookup.list_active() resolves the self-Facility row @@ -1012,6 +1056,64 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: tick_seconds=settings.enclosure_permit_probe_tick_seconds, ) + # RunWitness: shadow-observe an external tool's captures (2-BM + # commissioning ladder rung 1), and (behind the SECOND, + # independent run_witness_recording_enabled gate) promote a + # BEGUN capture to a real witnessed Run. run_witness_enabled is + # a SEPARATE gate from having capture_watch_pvs configured, + # so a deployment can declare the PVs ahead of turning the + # watcher on. No-op (empty capture_codes) when either is + # unset; recording stays off (shadow-only, writes nothing) + # unless run_witness_recording_enabled is also True. + capture_watch_observer = ControlPortCaptureObserver( + control_port=app.state.operation.control_port, + capture_pvs=settings.capture_watch_pvs, + status_phases=settings.capture_status_phases, + tick_seconds=settings.capture_watch_probe_tick_seconds, + ) + capture_watch_codes: frozenset[str] = ( + frozenset(settings.capture_watch_pvs) + if settings.run_witness_enabled + else frozenset() + ) + # Boot-time restart-rebuild: seed the dedup map from every + # currently-Running Witnessed Run's external_refs, so a + # capture still open across a restart is never re-promoted. + # Skipped entirely when the watcher is not configured at all. + # Probe the ListRuns read grant first (mirrors run_initiator_lifespan's + # probe_read_grant calls): raises a clear, named error in strict + # mode rather than the rebuild below failing with a bare + # UnauthorizedError. In non-strict mode the probe only warns, so + # the rebuild itself is also wrapped: every other watcher's read + # lives inside its own per-tick try/except and a missing grant + # never brings down more than that watcher; this one-time boot + # read had no such guard and would otherwise crash the entire + # app's boot over a single misconfigured grant. Falling back to + # an empty map on that failure degrades to "cold start" (a still- + # open capture could be re-promoted once) rather than refusing + # every other BC's routes. + open_captures: dict[str, UUID] = {} + if capture_watch_codes: + await probe_read_grant( + deps, + agent_id=RUN_WITNESS_AGENT_ID, + read_command="ListRuns", + log_prefix="run_witness", + strict=settings.watcher_authz_strict, + ) + try: + open_captures = await rebuild_open_captures( + deps, list_runs=app.state.run.list_runs + ) + except RunUnauthorizedError: + _log.warning( + "run_witness.rebuild_unauthorized", + reason=( + "ListRuns grant missing for RUN_WITNESS_AGENT_ID; " + "starting with an empty dedup map" + ), + ) + try: async with ( projection_worker_lifespan(deps, registry, settings), @@ -1062,6 +1164,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: deps, list_campaigns=app.state.campaign.list_campaigns, ), + run_witness_lifespan( + observer=capture_watch_observer, + capture_codes=capture_watch_codes, + deps=deps, + record_witnessed_run=app.state.run.record_witnessed_run, + open_captures=open_captures, + ), ): yield finally: diff --git a/apps/api/src/cora/api/pilot_seed.py b/apps/api/src/cora/api/pilot_seed.py index 337b5290a9d..f30f38a3f2b 100644 --- a/apps/api/src/cora/api/pilot_seed.py +++ b/apps/api/src/cora/api/pilot_seed.py @@ -1,16 +1,46 @@ -"""The pilot seed ceremony: give a CORA instance the beamline ingest needs. - -`python -m cora.api.pilot_seed` registers, idempotently, the minimum a -deployment must know before `ingest_scan` can record anything: the -beamline root Unit Asset (facility-bound per the anchoring XOR), the -camera Device Asset, its Camera family attachment (whose seed roster -carries Capturing), and one Storage-kind Supply. Inputs are explicit -CLI arguments; the ceremony reads no descriptor. The full -descriptor-reconciling onboarding is a deliberate later slice with its -own trigger (see project_beamline_seeder_design), because 52 of the -descriptor's 53 instances have no production reader in the read-only -pilot and the two things ingest needs are exactly the two the -descriptor cannot provide. +"""The pilot seed ceremony: give a CORA instance what the 2-BM pilot needs. + +`python -m cora.api.pilot_seed` registers, idempotently, two things a +deployment must know before it can do anything real: what `ingest_scan` +needs to record a capture, and what `start_run` needs to have a real +`plan_id` to bind to. Inputs are explicit CLI arguments; the ceremony +reads no descriptor. The full descriptor-reconciling onboarding is a +deliberate later slice with its own trigger (see +project_beamline_seeder_design), because 52 of the descriptor's 53 +instances have no production reader in the read-only pilot and the +things this ceremony needs are exactly the ones the descriptor cannot +provide. + +Registers: + - the beamline root Unit Asset (facility-bound per the anchoring + XOR), a camera Device Asset with its Camera family attachment + (whose seed roster carries Capturing), and one Storage-kind Supply + -- what `ingest_scan` needs. + - a StationShutter Device Asset and a second camera Device Asset (the + 5 MP unit `docs/deployments/2-bm/recipes.md`'s `dark_field` / + `flat_field` recipes actually target, distinct from the first + camera), each declaring `located_in_enclosure_id` for `2-BM-B` + (resolved by name, never hardcoded -- see + `docs/deployments/2-bm/enclosures.md`), plus the Capability -> + Method -> Practice -> Plan chain for those same two recipes, bound + to the two new Assets -- what `start_run` needs, including the + Enclosure-permit gate: `located_in_enclosure_id` is genesis-only + (no update command exists), and a Run whose scoped Assets declare + NONE is Permit-by-default, so leaving it unset is a silent gate + skip, not a cosmetic gap. `energy_setting` and `hexapod_reboot` + stay unregistered: `recipes.md` marks both "design, pending + executor", so a Plan for either would fail on its first conduct + step. + + A ONE-TIME migration lives permanently in this file: the first + StationShutter/Camera registration (pre-2026-08-14) had no + `located_in_enclosure_id`. Fixing that on an already-registered + Asset means decommission + re-register (Lock A precedent, same as + `controller_id`); the two Plans binding the old ids are deprecated + and replaced by `_v2` Plans binding the new ones. Capability / + Method / Practice are untouched -- none reference a specific Asset + id. A deployment seeded fresh after this change never sees the old + names at all; it goes straight to the located `_v2` registration. ## Identity is per-aggregate, matching each aggregate's locked design @@ -23,9 +53,11 @@ and at MAX IV must share one id or Assembly content hashes fork), and definitions belong to the seed registry's graduation governance. - - Enclosures: not touched. They are boot-seeded with minted ids and - an address pre-check precisely because deterministic ids would - collide with tombstones on re-register. + - Enclosures: RESOLVED via `seed_enclosures(kernel)`, the same + idempotent seed the real app's boot lifespan runs -- never + created with a ceremony-local id. Minted at boot, with an address + pre-check, precisely because deterministic ids would collide with + tombstones on re-register. - Supplies: minted id; idempotency comes from an address pre-check against the supply projection, mirroring the partial-unique address that makes deregister-then-re-register legal. @@ -36,16 +68,20 @@ production fills from projections, and a standalone kernel has no projection worker. The ceremony therefore runs the same idempotent bootstrap hooks the app lifespan runs (federation for the -self-Facility, equipment for roles and families) and drains the -relevant projections between stages; without that, a fresh database -refuses every registration with FacilityNotFound. +self-Facility, equipment for roles and families, enclosure for +2-BM-A / 2-BM-B) and drains the relevant projections between stages; +without that, a fresh database refuses every registration with +FacilityNotFound. ## What a re-run does -Nothing, loudly. Every instance reports one of: seeded, exists, -retired (the stream folds to Decommissioned; the ceremony never -resurrects a tombstone, since decommission-then-re-register is the -operator's rebind path, not the seeder's), or error. Exit codes: 0 +Nothing, loudly, for everything except the one deliberate migration +this file performs on itself (see the `_v2` Asset/Plan registrations +above): every OTHER instance reports one of: seeded, exists, retired +(the stream folds to Decommissioned; the ceremony never resurrects a +tombstone read on a name it did not itself just decommission, since +decommission-then-re-register under a NEW id is the only rebind path, +never a re-append to the old one), or error. Exit codes: 0 when everything already existed, 2 when anything was seeded, 1 on any error. A `--dry-run` prints the same report and writes nothing. """ @@ -53,9 +89,14 @@ import argparse import asyncio import sys +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypeVar from uuid import UUID, uuid5 +from cora.enclosure._enclosure_seed import seed_enclosures +from cora.enclosure._projections import register_enclosure_projections +from cora.enclosure.adapters.postgres_enclosure_lookup import PostgresEnclosureLookup from cora.equipment._bootstrap import bootstrap_equipment, bootstrap_families from cora.equipment._projections import register_equipment_projections from cora.equipment.aggregates.asset import Asset, AssetLifecycle, AssetTier @@ -67,6 +108,9 @@ from cora.equipment.aggregates.family.read import load_family from cora.equipment.features.add_asset_family.command import AddAssetFamily from cora.equipment.features.add_asset_family.decider import decide as decide_add_family +from cora.equipment.features.decommission_asset.command import DecommissionAsset +from cora.equipment.features.decommission_asset.context import DecommissionAssetContext +from cora.equipment.features.decommission_asset.decider import decide as decide_decommission_asset from cora.equipment.features.register_asset.command import RegisterAsset from cora.equipment.features.register_asset.decider import decide as decide_asset from cora.federation._bootstrap import bootstrap_federation @@ -87,6 +131,33 @@ from cora.infrastructure.projection.worker import ProjectionRegistry from cora.infrastructure.routing import SYSTEM_PRINCIPAL_ID from cora.infrastructure.schema_version import verify_schema_version +from cora.recipe.aggregates.capability import Capability, ExecutorShape +from cora.recipe.aggregates.capability.events import event_type_name as capability_event_type_name +from cora.recipe.aggregates.capability.events import to_payload as capability_to_payload +from cora.recipe.aggregates.capability.read import load_capability +from cora.recipe.aggregates.method import ExecutionPattern, Method +from cora.recipe.aggregates.method.events import event_type_name as method_event_type_name +from cora.recipe.aggregates.method.events import to_payload as method_to_payload +from cora.recipe.aggregates.method.read import load_method +from cora.recipe.aggregates.plan.events import event_type_name as plan_event_type_name +from cora.recipe.aggregates.plan.events import to_payload as plan_to_payload +from cora.recipe.aggregates.plan.read import load_plan +from cora.recipe.aggregates.plan.state import PlanStatus +from cora.recipe.aggregates.practice.events import event_type_name as practice_event_type_name +from cora.recipe.aggregates.practice.events import to_payload as practice_to_payload +from cora.recipe.aggregates.practice.read import load_practice +from cora.recipe.features.define_capability.command import DefineCapability +from cora.recipe.features.define_capability.decider import decide as decide_capability +from cora.recipe.features.define_method.command import DefineMethod +from cora.recipe.features.define_method.decider import decide as decide_method +from cora.recipe.features.define_plan.command import DefinePlan +from cora.recipe.features.define_plan.context import PlanBindingContext +from cora.recipe.features.define_plan.decider import decide as decide_plan +from cora.recipe.features.define_practice.command import DefinePractice +from cora.recipe.features.define_practice.decider import decide as decide_practice +from cora.recipe.features.deprecate_plan.command import DeprecatePlan +from cora.recipe.features.deprecate_plan.decider import decide as decide_deprecate_plan +from cora.shared.deprecation import DeprecationReason from cora.shared.facility_code import FacilityCode from cora.shared.identity import ActorId from cora.supply._projections import register_supply_projections @@ -99,23 +170,41 @@ from cora.supply.features.register_supply.command import RegisterSupply from cora.supply.features.register_supply.decider import decide as decide_supply +if TYPE_CHECKING: + from cora.recipe.aggregates.plan import Plan + from cora.recipe.aggregates.practice import Practice + #: Namespace for the ceremony's deterministic Asset identities. Path- #: qualified keys under it ("aps:2-bm:asset:") make re-runs #: idempotent without reserving bare names repo-wide (the Role/Imager #: lesson) and keep two beamlines' identically named devices distinct. ASSET_SEED_NAMESPACE = UUID("6c1f4a52-8f2e-4bb0-9d59-1a4c9be1a23d") +#: Namespace for the ceremony's deterministic Recipe-BC identities +#: (Capability / Method / Practice / Plan). Recipe aggregates have no +#: `uuid5` registry of their own (unlike Family's bare-name-derived +#: ids), and this deployment-specific ladder is not cross-facility +#: vocabulary, so it is facility+beamline-qualified like Assets, under +#: its own namespace rather than reusing `ASSET_SEED_NAMESPACE`. +RECIPE_SEED_NAMESPACE = UUID("48eb0d48-8fc2-482c-9e9e-d3547b1ff37b") + _COMMAND_NAME = "SeedPilotBeamline" _EXIT_CLEAN = 0 _EXIT_ERROR = 1 _EXIT_SEEDED = 2 +_T = TypeVar("_T") + def asset_seed_id(facility_code: str, beamline: str, name: str) -> UUID: return uuid5(ASSET_SEED_NAMESPACE, f"{facility_code}:{beamline}:asset:{name}") +def recipe_seed_id(facility_code: str, beamline: str, kind: str, name: str) -> UUID: + return uuid5(RECIPE_SEED_NAMESPACE, f"{facility_code}:{beamline}:{kind}:{name}") + + @dataclass class _Report: lines: list[str] @@ -140,6 +229,9 @@ async def seed_pilot_beamline( camera_family_name: str, supply_name: str, dry_run: bool, + shutter_name: str = "StationShutter", + acquisition_camera_name: str = "AcquisitionCamera", + rotary_stage_name: str = "RotaryStage", database_url: str | None = None, ) -> int: """Run the ceremony. `database_url` overrides the Settings value so @@ -166,12 +258,14 @@ async def seed_pilot_beamline( authz=AllowAllAuthorize(), facility_lookup=PostgresFacilityLookup(pool), supply_lookup=PostgresSupplyLookup(pool), + enclosure_lookup=PostgresEnclosureLookup(pool), ) registry = ProjectionRegistry() register_federation_projections(registry, kernel) register_equipment_projections(registry, kernel) register_supply_projections(registry, kernel) + register_enclosure_projections(registry, kernel) # Prerequisites the app lifespan normally seeds. All # idempotent; a dry run still runs them so its report reads @@ -179,6 +273,13 @@ async def seed_pilot_beamline( await bootstrap_federation(kernel) await bootstrap_equipment(kernel) await bootstrap_families(kernel) + # Same function the real app's own boot lifespan calls; on + # arcturus (already seeded) this just resolves the existing + # 2-BM-A / 2-BM-B ids, on a fresh database it seeds them for + # real. Never hardcode these ids: they are minted at boot, not + # deterministic (unlike Assets), so a stale literal would + # silently resolve nothing on a re-seeded database. + enclosure_ids_by_name = await seed_enclosures(kernel) await drain_projections(pool, registry) code = FacilityCode(facility_code) @@ -191,6 +292,15 @@ async def seed_pilot_beamline( ) return _finish(report, dry_run) + enclosure_b_id = enclosure_ids_by_name.get("2-BM-B") + if enclosure_b_id is None: + report.note( + "error", + "enclosure 2-BM-B", + "not configured; is ENCLOSURE_PERMIT_PVS set for this deployment?", + ) + return _finish(report, dry_run) + actor = ActorId(SYSTEM_PRINCIPAL_ID) clock = kernel.clock ids = kernel.id_generator @@ -293,37 +403,189 @@ async def seed_asset(asset_id: UUID, command: RegisterAsset, label: str) -> Asse # history), so an operator who deliberately detached the family # will see it re-attach on the next run; acceptable for the # pilot camera, revisited if a real detach case appears. - if camera is not None: - if family_id in camera.family_ids: - report.note("exists", f"{camera_name} family attachment") - elif dry_run: - report.note("seeded", f"{camera_name} family attachment", "dry-run, not written") - else: - current_state, current_version = await _load_asset_with_version(kernel, camera_id) - attach_events = decide_add_family( - state=current_state, - command=AddAssetFamily(asset_id=camera_id, family_id=family_id), - now=clock.now(), + async def attach_family( + asset: Asset | None, asset_id: UUID, family_id: UUID, label: str + ) -> None: + if asset is None: + return + if family_id in asset.family_ids: + report.note("exists", f"{label} family attachment") + return + if dry_run: + report.note("seeded", f"{label} family attachment", "dry-run, not written") + return + current_state, current_version = await _load_asset_with_version(kernel, asset_id) + attach_events = decide_add_family( + state=current_state, + command=AddAssetFamily(asset_id=asset_id, family_id=family_id), + now=clock.now(), + ) + attach_envelopes = [ + to_new_event( + event_type=asset_event_type_name(event), + payload=asset_to_payload(event), + occurred_at=event.occurred_at, + event_id=ids.new_id(), + command_name=_COMMAND_NAME, + correlation_id=run_correlation_id, + principal_id=SYSTEM_PRINCIPAL_ID, ) - attach_envelopes = [ - to_new_event( - event_type=asset_event_type_name(event), - payload=asset_to_payload(event), - occurred_at=event.occurred_at, - event_id=ids.new_id(), - command_name=_COMMAND_NAME, - correlation_id=run_correlation_id, - principal_id=SYSTEM_PRINCIPAL_ID, - ) - for event in attach_events - ] - await kernel.event_store.append( - stream_type="Asset", - stream_id=camera_id, - expected_version=current_version, - events=attach_envelopes, + for event in attach_events + ] + await kernel.event_store.append( + stream_type="Asset", + stream_id=asset_id, + expected_version=current_version, + events=attach_envelopes, + ) + report.note("seeded", f"{label} family attachment") + + await attach_family(camera, camera_id, family_id, camera_name) + + # ----- Recipe BC prerequisite Assets: what dark_field / flat_field + # actually target, per docs/deployments/2-bm/recipes.md ----- + + async def decommission_if_present(old_id: UUID, label: str) -> None: + """One-time migration step, permanent in this file: the first + registration of this Asset (pre-2026-08-14) had no + `located_in_enclosure_id`. No-op when the old stream never + existed (a fresh deployment goes straight to the located `_v2` + registration below and never sees this Asset name at all) or + is already Decommissioned (a prior run already migrated it). + """ + old_state, old_version = await _load_asset_with_version(kernel, old_id) + if old_state is None: + return + if old_state.lifecycle is AssetLifecycle.DECOMMISSIONED: + report.note("exists", f"asset {label} (v1) decommissioned") + return + if dry_run: + report.note("seeded", f"asset {label} (v1) decommissioned", "dry-run, not written") + return + decommission_events = decide_decommission_asset( + state=old_state, + command=DecommissionAsset( + asset_id=old_id, + reason=( + "relocated: located_in_enclosure_id was never set at genesis; " + "superseded by a 2-BM-B-located registration" + ), + ), + context=DecommissionAssetContext(currently_installed_at_mount_id=None), + now=clock.now(), + decommissioned_by=actor, + ) + decommission_envelopes = [ + to_new_event( + event_type=asset_event_type_name(event), + payload=asset_to_payload(event), + occurred_at=event.occurred_at, + event_id=ids.new_id(), + command_name=_COMMAND_NAME, + correlation_id=run_correlation_id, + principal_id=SYSTEM_PRINCIPAL_ID, ) - report.note("seeded", f"{camera_name} family attachment") + for event in decommission_events + ] + await kernel.event_store.append( + stream_type="Asset", + stream_id=old_id, + expected_version=old_version, + events=decommission_envelopes, + ) + report.note("seeded", f"asset {label} (v1) decommissioned") + + await decommission_if_present( + asset_seed_id(facility_code, beamline, shutter_name), shutter_name + ) + await decommission_if_present( + asset_seed_id(facility_code, beamline, acquisition_camera_name), + acquisition_camera_name, + ) + + # Located registration. New deterministic ids under a versioned + # SEED KEY only (asset_seed_id hashes on this key, not on + # RegisterAsset.name): the v1 id above is no longer at + # expected_version=0 once decommissioned, so this ceremony cannot + # reuse it. `RegisterAsset(name=...)` keeps the clean, unsuffixed + # display name; "_v2" exists only in the id-derivation key, never + # operator-visible. + shutter_id = asset_seed_id(facility_code, beamline, f"{shutter_name}_v2") + acquisition_camera_id = asset_seed_id( + facility_code, beamline, f"{acquisition_camera_name}_v2" + ) + + shutter = await seed_asset( + shutter_id, + RegisterAsset( + name=shutter_name, + tier=AssetTier.DEVICE, + parent_id=root_id, + facility_code=None, + located_in_enclosure_id=enclosure_b_id, + ), + f"asset {shutter_name} (Device, 2-BM-B)", + ) + acquisition_camera = await seed_asset( + acquisition_camera_id, + RegisterAsset( + name=acquisition_camera_name, + tier=AssetTier.DEVICE, + parent_id=root_id, + facility_code=None, + located_in_enclosure_id=enclosure_b_id, + ), + f"asset {acquisition_camera_name} (Device, 2-BM-B)", + ) + + # No legacy un-located registration to migrate away from (unlike + # shutter/camera): this is a brand-new Asset, so a plain + # (unsuffixed) seed key is correct, matching fly_scan's own Plan + # using `_v1` rather than `_v2`. + rotary_stage_id = asset_seed_id(facility_code, beamline, rotary_stage_name) + rotary_stage = await seed_asset( + rotary_stage_id, + RegisterAsset( + name=rotary_stage_name, + tier=AssetTier.DEVICE, + parent_id=root_id, + facility_code=None, + located_in_enclosure_id=enclosure_b_id, + ), + f"asset {rotary_stage_name} (Device, 2-BM-B)", + ) + + shutter_family_id = family_stream_id(FamilyName("Shutter")) + shutter_family = await load_family(kernel.event_store, shutter_family_id) + if shutter_family is None: + report.note("error", "family Shutter", "not seeded; unknown family name") + return _finish(report, dry_run) + report.note("exists", "family Shutter") + + # RotaryStage is a globally-bootstrapped family (see + # `_family_seed_registry.py`), same precondition as Shutter above. + # Continuous sample rotation is the defining feature of a real + # fly-scan (docs/deployments/2-bm/techniques.md); the fly_scan + # recipe below is the only caller that needs this family/Asset. + rotary_family_id = family_stream_id(FamilyName("RotaryStage")) + rotary_family = await load_family(kernel.event_store, rotary_family_id) + if rotary_family is None: + report.note("error", "family RotaryStage", "not seeded; unknown family name") + return _finish(report, dry_run) + report.note("exists", "family RotaryStage") + + await attach_family(shutter, shutter_id, shutter_family_id, shutter_name) + await attach_family( + acquisition_camera, acquisition_camera_id, family_id, acquisition_camera_name + ) + await attach_family(rotary_stage, rotary_stage_id, rotary_family_id, rotary_stage_name) + # Reload: `attach_family` writes the attachment but returns nothing, + # and the Plan step below needs each Asset's CURRENT family_ids + # (the Recipe-BC family-superset check reads them), not the + # pre-attachment snapshot `seed_asset` returned above. + shutter = await load_asset(kernel.event_store, shutter_id) + acquisition_camera = await load_asset(kernel.event_store, acquisition_camera_id) + rotary_stage = await load_asset(kernel.event_store, rotary_stage_id) # Storage supply: minted id, address-pre-checked idempotency. supplies_by_kind = await kernel.supply_lookup.find_supplies_by_kind( @@ -425,6 +687,298 @@ async def seed_asset(asset_id: UUID, command: RegisterAsset, label: str) -> Asse ) report.note("seeded", f"supply {supply_name} availability") + # ----- Recipe BC: Capability -> Method -> Practice -> Plan ceremony ----- + # + # A real Run at 2-BM needs a valid plan_id (`start_run` walks + # Plan -> Practice -> Method -> Capability); nothing here has ever + # been registered. This registers exactly the two "conductible + # today" recipes from docs/deployments/2-bm/recipes.md + # (dark_field, flat_field, both reusing the registered `collect` + # action body) against the StationShutter + acquisition-camera + # Assets seeded above. + + async def seed_genesis( + *, + stream_type: str, + state: _T | None, + decide_thunk: Callable[[], Sequence[Any]], + event_type_name_fn: Callable[[Any], str], + to_payload_fn: Callable[[Any], dict[str, Any]], + stream_id: UUID, + label: str, + reload: Callable[[], Awaitable[_T | None]], + ) -> _T | None: + """Genesis-only append for a Recipe BC aggregate, mirroring + `seed_asset`'s shape (state=None check, decide, serialize, + append at expected_version=0, ConcurrencyError means already + present). + + Unlike `seed_asset`, `dry_run` is checked BEFORE calling + `decide_thunk`: a cross-aggregate decider here (Method needs + Capability, Plan needs Practice + Method + Assets) can + legitimately raise when an upstream dependency is merely + not-yet-WRITTEN under dry-run on a fresh database, and that + must read as "would seed", not a crash. This is the same + "skip a downstream step whose upstream id is unknown under + dry-run" posture the Supply availability step above already + takes, made explicit here because the dependency is an + object, not just an id. + """ + if state is not None: + report.note("exists", label) + return state + if dry_run: + report.note("seeded", label, "dry-run, not written") + return None + events = decide_thunk() + envelopes = [ + to_new_event( + event_type=event_type_name_fn(event), + payload=to_payload_fn(event), + occurred_at=event.occurred_at, + event_id=ids.new_id(), + command_name=_COMMAND_NAME, + correlation_id=run_correlation_id, + principal_id=SYSTEM_PRINCIPAL_ID, + ) + for event in events + ] + try: + await kernel.event_store.append( + stream_type=stream_type, + stream_id=stream_id, + expected_version=0, + events=envelopes, + ) + except ConcurrencyError: + report.note("exists", label, "raced another writer; already present") + return await reload() + report.note("seeded", label) + return await reload() + + capability_id = recipe_seed_id(facility_code, beamline, "capability", "acquisition") + capability: Capability | None = await seed_genesis( + stream_type="Capability", + state=await load_capability(kernel.event_store, capability_id), + decide_thunk=lambda: decide_capability( + state=None, + command=DefineCapability( + code="cora.capability.acquisition", + name="Acquisition", + # A dark/flat baseline capture opens or closes the + # station shutter and captures a frame stack; both + # are real preconditions of "Acquisition" at 2-BM, + # not an arbitrary choice. Covered by the union of + # the Shutter + Camera family affordances below. + required_affordances=frozenset({Affordance.SHUTTERABLE, Affordance.CAPTURING}), + # Only ever bound via Method in this ceremony; no + # Procedure realizes this Capability, so PROCEDURE + # is left out rather than added speculatively. + executor_shapes=frozenset({ExecutorShape.METHOD}), + ), + now=clock.now(), + new_id=capability_id, + ), + event_type_name_fn=capability_event_type_name, + to_payload_fn=capability_to_payload, + stream_id=capability_id, + label="capability cora.capability.acquisition", + reload=lambda: load_capability(kernel.event_store, capability_id), + ) + + # dark_field/flat_field need exactly the two Assets seeded above; + # no Scintillator or other microscope-family requirement, unlike + # the broader scenario-test fixtures for these same recipes + # (this ceremony registers a minimal, real, conductible pair, + # not the fuller test rig). fly_scan additionally needs the + # Rotary stage: continuous sample rotation is the defining + # feature of a real fly-scan, unlike a static baseline capture. + recipe_family_ids = frozenset({shutter_family_id, family_id}) + recipe_family_ids_with_rotary = recipe_family_ids | {rotary_family_id} + + async def seed_acquisition_recipe( + method_name: str, + practice_name: str, + plan_name: str, + *, + include_rotary: bool = False, + ) -> None: + needed_family_ids = ( + recipe_family_ids_with_rotary if include_rotary else recipe_family_ids + ) + method_id = recipe_seed_id(facility_code, beamline, "method", method_name) + method: Method | None = await seed_genesis( + stream_type="Method", + state=await load_method(kernel.event_store, method_id), + decide_thunk=lambda: decide_method( + state=None, + command=DefineMethod( + name=method_name, + capability_id=capability_id, + execution_pattern=ExecutionPattern.BATCH, + needed_family_ids=needed_family_ids, + ), + capability=capability, + now=clock.now(), + new_id=method_id, + ), + event_type_name_fn=method_event_type_name, + to_payload_fn=method_to_payload, + stream_id=method_id, + label=f"method {method_name}", + reload=lambda: load_method(kernel.event_store, method_id), + ) + + practice_id = recipe_seed_id(facility_code, beamline, "practice", practice_name) + practice: Practice | None = await seed_genesis( + stream_type="Practice", + state=await load_practice(kernel.event_store, practice_id), + decide_thunk=lambda: decide_practice( + state=None, + command=DefinePractice( + name=practice_name, + method_id=method_id, + # "Site-level Asset this Practice belongs to" + # (DefinePractice's own docstring); the root Unit + # Asset IS that binding point. No distinct Site + # aggregate exists in the codebase. + site_id=root_id, + ), + now=clock.now(), + new_id=practice_id, + ), + event_type_name_fn=practice_event_type_name, + to_payload_fn=practice_to_payload, + stream_id=practice_id, + label=f"practice {practice_name}", + reload=lambda: load_practice(kernel.event_store, practice_id), + ) + + plan_id = recipe_seed_id(facility_code, beamline, "plan", plan_name) + + def build_plan_events() -> list[Any]: + # Only ever called for real (never under dry-run, and + # never when this Plan already exists), by which point + # this same ceremony run has already written the + # Practice/Method/Assets it binds. The asserts are that + # invariant, not a runtime possibility. + assert practice is not None + assert method is not None + assert shutter is not None + assert acquisition_camera is not None + assets = { + shutter_id: shutter, + acquisition_camera_id: acquisition_camera, + } + family_affordances = { + shutter_family_id: shutter_family.affordances, + family_id: family.affordances, + } + if include_rotary: + assert rotary_stage is not None + assets[rotary_stage_id] = rotary_stage + family_affordances[rotary_family_id] = rotary_family.affordances + context = PlanBindingContext( + practice=practice, + method=method, + assets=assets, + capability=capability, + family_affordances=family_affordances, + ) + return decide_plan( + state=None, + command=DefinePlan( + name=plan_name, + practice_id=practice_id, + asset_ids=frozenset(assets), + ), + context=context, + now=clock.now(), + new_id=plan_id, + ) + + plan: Plan | None = await seed_genesis( + stream_type="Plan", + state=await load_plan(kernel.event_store, plan_id), + decide_thunk=build_plan_events, + event_type_name_fn=plan_event_type_name, + to_payload_fn=plan_to_payload, + stream_id=plan_id, + label=f"plan {plan_name}", + reload=lambda: load_plan(kernel.event_store, plan_id), + ) + _ = plan + + async def deprecate_plan_if_present(old_plan_name: str) -> None: + """One-time migration step, permanent in this file: the Plans + bound to the pre-2026-08-14 (un-located) Assets are superseded + by the `_v2` Plans below. No-op when the old Plan never existed + (fresh deployment) or is already Deprecated (prior run already + migrated it). Hygiene, not a safety requirement: `start_run` + already refuses any Plan bound to a Decommissioned Asset + regardless of this step. + """ + old_plan_id = recipe_seed_id(facility_code, beamline, "plan", old_plan_name) + old_plan = await load_plan(kernel.event_store, old_plan_id) + if old_plan is None: + return + if old_plan.status is PlanStatus.DEPRECATED: + report.note("exists", f"plan {old_plan_name} deprecated") + return + if dry_run: + report.note("seeded", f"plan {old_plan_name} deprecated", "dry-run, not written") + return + _, old_plan_version = await kernel.event_store.load("Plan", old_plan_id) + deprecate_events = decide_deprecate_plan( + state=old_plan, + command=DeprecatePlan(plan_id=old_plan_id, reason=DeprecationReason.SUPERSEDED), + now=clock.now(), + ) + deprecate_envelopes = [ + to_new_event( + event_type=plan_event_type_name(event), + payload=plan_to_payload(event), + occurred_at=event.occurred_at, + event_id=ids.new_id(), + command_name=_COMMAND_NAME, + correlation_id=run_correlation_id, + principal_id=SYSTEM_PRINCIPAL_ID, + ) + for event in deprecate_events + ] + await kernel.event_store.append( + stream_type="Plan", + stream_id=old_plan_id, + expected_version=old_plan_version, + events=deprecate_envelopes, + ) + report.note("seeded", f"plan {old_plan_name} deprecated") + + await deprecate_plan_if_present("2BM_dark_field_plan") + await deprecate_plan_if_present("2BM_flat_field_plan") + + await seed_acquisition_recipe( + "dark_field", "2BM_dark_field_practice", "2BM_dark_field_plan_v2" + ) + await seed_acquisition_recipe( + "flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan_v2" + ) + # The actual 2-BM TomoScan workflow the RunWitness's promotion path + # (cora.api._run_witness) watches: a fly-scan capture, distinct from + # the two conductible baseline captures above. Watch-only, not + # conducted: no operator REST/UI surface ever selects this Plan for + # start_run, matching record_witnessed_run's own stub route/tool. + # `_v1`, not `_v2`: there is no prior un-located fly_scan Plan to + # supersede via deprecate_plan_if_present. include_rotary=True: a + # real fly-scan's defining feature is continuous sample rotation, + # unlike the two static baseline captures above. + await seed_acquisition_recipe( + "fly_scan", + "2BM_fly_scan_practice", + "2BM_fly_scan_plan_v1", + include_rotary=True, + ) + _ = root if not dry_run: # Leave the projections current so a re-run's supply @@ -479,9 +1033,13 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m cora.api.pilot_seed", description=( - "Register the minimum a deployment needs before ingest_scan " - "can record: the beamline root Unit, the camera Device with " - "its Capturing-bearing family, and a Storage supply. " + "Register what a deployment needs before ingest_scan can " + "record (the beamline root Unit, a camera Device with its " + "Capturing-bearing family, a Storage supply) and before " + "start_run has a real plan_id to bind to (a StationShutter " + "and a second camera Device located in 2-BM-B, plus the " + "Capability -> Method -> Practice -> Plan chain for the " + "dark_field / flat_field recipes, bound to those two). " "Idempotent; re-runs report and change nothing." ), ) @@ -491,6 +1049,14 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--camera-name", default="Camera") parser.add_argument("--camera-family-name", default="Camera") parser.add_argument("--supply-name", default="analysis-tier") + parser.add_argument("--shutter-name", default="StationShutter") + # Default deliberately distinct from --camera-name's own default + # ("Camera"): both default to that name would derive the SAME + # asset_seed_id and collide. Pass --acquisition-camera-name Camera + # explicitly for a deployment (like 2-BM) where --camera-name + # already names a different physical camera under an override. + parser.add_argument("--acquisition-camera-name", default="AcquisitionCamera") + parser.add_argument("--rotary-stage-name", default="RotaryStage") parser.add_argument("--dry-run", action="store_true") return parser @@ -505,6 +1071,9 @@ def main(argv: list[str] | None = None) -> int: camera_name=args.camera_name, camera_family_name=args.camera_family_name, supply_name=args.supply_name, + shutter_name=args.shutter_name, + acquisition_camera_name=args.acquisition_camera_name, + rotary_stage_name=args.rotary_stage_name, dry_run=args.dry_run, ) ) diff --git a/apps/api/src/cora/enclosure/aggregates/enclosure/permit_probes.py b/apps/api/src/cora/enclosure/aggregates/enclosure/permit_probes.py index d11be3c5cc2..866b6c7a872 100644 --- a/apps/api/src/cora/enclosure/aggregates/enclosure/permit_probes.py +++ b/apps/api/src/cora/enclosure/aggregates/enclosure/permit_probes.py @@ -15,34 +15,22 @@ entries_* table is REVOKEd from UPDATE, and there is no natural key to deduplicate against (`event_id` is a fresh id per observation), so unlike `FeedHeartbeatStore` this store does not need `ON CONFLICT`. + +`ReachTier` itself is hoisted to `cora.shared.reach`: the Run BC's +capture-observe seam needed the identical vocabulary and `cora.run` +cannot depend on `cora.enclosure.aggregates` (tach). Re-exported here +so every existing import of `ReachTier` from this module keeps working. """ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false from dataclasses import dataclass -from enum import StrEnum from typing import Protocol from uuid import UUID import asyncpg - -class ReachTier(StrEnum): - """How CORA reached the permit substrate for one observation. - - Two values ship in v1. `RELAYED` means CORA received or fetched a - value through the configured channel; `UNREACHED` means it could - not, this tick. A stronger tier for a confirmed direct round trip to - the authoritative source (as opposed to an intermediary, such as an - EPICS CA gateway that may answer from its own cache) is deliberately - NOT defined here: no producer in this codebase can currently prove - one, and an unearned strong claim is worse than none. Adding a value - later needs no migration, since the column is a length-CHECK, not a - value-enumerating CHECK. - """ - - RELAYED = "Relayed" - UNREACHED = "Unreached" +from cora.shared.reach import ReachTier @dataclass(frozen=True) diff --git a/apps/api/src/cora/infrastructure/config.py b/apps/api/src/cora/infrastructure/config.py index c62be7d08a8..00ce79e7d6f 100644 --- a/apps/api/src/cora/infrastructure/config.py +++ b/apps/api/src/cora/infrastructure/config.py @@ -13,6 +13,7 @@ from cora.infrastructure.auth.config import IdentityProviderConfig from cora.infrastructure.control_port_route import ControlPortRoute +from cora.shared.capture_phase import CapturePhase _ALLOWED_DATABASE_SCHEMES = ("postgresql://", "postgres://") @@ -710,6 +711,115 @@ class Settings(BaseSettings): # See `cora.operation.adapters.control_port_beam_availability_lookup`. beam_availability_pvs: dict[str, str] = {} + # Capture-observe seam (2-BM commissioning ladder rung 1: watch a + # TomoScan capture live rather than learn of it from a staged file). + # Outer key is the capture code (2-BM runs several tomoscan variants + # off the same base class, e.g. tomoscan_2bm / tomoscan_2bm_step / + # tomoscan_fpga_2bm; each gets its own code); inner dict is + # role -> read-only PV. `status` is the only role every variant must + # provide; the rest are optional per variant. When empty (default) + # the capture-watch runtime is a no-op, so a generic boot is + # unaffected. Read from CAPTURE_WATCH_PVS as JSON: + # + # CAPTURE_WATCH_PVS='{ + # "2bmb-tomoscan": { + # "status": "2bmb:TomoScan:ScanStatus", + # "server_running": "2bmb:TomoScan:ServerRunning", + # "abort": "2bmb:TomoScan:AbortScan", + # "images_saved": "2bmb:TomoScan:ImagesSaved", + # "images_collected": "2bmb:TomoScan:ImagesCollected" + # } + # }' + # + # `status` is a DBR_CHAR waveform at 2-BM; the deployment's + # CONTROL_PORT_ROUTES must declare it in `text_addresses` or it + # decodes as raw bytes, not text. See `cora.api._capture_observer`. + capture_watch_pvs: dict[str, dict[str, str]] = {} + + # The `status` role's raw substrate literal, mapped onto CORA's + # closed `CapturePhase` vocabulary. These strings belong to one + # tomoscan commit at one facility and MUST NOT be hardcoded in the + # spine: 2-BM's `decarlof/tomoscan` reports free text like + # "Beginning scan" / "Collecting projections" / "Scan complete" on + # `ScanStatus`, and a different facility or a later tomoscan commit + # may use different words for the same phase. A literal absent from + # this table classifies as CapturePhase.UNRECOGNIZED rather than + # being silently dropped or coerced into a nearby phase, so a + # vocabulary drift (a tool upgrade renaming a status) is visible in + # the watcher's log rather than misread as routine progress. Applies + # across every code in `capture_watch_pvs`: the deployed variants + # are confirmed byte-identical forks of one tomoscan base class, so + # one shared table is the fact on the ground, not a shortcut. + # + # CAPTURE_STATUS_PHASES='{ + # "Beginning scan": "Begun", + # "Programming PSO": "Progressing", + # "Collecting dark fields": "Progressing", + # "Collecting flat fields": "Progressing", + # "Collecting projections": "Progressing", + # "fdt file transfer complete": "Progressing", + # "scp file transfer complete": "Progressing", + # "Scan complete": "Ended", + # "Scan aborted": "Aborted" + # }' + # + # NOTE the fdt / scp transfer messages map to Progressing, not + # Ended: they mark transfer START, not arrival, per + # docs/deployments/2-bm/operations.md. + capture_status_phases: dict[str, str] = {} + + # Bounds how often the capture-watch runtime re-reads each + # configured `status` PV independent of push traffic, mirroring + # `enclosure_permit_probe_tick_seconds`. `None` (default) disables + # polling entirely: reach is then push-only. OPERATIONAL KILL + # SWITCH, not just a test convenience. Irrelevant when + # `capture_watch_pvs` is empty. + capture_watch_probe_tick_seconds: float | None = None + + # Runs the capture-watch loop in shadow mode: drains observations, + # maps them through `capture_status_phases`, and logs. Writes + # nothing (no event, no entries row, no Run) unless + # `run_witness_recording_enabled` is ALSO True (see below). Default + # off; irrelevant when `capture_watch_pvs` is empty. See + # `cora.api._run_witness`. + run_witness_enabled: bool = False + + # Which Plan a promoted witnessed Run references (record_witnessed_run's + # plan_id). Deployment-declared, not read from the substrate: + # TomoScan reports a scan began, never which Plan it corresponds to. + # `None` (default) disables promotion regardless of + # `run_witness_recording_enabled` (see + # `_enforce_run_witness_recording_gate` in `main.py`). + capture_watch_plan_id: UUID | None = None + + # SECOND, independent kill switch above `run_witness_enabled`: + # shadow mode (drain + log) stays default-on once `run_witness_enabled` + # is True; this flag additionally gates whether a BEGUN observation is + # actually promoted to a real witnessed Run via `record_witnessed_run`. + # Default off, so enabling `run_witness_enabled` alone stays + # shadow-only, unchanged from today. OPERATIONAL KILL SWITCH: boot + # refuses to start if this is True without both + # `run_witness_enabled=True` and `capture_watch_plan_id` set (see + # `_enforce_run_witness_recording_gate` in `main.py`). + run_witness_recording_enabled: bool = False + + @field_validator("capture_status_phases") + @classmethod + def _validate_capture_status_phases(cls, value: dict[str, str]) -> dict[str, str]: + """Refuse an unparseable phase table at boot, not at the first + capture: a typo here would otherwise silently classify every + observation as UNRECOGNIZED until someone reads the log.""" + valid = {member.value for member in CapturePhase if member is not CapturePhase.UNRECOGNIZED} + bad = {literal: phase for literal, phase in value.items() if phase not in valid} + if bad: + msg = ( + f"capture_status_phases has values outside CapturePhase {sorted(valid)}: " + f"{bad}. UNRECOGNIZED is not a valid mapping target; a literal " + "absent from this table already classifies as UNRECOGNIZED." + ) + raise ValueError(msg) + return value + @field_validator("database_url") @classmethod def _validate_database_url(cls, value: str) -> str: diff --git a/apps/api/src/cora/infrastructure/record_export/_dispositions.py b/apps/api/src/cora/infrastructure/record_export/_dispositions.py index 61be2a414df..a786e364763 100644 --- a/apps/api/src/cora/infrastructure/record_export/_dispositions.py +++ b/apps/api/src/cora/infrastructure/record_export/_dispositions.py @@ -1618,6 +1618,7 @@ "workaround_excerpt": "drop:text", }, "campaign_id": "token:uuid", + "conduct_mode": "keep:enum:ConductMode", "decided_by_decision_id": "token:uuid", "effective_parameters": "drop:opaque", "external_refs": "drop:opaque", @@ -1629,6 +1630,10 @@ "plan_id": "token:uuid", "raid": "drop:text", "run_id": "token:uuid", + "safety_envelope_verdict": { + "beam_available": "drop:text", + "enclosure_permitted": "drop:text", + }, "subject_id": "token:uuid", "trigger_source": "drop:text", }, diff --git a/apps/api/src/cora/infrastructure/schema_version.py b/apps/api/src/cora/infrastructure/schema_version.py index 6a643d4b9db..2ec5a0cc6cb 100644 --- a/apps/api/src/cora/infrastructure/schema_version.py +++ b/apps/api/src/cora/infrastructure/schema_version.py @@ -74,7 +74,7 @@ class SchemaCheck: expected: str -EXPECTED_SCHEMA_VERSION: Final = "20260810120000" +EXPECTED_SCHEMA_VERSION: Final = "20260814041035" """The newest migration this build was written against. Hand-maintained, and deliberately not derived at runtime: the image does diff --git a/apps/api/src/cora/run/aggregates/run/__init__.py b/apps/api/src/cora/run/aggregates/run/__init__.py index f299473d3d8..b8d7426b3da 100644 --- a/apps/api/src/cora/run/aggregates/run/__init__.py +++ b/apps/api/src/cora/run/aggregates/run/__init__.py @@ -60,7 +60,14 @@ validate_effective_parameters_against_method_schema, ) from cora.run.aggregates.run.read import load_run -from cora.run.aggregates.run.safety_envelope import check_safety_envelope +from cora.run.aggregates.run.safety_envelope import ( + beam_gate_refusal, + check_safety_envelope, + clearance_gate_check, + enclosure_gate_refusal, + supply_gate_check, + witness_safety_envelope, +) from cora.run.aggregates.run.state import ( LOGBOOK_KIND_OBSERVATION, OBSERVATION_LOGBOOK_SCHEMA, @@ -73,6 +80,7 @@ RUN_PINNED_CALIBRATIONS_MAX_ENTRIES, SAMPLING_PROCEDURE_VALUES, ChannelName, + ConductMode, InvalidChannelNameError, InvalidInputDatasetsError, InvalidObservationValueError, @@ -109,6 +117,7 @@ RunHoldClaimsRemainError, RunInputNotReachableError, RunInputNotVerifiedError, + RunMonitorTriggerNotPermittedError, RunName, RunNotFoundError, RunObservationLogbookClosedError, @@ -123,6 +132,7 @@ RunSubjectNotMountableError, RunSupplyCoverageMismatchError, RunTruncateReason, + SafetyEnvelopeVerdict, SamplingProcedure, validate_input_dataset_ids, validate_pinned_calibration_ids, @@ -148,6 +158,7 @@ "SAMPLING_PROCEDURE_VALUES", "CautionAcknowledgement", "ChannelName", + "ConductMode", "DecisionDebriefRequested", "FeedHeartbeat", "FeedHeartbeatStore", @@ -200,6 +211,7 @@ "RunHoldClaimsRemainError", "RunInputNotReachableError", "RunInputNotVerifiedError", + "RunMonitorTriggerNotPermittedError", "RunName", "RunNotFoundError", "RunObservationLogbookClosedError", @@ -220,11 +232,15 @@ "RunSupplyCoverageMismatchError", "RunTruncateReason", "RunTruncated", + "SafetyEnvelopeVerdict", "SamplingProcedure", "active_hold_claims", + "beam_gate_refusal", "blocking_causes", "check_safety_envelope", + "clearance_gate_check", "derive_claim_id", + "enclosure_gate_refusal", "event_type_name", "evolve", "fold", @@ -233,9 +249,11 @@ "from_stored", "is_last_active_claim", "load_run", + "supply_gate_check", "to_payload", "validate_adjusted_parameters_against_method_schema", "validate_effective_parameters_against_method_schema", "validate_input_dataset_ids", "validate_pinned_calibration_ids", + "witness_safety_envelope", ] diff --git a/apps/api/src/cora/run/aggregates/run/events.py b/apps/api/src/cora/run/aggregates/run/events.py index e3c76ec4b27..0e268617d05 100644 --- a/apps/api/src/cora/run/aggregates/run/events.py +++ b/apps/api/src/cora/run/aggregates/run/events.py @@ -91,6 +91,7 @@ from cora.infrastructure.event_payload import deserialize_or_raise from cora.infrastructure.ports.event_store import StoredEvent +from cora.run.aggregates.run.state import ConductMode, SafetyEnvelopeVerdict from cora.shared.identity import ActorId from cora.shared.logbook import LogbookSchema @@ -197,6 +198,27 @@ class RunStarted: plan_id: UUID subject_id: UUID | None occurred_at: datetime + # who drove this act: CORA's own Conductor, or an external tool CORA + # only observes. See `ConductMode`'s own docstring (cora.run.aggregates + # .run.state) for the full rationale. A property of which decider ran, + # never a caller's choice: `StartRun` carries no `conduct_mode` field, + # and the driven decider hardcodes CONDUCTED here; a witnessed genesis + # (the separate decider) hardcodes WITNESSED. Defaults to CONDUCTED for + # forward-compat replay via `payload.get("conduct_mode", + # ConductMode.CONDUCTED.value)` in `from_stored` for legacy streams + # without the key, not because any caller supplies it. + conduct_mode: ConductMode = ConductMode.CONDUCTED + # The witnessed genesis's recorded reading of the two live facility + # signals (enclosure permit, beam availability) instead of an + # enforced gate. Always None on a driven Run: a driven Run + # necessarily passed both gates to exist at all, so a stored + # all-True verdict would carry no information beyond the event's + # own existence, the same reason `start_run`'s decider never + # persists its beam reading. Only `record_witnessed_run`'s decider + # ever constructs a non-None value. Forward-compat via + # `payload.get("safety_envelope_verdict")` returning None for legacy + # streams without the key. + safety_envelope_verdict: SafetyEnvelopeVerdict | None = None raid: str | None = None override_parameters: dict[str, Any] = field(default_factory=dict[str, Any]) effective_parameters: dict[str, Any] = field(default_factory=dict[str, Any]) @@ -742,6 +764,8 @@ def to_payload(event: RunEvent) -> dict[str, Any]: name=name, plan_id=plan_id, subject_id=subject_id, + conduct_mode=conduct_mode, + safety_envelope_verdict=safety_envelope_verdict, raid=raid, override_parameters=override_parameters, effective_parameters=effective_parameters, @@ -759,6 +783,15 @@ def to_payload(event: RunEvent) -> dict[str, Any]: "name": name, "plan_id": str(plan_id), "subject_id": str(subject_id) if subject_id is not None else None, + "conduct_mode": conduct_mode.value, + "safety_envelope_verdict": ( + { + "enclosure_permitted": safety_envelope_verdict.enclosure_permitted, + "beam_available": safety_envelope_verdict.beam_available, + } + if safety_envelope_verdict is not None + else None + ), "raid": raid, "override_parameters": override_parameters, "effective_parameters": effective_parameters, @@ -993,7 +1026,7 @@ def from_stored(stored: StoredEvent) -> RunEvent: def _build_run_started() -> RunStarted: raw_subject = payload["subject_id"] - # Forward-compat additive evolution: `raid`, + # Forward-compat additive evolution: `conduct_mode`, `raid`, # `override_parameters` / `effective_parameters` / # `trigger_source`, `external_refs`, # `acknowledged_cautions`, `campaign_id`, @@ -1005,11 +1038,23 @@ def _build_run_started() -> RunStarted: # payload, so legacy streams replay without an upcaster. raw_campaign_id = payload.get("campaign_id") raw_decided_by = payload.get("decided_by_decision_id") + raw_verdict = payload.get("safety_envelope_verdict") return RunStarted( run_id=UUID(payload["run_id"]), name=payload["name"], plan_id=UUID(payload["plan_id"]), subject_id=UUID(raw_subject) if raw_subject is not None else None, + conduct_mode=ConductMode( + payload.get("conduct_mode", ConductMode.CONDUCTED.value) + ), + safety_envelope_verdict=( + SafetyEnvelopeVerdict( + enclosure_permitted=raw_verdict["enclosure_permitted"], + beam_available=raw_verdict["beam_available"], + ) + if raw_verdict is not None + else None + ), raid=payload.get("raid"), override_parameters=payload.get("override_parameters", {}), effective_parameters=payload.get("effective_parameters", {}), diff --git a/apps/api/src/cora/run/aggregates/run/evolver.py b/apps/api/src/cora/run/aggregates/run/evolver.py index 2ac9110f515..2d9dad493b9 100644 --- a/apps/api/src/cora/run/aggregates/run/evolver.py +++ b/apps/api/src/cora/run/aggregates/run/evolver.py @@ -157,6 +157,7 @@ def evolve(state: Run | None, event: RunEvent) -> Run: name=name, plan_id=plan_id, subject_id=subject_id, + conduct_mode=conduct_mode, raid=raid, override_parameters=override_parameters, effective_parameters=effective_parameters, @@ -176,6 +177,7 @@ def evolve(state: Run | None, event: RunEvent) -> Run: subject_id=subject_id, raid=raid, status=RunStatus.RUNNING, + conduct_mode=conduct_mode, override_parameters=dict(override_parameters), effective_parameters=dict(effective_parameters), trigger_source=trigger_source, @@ -208,6 +210,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=RunStatus.HELD, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -239,6 +244,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=RunStatus.RUNNING, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -270,6 +278,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=RunStatus.COMPLETED, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -299,6 +310,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=RunStatus.ABORTED, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -328,6 +342,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=RunStatus.STOPPED, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -355,6 +372,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=RunStatus.TRUNCATED, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -393,6 +413,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=prior.status, override_parameters=prior.override_parameters, effective_parameters=dict(effective_parameters), @@ -426,6 +449,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=prior.status, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -458,6 +484,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=prior.status, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -490,6 +519,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, status=prior.status, override_parameters=prior.override_parameters, effective_parameters=prior.effective_parameters, @@ -535,6 +567,9 @@ def evolve(state: Run | None, event: RunEvent) -> Run: plan_id=prior.plan_id, subject_id=prior.subject_id, raid=prior.raid, + # Conduct provenance: who drove this act. IMMUTABLE after + # genesis, same as pinned_calibration_ids. + conduct_mode=prior.conduct_mode, # Status deliberately unchanged: still HELD if it was. status=prior.status, override_parameters=prior.override_parameters, diff --git a/apps/api/src/cora/run/aggregates/run/safety_envelope.py b/apps/api/src/cora/run/aggregates/run/safety_envelope.py index f231060dcb2..d8700bb43cd 100644 --- a/apps/api/src/cora/run/aggregates/run/safety_envelope.py +++ b/apps/api/src/cora/run/aggregates/run/safety_envelope.py @@ -4,21 +4,39 @@ clearance, supply, enclosure, and beam, are the same gates that must still hold for a held Run to be safely resumed. Living in the aggregate kernel (mirroring `plan.wires_validation.validate_wire_endpoints`) lets -both the `start_run` decider and the RunSupervisor's pre-resume re-check -import one definition, so a change to any gate applies to both the first -start and every later resume. Slice-to-slice sharing through a feature +the `start_run` decider, the RunSupervisor's pre-resume re-check, AND the +witnessed-genesis decider import one definition, so a change to any gate +applies to every consumer. Slice-to-slice sharing through a feature module is banned (cross-slice independence), so this is the correct home. -Pure: no I/O. The caller (the `start_run` handler, or the supervisor -runtime for resume) loads the cross-aggregate state and passes it in. -Each gate raises the same Run-BC error it always did, in the same order; -the caller maps those to HTTP 409 / 4xx. +Pure: no I/O. The caller (the `start_run` handler, the supervisor runtime +for resume, or the witnessed-genesis handler) loads the cross-aggregate +state and passes it in. + +## Two entry points, four shared gates + +`check_safety_envelope` raises the first failing gate, in the fixed order +clearance, supply, enclosure, beam; unchanged from before this module had +a second entry point. `witness_safety_envelope` is the witnessed-genesis +counterpart: per the roadmap's rule ("refuse on what CORA can fix, witness +what CORA cannot"), clearance and supply are CORA's own aggregates, so +they still RAISE on the witnessed path exactly as they do on the driven +path. Only enclosure and beam, the two genuinely external live facility +signals, become a recorded `SafetyEnvelopeVerdict` instead of a refusal. + +Both entry points call the same four gate functions below, `check_*` for +the two that always raise and `*_gate_refusal` for the two that may +instead be witnessed. There is no third copy of any gate's logic: a +change to, say, the enclosure rule changes what both paths see by +construction, not by two authors remembering to keep two copies in sync. The structural start-genesis validations (Plan-deprecated, Subject status, Asset-decommissioned, capability re-validation, wire endpoints, -Campaign membership, name) deliberately stay in the `start_run` decider: -they are genesis invariants, not live-signal gates, and a resume must -NOT re-run them (resume continues a Run that already passed them). +Campaign membership, name) deliberately stay in each decider: they are +genesis invariants, not live-signal gates, and a resume must NOT re-run +them (resume continues a Run that already passed them), and the witnessed +path's decider re-runs them exactly as the driven decider does (per the +roadmap: CORA-side data faults stay refusals on both paths). """ from collections.abc import Mapping @@ -37,38 +55,32 @@ RunRequiresOpenBeamShuttersError, RunRequiresPermittedEnclosureError, RunSupplyCoverageMismatchError, + SafetyEnvelopeVerdict, ) -def check_safety_envelope( +def clearance_gate_check( *, run_id: UUID, referencing_clearances: tuple[ClearanceLookupResult, ...], - needed_supplies_snapshot: frozenset[str], - needed_supplies_satisfaction: Mapping[str, tuple[SupplyLookupResult, ...]], - referencing_enclosures: tuple[EnclosureLookupResult, ...], - beam_availability: BeamAvailabilityLookupResult | None, ) -> None: - """Raise the first failing start-safety gate; return None if all pass. + """Raise unless at least one Safety Clearance is Active AND references + this Run's scope. The caller pre-loaded every clearance whose bindings + reference the Run/Subject/Asset ids. Partition on status == "Active" + to distinguish "no clearance at all" (RunRequiresActiveClearanceError) + from "clearance exists but none Active" (RunClearanceCoverageMismatchError). + Modern DDD consensus (Khononov / Dudycz / Herberto Graca 2024-2025): + cross-context gating queries a replicated read model (here: + proj_safety_clearance_summary), not the upstream aggregate. - `run_id` is carried on each raised error (the new id at start_run, - the existing run id at resume). + Always raises, never witnesses: a Clearance is a human authorization, + not a live facility reading, per the roadmap's rule. This keeps the + cross-port invariant fail-closed for EVERY Run, including a compute + Run whose empty Asset scope makes the enclosure / beam gates below + vacuous, and including a witnessed Run (see `test_compute_shaped_scope_ + still_requires_active_clearance`; any future split must not exempt + either). """ - # cross-BC clearance gate: at least one Safety Clearance must be - # Active AND reference this Run's scope. The caller pre-loaded every - # clearance whose bindings reference the Run/Subject/Asset ids. - # Partition on status == "Active" to distinguish "no clearance at - # all" (RunRequiresActiveClearanceError) from "clearance exists but - # none Active" (RunClearanceCoverageMismatchError). Modern DDD - # consensus (Khononov / Dudycz / Herberto Graca 2024-2025): cross- - # context gating queries a replicated read model (here: - # proj_safety_clearance_summary), not the upstream aggregate. - # - # Cross-port invariant: this clearance gate stays fail-closed for EVERY - # Run, including a compute Run whose empty Asset scope makes the enclosure - # / beam interlocks below vacuous. Any future per-port envelope split must - # not exempt compute from clearance (see - # test_compute_shaped_scope_still_requires_active_clearance). if not referencing_clearances: raise RunRequiresActiveClearanceError(run_id) active_clearances = [c for c in referencing_clearances if c.status == "Active"] @@ -78,15 +90,24 @@ def check_safety_envelope( referencing_clearance_count=len(referencing_clearances), ) - # cross-BC Supply gate per [[project_supply_preflight_gate_design]]: - # for every kind in Method.needed_supplies, at least one Supply of - # that kind must be registered (RunRequiresAvailableSupplyError when - # absent), AND at least one of those registered Supplies must be in - # status=Available (RunSupplyCoverageMismatchError when present but - # none Available). Default-strict: Degraded does NOT pass; operators - # with override authority use mark_supply_available to declare a - # Supply Available before starting. Mirrors the clearance-gate two- - # error pair pattern above. + +def supply_gate_check( + *, + run_id: UUID, + needed_supplies_snapshot: frozenset[str], + needed_supplies_satisfaction: Mapping[str, tuple[SupplyLookupResult, ...]], +) -> None: + """Raise unless, for every kind in Method.needed_supplies, at least one + registered Supply of that kind is in status=Available, per + [[project_supply_preflight_gate_design]]. Default-strict: Degraded + does NOT pass; operators with override authority use + mark_supply_available to declare a Supply Available before starting. + Mirrors `clearance_gate_check`'s two-error pair pattern. + + Always raises, never witnesses, for the same reason as + `clearance_gate_check`: Supply is CORA's own aggregate, not a live + facility reading. + """ for kind in sorted(needed_supplies_snapshot): candidates = needed_supplies_satisfaction.get(kind, ()) if not candidates: @@ -98,60 +119,153 @@ def check_safety_envelope( frozenset((s.supply_id, s.status) for s in candidates), ) - # cross-BC Enclosure gate per [[project_enclosure_stage1_design]]: - # every referencing Enclosure row must be `permit_status == - # "Permitted"` AND `lifecycle == "Active"`. Per L-pre-1 (always- - # derive-from-Asset-chain), the scope set is derived by the caller by - # collecting each scoped Asset's (and ancestor's) - # `located_in_enclosure_id` and loading them via - # `EnclosureLookup.find_by_ids`; an empty `referencing_enclosures` is - # Permit-by-default (no scoped Asset is located in any Enclosure). - # When any row fails, raise with `enclosure_status_summary` carrying - # the `(enclosure_id, "permit_status|lifecycle")` tuple for every - # failing Enclosure so the 409 names each blocker. Default-strict: - # NotPermitted / Unknown / Decommissioned all fail (the adapter - # excludes most Decommissioned rows at the read layer; this treats - # any non-"Active" non-"Permitted" row as a fail defensively). + +def enclosure_gate_refusal( + *, + run_id: UUID, + referencing_enclosures: tuple[EnclosureLookupResult, ...], +) -> RunRequiresPermittedEnclosureError | RunEnclosureCoverageMismatchError | None: + """Return the enclosure-gate refusal this Run would face, or None when + the gate holds. Per [[project_enclosure_stage1_design]], every + referencing Enclosure row must be `permit_status == "Permitted"` AND + `lifecycle == "Active"`. Per L-pre-1 (always-derive-from-Asset-chain), + the scope set is derived by the caller by collecting each scoped + Asset's (and ancestor's) `located_in_enclosure_id` and loading them + via `EnclosureLookup.find_by_ids`; an empty `referencing_enclosures` + is Permit-by-default (no scoped Asset is located in any Enclosure). + The returned error carries `enclosure_status_summary`, the + `(enclosure_id, "permit_status|lifecycle")` tuple for every failing + Enclosure, so a 409 built from it names each blocker. Default-strict: + NotPermitted / Unknown / Decommissioned all fail (the adapter excludes + most Decommissioned rows at the read layer; this treats any + non-"Active" non-"Permitted" row as a fail defensively). + + Returns rather than raises: this is a live facility signal, so + `check_safety_envelope` raises the return value while + `witness_safety_envelope` records only whether it was None. + """ failing_rows = tuple( e for e in referencing_enclosures if not (e.permit_status == "Permitted" and e.lifecycle == "Active") ) - if failing_rows: - # Build the user-facing summary as a frozenset (dedupes on - # (id, label) for noise reduction in the 409 message). The branch - # decision uses raw tuple lengths so a future adapter that - # returns duplicate rows still classifies correctly. - failing_summary = frozenset( - (e.enclosure_id, f"{e.permit_status}|{e.lifecycle}") for e in failing_rows + if not failing_rows: + return None + # Build the user-facing summary as a frozenset (dedupes on + # (id, label) for noise reduction in the 409 message). The branch + # decision uses raw tuple lengths so a future adapter that returns + # duplicate rows still classifies correctly. + failing_summary = frozenset( + (e.enclosure_id, f"{e.permit_status}|{e.lifecycle}") for e in failing_rows + ) + if len(failing_rows) == len(referencing_enclosures): + # Every referencing Enclosure failed the gate. + return RunRequiresPermittedEnclosureError(run_id, failing_summary) + # Mixed: at least one passed, at least one failed. + return RunEnclosureCoverageMismatchError(run_id, failing_summary) + + +def beam_gate_refusal( + *, + run_id: UUID, + beam_availability: BeamAvailabilityLookupResult | None, +) -> RunBeamAvailabilityUnknownError | RunRequiresOpenBeamShuttersError | None: + """Return the beam-gate refusal this Run would face, or None when the + gate holds. Per BEAM-1: when the deployment configures beam PVs the + caller reads the live front-end + station shutter states + (BeamBlockingM, inverted polarity: 0 == open) and the ACIS FES-permit + composite and passes the BeamAvailabilityLookupResult here. None means + the deployment configures no beam PVs (beam-by-default). Fail-closed: + a read whose quality is not Good (disconnected / bad PV) refuses + rather than assume beam is open. Distinct axis from the Enclosure + SecureM permit above: beam-open cycles per-scan, the enclosure permit + is access-state. + + Returns rather than raises, same reason as `enclosure_gate_refusal`. + """ + if beam_availability is None: + return None + if not beam_availability.quality_ok: + return RunBeamAvailabilityUnknownError(run_id) + blocking = frozenset( + flag + for flag, ok in ( + ("fes_open", beam_availability.fes_open), + ("sbs_open", beam_availability.sbs_open), + ("fes_permit", beam_availability.fes_permit), ) - if len(failing_rows) == len(referencing_enclosures): - # Every referencing Enclosure failed the gate. - raise RunRequiresPermittedEnclosureError(run_id, failing_summary) - # Mixed: at least one passed, at least one failed. - raise RunEnclosureCoverageMismatchError(run_id, failing_summary) - - # cross-BC beam-availability gate per BEAM-1: when the deployment - # configures beam PVs the caller reads the live front-end + station - # shutter states (BeamBlockingM, inverted polarity: 0 == open) and - # the ACIS FES-permit composite and passes the - # BeamAvailabilityLookupResult here. None means the deployment - # configures no beam PVs (beam-by-default). Fail-closed: a read whose - # quality is not Good (disconnected / bad PV) refuses rather than - # assume beam is open. Distinct axis from the Enclosure SecureM - # permit above: beam-open cycles per-scan, the enclosure permit is - # access-state. - if beam_availability is not None: - if not beam_availability.quality_ok: - raise RunBeamAvailabilityUnknownError(run_id) - blocking = frozenset( - flag - for flag, ok in ( - ("fes_open", beam_availability.fes_open), - ("sbs_open", beam_availability.sbs_open), - ("fes_permit", beam_availability.fes_permit), - ) - if not ok + if not ok + ) + if blocking: + return RunRequiresOpenBeamShuttersError(run_id, blocking) + return None + + +def check_safety_envelope( + *, + run_id: UUID, + referencing_clearances: tuple[ClearanceLookupResult, ...], + needed_supplies_snapshot: frozenset[str], + needed_supplies_satisfaction: Mapping[str, tuple[SupplyLookupResult, ...]], + referencing_enclosures: tuple[EnclosureLookupResult, ...], + beam_availability: BeamAvailabilityLookupResult | None, +) -> None: + """Raise the first failing start-safety gate; return None if all pass. + + `run_id` is carried on each raised error (the new id at start_run, + the existing run id at resume or at a witnessed genesis). Composed from + the four gate functions above; behaviour, order, and every raised + error's payload are unchanged from before this module gained a second + entry point. + """ + clearance_gate_check(run_id=run_id, referencing_clearances=referencing_clearances) + supply_gate_check( + run_id=run_id, + needed_supplies_snapshot=needed_supplies_snapshot, + needed_supplies_satisfaction=needed_supplies_satisfaction, + ) + enclosure_refusal = enclosure_gate_refusal( + run_id=run_id, referencing_enclosures=referencing_enclosures + ) + if enclosure_refusal is not None: + raise enclosure_refusal + beam_refusal = beam_gate_refusal(run_id=run_id, beam_availability=beam_availability) + if beam_refusal is not None: + raise beam_refusal + + +def witness_safety_envelope( + *, + run_id: UUID, + referencing_clearances: tuple[ClearanceLookupResult, ...], + needed_supplies_snapshot: frozenset[str], + needed_supplies_satisfaction: Mapping[str, tuple[SupplyLookupResult, ...]], + referencing_enclosures: tuple[EnclosureLookupResult, ...], + beam_availability: BeamAvailabilityLookupResult | None, +) -> SafetyEnvelopeVerdict: + """Record a verdict on the two live facility signals instead of + enforcing them; used only by the witnessed-genesis decider. + + Same six inputs and same clearance/supply behaviour as + `check_safety_envelope`: both still raise on those two gates, because + they are CORA-side data, not something RunWitness can observe from + the floor. Only enclosure and beam, evaluated by the exact same + `enclosure_gate_refusal` / `beam_gate_refusal` functions + `check_safety_envelope` uses, are converted to a bool instead of + raised. This is what makes "both paths provably call the same + predicates" a structural fact rather than a claim to trust. + """ + clearance_gate_check(run_id=run_id, referencing_clearances=referencing_clearances) + supply_gate_check( + run_id=run_id, + needed_supplies_snapshot=needed_supplies_snapshot, + needed_supplies_satisfaction=needed_supplies_satisfaction, + ) + return SafetyEnvelopeVerdict( + enclosure_permitted=enclosure_gate_refusal( + run_id=run_id, referencing_enclosures=referencing_enclosures ) - if blocking: - raise RunRequiresOpenBeamShuttersError(run_id, blocking) + is None, + beam_available=beam_gate_refusal(run_id=run_id, beam_availability=beam_availability) + is None, + ) diff --git a/apps/api/src/cora/run/aggregates/run/state.py b/apps/api/src/cora/run/aggregates/run/state.py index 0c61bb23a94..324bd153d59 100644 --- a/apps/api/src/cora/run/aggregates/run/state.py +++ b/apps/api/src/cora/run/aggregates/run/state.py @@ -276,6 +276,87 @@ class RunStatus(StrEnum): TRUNCATED = "Truncated" +class ConductMode(StrEnum): + """Who drove this Run's act: CORA's own Conductor, or an external tool. + + Reifies the axis `docs/reference/modeling.md`'s "Run vs Procedure + boundary" section names in prose but never encoded: "Conducted vs + recorded (who drives the act) ... Both Runs and Procedures span + both modes." Orthogonal to `RunStatus`: every status transition is + reachable under either mode, and the mode never changes once set + at genesis. + + `CONDUCTED` is CORA's own Conductor driving the act (every Run + started today, via `_run_initiator.py` or a phase-conduct bridge, + is Conducted). `WITNESSED` is an externally-driven act CORA only + observes after the fact, for example a 2-BM tomoscan scan witnessed + by `RunWitness` (see `cora.api._run_witness`) and promoted via + `record_witnessed_run`. + + Named `WITNESSED`, not `RECORDED`: every Conducted Run is ALSO + recorded (in the event store, in `proj_run_summary`, in the export + bundle), so `RECORDED` was not actually a contrast pair with + `CONDUCTED` -- it answered a different question ("how did this + fact enter CORA") that happens to be true of both modes at once. + `WITNESSED` is the only candidate mutually exclusive with + `CONDUCTED`: CORA does not merely witness a Run it drove. This is + also the governing rule `record_witnessed_run/decider.py` states + verbatim: "refuse on what CORA can fix, witness what CORA cannot." + This is a provenance label, not an attestation guarantee: it says + CORA observed the act, not that the observation was independently + verified. + + Named `conduct_mode`, not bare `mode`, on the `Run` field: Run + already carries a neighboring "how was this driven" fact, + `actuation_kind` (raw `ActuationKind`, stamped by the compute + CONDUCT runtime onto terminal events), and bare `mode` would read + ambiguously beside it. + """ + + CONDUCTED = "Conducted" + WITNESSED = "Witnessed" + + +@dataclass(frozen=True) +class SafetyEnvelopeVerdict: + """A recorded reading of the two live facility signals at a witnessed + genesis: did the enclosure permit hold, was beam available. + + Plain bools only, deliberately. The record exporter's disposition + generator drops bare `str` and `Any`; `keep:number` covers `bool`, so + this VO survives export and redaction whole. Naming WHICH enclosure or + WHICH shutter failed is not this VO's job: that detail goes to the log + line at the moment of the reading and stays reconstructible from the + Enclosure stream in the same exported bundle. + + Clearance and Supply are deliberately absent. Per the roadmap's rule + ("refuse on what CORA can fix, witness what CORA cannot"), those two + gates are CORA's own aggregates, not live facility readings, so they + stay refusals on every path (`check_safety_envelope` AND + `witness_safety_envelope` both raise on them); their passage is implied + by a `RunStarted` existing at all, the same reason `check_safety_envelope` + itself never persists a snapshot of the gates it enforces. + + Lives beside `ConductMode` in this module (not in `events.py`, the + `CautionAcknowledgement` precedent's home) so that `safety_envelope.py` + and `events.py`, which both already import from `state.py`, gain no new + import edge to carry it. + """ + + enclosure_permitted: bool + beam_available: bool + + @property + def all_gates_passed(self) -> bool: + """True only when every witnessed gate passed. Not a dataclass + field: a derived property never reaches the record-export + generator, which walks `dataclasses.fields()`. Named + `all_gates_passed`, not `held`: `Held` is already this module's + RunStatus vocabulary (a paused Run), and `verdict.held` would + read as the opposite of what it means here.""" + return self.enclosure_permitted and self.beam_available + + class InvalidRunNameError(ValueError): """The supplied name is empty, whitespace-only, or too long.""" @@ -294,6 +375,34 @@ def __init__(self, run_id: UUID) -> None: self.run_id = run_id +class RunMonitorTriggerNotPermittedError(Exception): + """`record_witnessed_run` carried a non-Monitor trigger. + + Mirrors the Enclosure BC's `MonitorTriggerNotPermittedError` + (`observe_enclosure_status`, D6.L2 observation-axis-only anti-lock): + a witnessed genesis is reachable only via Monitor-driven inbound + observation from the substrate; there is no operator path to it. The + command surface types `monitor_source_id` as `MonitorSourceId` so an + operator cannot supply non-Monitor attribution at the type level; + this error fences the same invariant defensively at the decider so a + programmer mistake in a custom handler, test fixture, or future + adapter cannot smuggle an operator-asserted Run genesis onto the + spine through the witnessed path. + + HTTP 400 (semantically a request the caller cannot issue, not a + state-transition conflict). + """ + + def __init__(self, run_id: UUID, trigger: str) -> None: + super().__init__( + f"Run {run_id}: trigger {trigger!r} is not permitted on " + f"record_witnessed_run; only 'Monitor' is accepted per the " + f"observation-axis-only anti-lock." + ) + self.run_id = run_id + self.trigger = trigger + + class RunNotFoundError(Exception): """Attempted an operation on a run whose stream has no events.""" @@ -1292,6 +1401,14 @@ class Run: subject_id: UUID | None raid: str | None = None status: RunStatus = RunStatus.RUNNING + # who drove this act: CORA's own Conductor, or an external tool CORA + # only observes. Set once at genesis (RunStarted.conduct_mode) and + # IMMUTABLE thereafter: every transition arm in the evolver threads + # `prior.conduct_mode` verbatim, same as `pinned_calibration_ids`. + # Never Optional: a Run's cause is always one of the two named + # values, declared explicitly by the genesis command, never + # inferred. See `ConductMode`'s own docstring. + conduct_mode: ConductMode = ConductMode.CONDUCTED override_parameters: dict[str, Any] = field(default_factory=dict[str, Any]) effective_parameters: dict[str, Any] = field(default_factory=dict[str, Any]) trigger_source: str | None = None diff --git a/apps/api/src/cora/run/features/__init__.py b/apps/api/src/cora/run/features/__init__.py index 4a0e8759906..1d45c979537 100644 --- a/apps/api/src/cora/run/features/__init__.py +++ b/apps/api/src/cora/run/features/__init__.py @@ -10,4 +10,7 @@ open-on-first-write) - 6j: adjust_run (mid-flight parameter steering; idempotency-wrapped; closes the autonomous-CT closed-loop steering gap) + - record_witnessed_run: the witnessed genesis (a second, independent + genesis alongside start_run). Hardcodes ConductMode.WITNESSED; + in-process-only, no REST route, no MCP tool. """ diff --git a/apps/api/src/cora/run/features/list_runs/handler.py b/apps/api/src/cora/run/features/list_runs/handler.py index 63054d4e251..e0d94d48840 100644 --- a/apps/api/src/cora/run/features/list_runs/handler.py +++ b/apps/api/src/cora/run/features/list_runs/handler.py @@ -1,12 +1,12 @@ """Application handler for the `list_runs` query slice. Reads `proj_run_summary` via the cross-BC -`infrastructure.list_query.make_list_query_handler` factory. Two -optional filters (status + plan_id) plus cursor pagination, -declared as `ScalarFilter` specs; the factory composes only the -WHERE fragments for filters the caller actually passed (sargable, -indexable; replaces the legacy `$N IS NULL OR column = $N` smart- -logic pattern documented in the factory module). +`infrastructure.list_query.make_list_query_handler` factory. Four +optional filters (status, plan_id, campaign_id, conduct_mode) plus +cursor pagination, declared as `ScalarFilter` specs; the factory +composes only the WHERE fragments for filters the caller actually +passed (sargable, indexable; replaces the legacy `$N IS NULL OR +column = $N` smart-logic pattern documented in the factory module). `subject_id` and `raid` flow through to the result row from the genesis event; both are nullable (Plan-only Runs without a Subject; @@ -66,6 +66,11 @@ class RunSummaryItem: expected_observation_interval_seconds: float | None """Expected inter-arrival for Rule R (stall), precomputed from effective_parameters. NULL disables Rule R for this Run.""" + conduct_mode: str + """`ConductMode` value ("Conducted" or "Witnessed"), immutable from + genesis. The RunSupervisor and RunInitiator runtimes filter on this + to skip Witnessed Runs: those are not theirs to hold, resume, + truncate, or count toward in-flight limits.""" @dataclass(frozen=True) @@ -92,7 +97,7 @@ async def __call__( _SELECT_COLUMNS = ( "run_id, name, plan_id, subject_id, raid, status, created_at, running_since, " "override_parameters_present, campaign_id, snr_limit, " - "expected_observation_interval_seconds" + "expected_observation_interval_seconds, conduct_mode" ) @@ -110,6 +115,7 @@ def _row_to_item(row: Any) -> RunSummaryItem: campaign_id=row["campaign_id"], snr_limit=row["snr_limit"], expected_observation_interval_seconds=row["expected_observation_interval_seconds"], + conduct_mode=str(row["conduct_mode"]), ) @@ -118,6 +124,7 @@ def _log_fields(query: ListRuns) -> dict[str, Any]: "status": query.status, "plan_id": str(query.plan_id) if query.plan_id else None, "campaign_id": str(query.campaign_id) if query.campaign_id else None, + "conduct_mode": query.conduct_mode, } @@ -136,6 +143,7 @@ def bind(deps: Kernel) -> Handler: ScalarFilter(attr="status"), ScalarFilter(attr="plan_id"), ScalarFilter(attr="campaign_id"), + ScalarFilter(attr="conduct_mode"), ], row_to_item=_row_to_item, item_cursor_at=lambda item: item.created_at, diff --git a/apps/api/src/cora/run/features/list_runs/query.py b/apps/api/src/cora/run/features/list_runs/query.py index 2bf3108f8be..ca242b6bef6 100644 --- a/apps/api/src/cora/run/features/list_runs/query.py +++ b/apps/api/src/cora/run/features/list_runs/query.py @@ -1,8 +1,9 @@ """The `ListRuns` query: intent dataclass for keyset-paginated list of runs from the projection. -Two optional filters: status (Running / Held / Completed / Aborted / -Stopped / Truncated) and plan_id (which Plan was bound). Cursor +Optional filters: status (Running / Held / Completed / Aborted / +Stopped / Truncated), plan_id (which Plan was bound), campaign_id +(Campaign membership), and conduct_mode (Conducted / Witnessed). Cursor encodes (created_at, run_id). """ @@ -41,3 +42,8 @@ class ListRuns: """Optional `campaign_id` filter (Campaign Watch #10): returns Runs that are members of the given Campaign. Pass `None` (omit) for "any Campaign or none".""" + + conduct_mode: str | None = None + """Optional `ConductMode` value filter ("Conducted" or "Witnessed"). + Pass `None` (omit) for "any conduct mode". Lets a caller restrict to + Witnessed Runs, e.g. the RunWitness restart-rebuild query.""" diff --git a/apps/api/src/cora/run/features/record_witnessed_run/__init__.py b/apps/api/src/cora/run/features/record_witnessed_run/__init__.py new file mode 100644 index 00000000000..ba21994ae7f --- /dev/null +++ b/apps/api/src/cora/run/features/record_witnessed_run/__init__.py @@ -0,0 +1,31 @@ +"""Vertical slice for the `RecordWitnessedRun` command: the witnessed genesis. + +In-process-only by design: no REST route, no MCP tool. Module-as- +namespace surface, symmetric with the other genesis slice: + + from cora.run.features import record_witnessed_run + + cmd = record_witnessed_run.RecordWitnessedRun( + name="...", plan_id=..., capture_code=..., monitor_source_id=..., + trigger="Monitor", + ) + handler = record_witnessed_run.bind(deps) + run_id = await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.run.features.record_witnessed_run import tool +from cora.run.features.record_witnessed_run.command import RecordWitnessedRun +from cora.run.features.record_witnessed_run.context import RunWitnessedStartContext +from cora.run.features.record_witnessed_run.decider import decide +from cora.run.features.record_witnessed_run.handler import Handler, bind +from cora.run.features.record_witnessed_run.route import router + +__all__ = [ + "Handler", + "RecordWitnessedRun", + "RunWitnessedStartContext", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/run/features/record_witnessed_run/command.py b/apps/api/src/cora/run/features/record_witnessed_run/command.py new file mode 100644 index 00000000000..79e64265eec --- /dev/null +++ b/apps/api/src/cora/run/features/record_witnessed_run/command.py @@ -0,0 +1,64 @@ +"""The `RecordWitnessedRun` command -- intent dataclass for this slice. + +A witnessed genesis: CORA records that an external tool began a capture, +rather than driving the act itself. Carries the caller-controlled inputs: + + - `name` -- display name for the new Run, same free-text shape as + `StartRun.name`. + - `plan_id` -- the Plan being executed. Deployment-declared (the + RunWitness runtime resolves it from settings, not from the substrate); + existence verified at handler-load time exactly as at a driven start. + - `subject_id` -- always None in practice today (2-BM's dark-field / + flat-field / fly-scan captures carry no Subject binding), but kept + `UUID | None` rather than dropped: a future deployment watching a + sample-bound capture is the same shape, not a different command. + - `capture_code` -- the deployment-declared identity surface for the + acquisition path being watched (`CaptureObserverScope`'s own + vocabulary), carried onto the emitted `RunStarted.external_refs` as + an `Identifier(scheme="capture-code", value=capture_code)` so a + restart can rediscover which Run belongs to which open capture. + - `monitor_source_id` -- the stable `MonitorSourceId` of the in-process + RunWitness runtime that produced this genesis, mirroring + `ObserveEnclosureStatus.monitor_source_id`. + - `trigger` -- command-tier guard string. The decider rejects any value + other than the literal `"Monitor"` with + `RunMonitorTriggerNotPermittedError`, closing the operator-assert- + Witnessed backdoor (mirrors `ObserveEnclosureStatus.trigger`'s D6.L2 + anti-lock): there is no operator path to a witnessed genesis. + +No `conduct_mode` field: this decider hardcodes `ConductMode.WITNESSED`, +symmetric to `StartRun` carrying no `conduct_mode` field for the driven +decider's hardcoded `CONDUCTED`. The mode is a property of which decider +ran, never a caller's choice, on either path. + +No `override_parameters`, `campaign_id`, `raid`, `decided_by_decision_id`, +`pinned_calibration_ids`, `input_dataset_ids`, or `compute_resource_code`: +RunWitness has no operator inputs to pass, and every field this command +does not carry is a field an operator cannot reach through it. Effective +parameters are the Plan's own defaults, unmodified. + +No `observed_at`: `RunStarted` has no substrate-time field, and adding +one is deferred to the terminal-recording slice that actually needs it. +`occurred_at`, stamped from the Clock port in the handler, is honest +about what it claims: when CORA learned of the genesis, not when the +substrate says the capture began. + +Status is implicit at start (`Running`), same as `StartRun`. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.shared.identity import MonitorSourceId + + +@dataclass(frozen=True) +class RecordWitnessedRun: + """Record that an external tool began a capture: a witnessed Run genesis.""" + + name: str + plan_id: UUID + capture_code: str + monitor_source_id: MonitorSourceId + trigger: str + subject_id: UUID | None = None diff --git a/apps/api/src/cora/run/features/record_witnessed_run/context.py b/apps/api/src/cora/run/features/record_witnessed_run/context.py new file mode 100644 index 00000000000..a035bcca2aa --- /dev/null +++ b/apps/api/src/cora/run/features/record_witnessed_run/context.py @@ -0,0 +1,76 @@ +"""Cross-aggregate context the `record_witnessed_run` decider validates against. + +`RunWitnessedStartContext` is the witnessed-genesis counterpart to `RunStartContext` +(`cora.run.features.start_run.context`). Deliberately a separate, +slice-local dataclass rather than a shared import: cross-slice sharing +through a feature module is banned (cross-slice independence), and the +two contexts are not identical shapes anyway (this one carries no +`campaign`, `input_distributions`, or `reachable_storage_supply_ids`, +since RunWitness has no operator inputs in those axes). + +## Field semantics + +Same loading and gating semantics as the matching `RunStartContext` +fields, with one difference: `referencing_enclosures` and +`beam_availability` are still LOADED here exactly as at a driven start, +but the decider WITNESSES them (via `witness_safety_envelope`) instead of +enforcing them. `referencing_clearances` and `needed_supplies_satisfaction` +are still ENFORCED (via the same `clearance_gate_check` / +`supply_gate_check` functions the driven decider uses), per the roadmap's +rule: CORA-side data faults stay refusals on both paths. + + - `plan`: the Plan being executed. Decider rejects if Deprecated. + - `subject`: the Subject being measured, or None (the common case for + a watched capture today). Decider rejects if non-None and not in + Mounted | Measured. + - `assets`: dict keyed by asset_id, loaded from `plan.asset_ids`. + Decider rejects if any is Decommissioned, and re-validates capability + superset against current Asset state. + - `referencing_clearances`: every Safety clearance whose bindings + reference this Run's scope, loaded exactly as at a driven start. + Still gates: a witnessed genesis without an Active Clearance still + refuses. + - `active_cautions`: every Active Caution in scope. Non-blocking, + embedded on `RunStarted.acknowledged_cautions` exactly as at a + driven start. + - `needed_supplies_satisfaction`: mapping keyed by Supply kind. Still + gates: a witnessed genesis without an Available Supply of a required + kind still refuses. + - `referencing_enclosures`: every Enclosure the Run's scoped Assets (or + ancestors) declare via `located_in_enclosure_id`. WITNESSED, not + enforced: a NotPermitted enclosure records `enclosure_permitted=False` + on the emitted verdict rather than refusing the genesis. + - `beam_availability`: the live beam reading, or None when the + deployment configures no beam PVs. WITNESSED, not enforced, same + reason as `referencing_enclosures`. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import cast +from uuid import UUID + +from cora.equipment.aggregates.asset import Asset +from cora.infrastructure.ports.beam_availability_lookup import BeamAvailabilityLookupResult +from cora.infrastructure.ports.caution_lookup import CautionLookupResult +from cora.infrastructure.ports.clearance_lookup import ClearanceLookupResult +from cora.infrastructure.ports.enclosure_lookup import EnclosureLookupResult +from cora.infrastructure.ports.supply_lookup import SupplyLookupResult +from cora.recipe.aggregates.plan import Plan +from cora.subject.aggregates.subject import Subject + + +@dataclass(frozen=True) +class RunWitnessedStartContext: + """Snapshot of upstream aggregate state at a witnessed Run's genesis.""" + + plan: Plan + subject: Subject | None + assets: dict[UUID, Asset] + referencing_clearances: tuple[ClearanceLookupResult, ...] + active_cautions: tuple[CautionLookupResult, ...] = () + needed_supplies_satisfaction: Mapping[str, tuple[SupplyLookupResult, ...]] = field( + default_factory=lambda: cast("Mapping[str, tuple[SupplyLookupResult, ...]]", {}) + ) + referencing_enclosures: tuple[EnclosureLookupResult, ...] = () + beam_availability: BeamAvailabilityLookupResult | None = None diff --git a/apps/api/src/cora/run/features/record_witnessed_run/decider.py b/apps/api/src/cora/run/features/record_witnessed_run/decider.py new file mode 100644 index 00000000000..fd802889e31 --- /dev/null +++ b/apps/api/src/cora/run/features/record_witnessed_run/decider.py @@ -0,0 +1,215 @@ +"""Pure decider for the `RecordWitnessedRun` command: the witnessed genesis. + +Second genesis decider on the Run aggregate, after `start_run`. Where +`start_run` drives the act through CORA's own Conductor and hardcodes +`ConductMode.CONDUCTED`, this decider records that an external tool +already began a capture and hardcodes `ConductMode.WITNESSED`. Never the +other way around: `RecordWitnessedRun` carries no `conduct_mode` field for +either decider to read, so the mode is a property of which decider ran. + +## The governing rule: refuse on what CORA can fix, witness what CORA cannot + +CORA-side data faults (deprecated Plan, decommissioned Asset, capability +shortfall, bad wires, an absent Clearance or Supply) stay refusals here +exactly as at a driven start: they are loud, fixable, and the watcher +retries on the next capture. Only the two genuinely external live +facility signals, enclosure permit and beam availability, are witnessed +instead of enforced: `witness_safety_envelope` (shared with +`check_safety_envelope` via the same four gate functions in +`safety_envelope.py`, so the two paths cannot silently drift) still +raises for a failing clearance or supply gate, and returns a +`SafetyEnvelopeVerdict` for the other two. + +## Validation order + +Mirrors `start_run.decide`'s order for every CORA-side check it shares; +the trigger guard runs first (it is a request-shape rejection, not a +domain-state one) and the envelope witness runs where `start_run` runs +its enforcing envelope check. + +Invariants: + - State must be None (genesis-only) -> RunAlreadyExistsError + - `trigger` must be the literal "Monitor" -> RunMonitorTriggerNotPermittedError + - At least one Clearance must reference this Run's scope + -> RunRequiresActiveClearanceError + - At least one referencing Clearance must be Active + -> RunClearanceCoverageMismatchError + - Every kind in Method.needed_supplies must have at least + one registered Supply -> RunRequiresAvailableSupplyError + - Every kind in Method.needed_supplies must have at least + one AVAILABLE Supply -> RunSupplyCoverageMismatchError + - Plan must not be Deprecated -> RunBoundPlanDeprecatedError + - Subject (when set) must be Mounted or Measured + -> RunSubjectNotMountableError + - No bound Asset may be Decommissioned + -> RunPlanAssetDecommissionedError + - Union of current bound Asset families must cover Method's + needed_family_ids -> RunCapabilitiesNotSatisfiedError + - Effective parameters (the Plan's own defaults, unmodified: this + command carries no override_parameters) must validate against + Method's parameters_schema -> InvalidRunEffectiveParametersError + (via validate_effective_parameters_against_method_schema) + - All Plan wires must re-validate against current Asset.ports + -> PlanWireAssetNotBoundError / PlanWirePortNotFoundError + (via validate_wire_endpoints) + - Name must be valid -> InvalidRunNameError (via RunName VO) + +Not enforced (witnessed instead, see above): the enclosure permit and +beam availability gates. Not present at all: campaign membership, +pinned_calibration_ids, input_dataset_ids, compute reachability -- the +command carries none of the fields those checks key on, so there is +nothing to validate. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from uuid import UUID + +from cora.equipment.aggregates.asset import AssetLifecycle +from cora.recipe.aggregates.plan import PlanStatus, validate_wire_endpoints +from cora.run.aggregates.run import ( + CautionAcknowledgement, + ConductMode, + Run, + RunAlreadyExistsError, + RunBoundPlanDeprecatedError, + RunCapabilitiesNotSatisfiedError, + RunMonitorTriggerNotPermittedError, + RunName, + RunPlanAssetDecommissionedError, + RunStarted, + RunSubjectNotMountableError, + validate_effective_parameters_against_method_schema, + witness_safety_envelope, +) +from cora.run.features.record_witnessed_run.command import RecordWitnessedRun +from cora.run.features.record_witnessed_run.context import RunWitnessedStartContext +from cora.shared.identifier import Identifier +from cora.subject.aggregates.subject import SubjectStatus + +_SUBJECT_RUNNABLE_STATUSES: tuple[SubjectStatus, ...] = ( + SubjectStatus.MOUNTED, + SubjectStatus.MEASURED, +) + +_REQUIRED_TRIGGER = "Monitor" + +_CAPTURE_CODE_SCHEME = "capture-code" + + +@dataclass(frozen=True) +class RunWitnessedStartEvents: + """Events produced by a witnessed Run genesis: always exactly one.""" + + run_events: list[RunStarted] + + +def decide( + state: Run | None, + command: RecordWitnessedRun, + *, + context: RunWitnessedStartContext, + needed_family_ids_snapshot: frozenset[UUID], + needed_supplies_snapshot: frozenset[str] = frozenset(), + effective_parameters: dict[str, Any], + method_parameters_schema: dict[str, Any] | None, + now: datetime, + new_id: UUID, +) -> RunWitnessedStartEvents: + """Decide the events produced by recording a witnessed Run genesis.""" + if state is not None: + raise RunAlreadyExistsError(state.id) + + if command.trigger != _REQUIRED_TRIGGER: + raise RunMonitorTriggerNotPermittedError(new_id, command.trigger) + + # Witnessed, not enforced: enclosure permit and beam availability. + # Clearance and Supply still raise here (CORA's own data, not a live + # facility signal), via the exact same gate functions + # check_safety_envelope composes, so the two entry points cannot + # silently drift on what a gate checks. + safety_envelope_verdict = witness_safety_envelope( + run_id=new_id, + referencing_clearances=context.referencing_clearances, + needed_supplies_snapshot=needed_supplies_snapshot, + needed_supplies_satisfaction=context.needed_supplies_satisfaction, + referencing_enclosures=context.referencing_enclosures, + beam_availability=context.beam_availability, + ) + + if context.plan.status is PlanStatus.DEPRECATED: + raise RunBoundPlanDeprecatedError(context.plan.id) + + if context.subject is not None and context.subject.status not in _SUBJECT_RUNNABLE_STATUSES: + raise RunSubjectNotMountableError( + context.subject.id, current_status=context.subject.status.value + ) + + decommissioned = sorted( + ( + asset.id + for asset in context.assets.values() + if asset.lifecycle is AssetLifecycle.DECOMMISSIONED + ), + key=str, + ) + if decommissioned: + raise RunPlanAssetDecommissionedError(decommissioned) + + union_capabilities: frozenset[UUID] = frozenset( + cap for asset in context.assets.values() for cap in asset.family_ids + ) + missing = needed_family_ids_snapshot - union_capabilities + if missing: + raise RunCapabilitiesNotSatisfiedError(missing) + + validate_effective_parameters_against_method_schema( + effective_parameters, method_parameters_schema + ) + + for wire in context.plan.wires: + validate_wire_endpoints( + wire, + bound_asset_ids=context.plan.asset_ids, + assets_by_id=context.assets, + ) + + name = RunName(command.name) # validates + trims; raises InvalidRunNameError + + acknowledged_cautions = tuple( + CautionAcknowledgement( + caution_id=caution.caution_id, + target_kind=caution.target_kind, + target_id=caution.target_id, + category=caution.category, + severity=caution.severity, + text_excerpt=caution.text_excerpt, + workaround_excerpt=caution.workaround_excerpt, + ) + for caution in context.active_cautions + ) + + external_refs = ( + { + "scheme": Identifier(scheme=_CAPTURE_CODE_SCHEME, value=command.capture_code).scheme, + "value": command.capture_code, + }, + ) + + run_events = [ + RunStarted( + run_id=new_id, + name=name.value, + plan_id=command.plan_id, + subject_id=command.subject_id, + conduct_mode=ConductMode.WITNESSED, + trigger_source=f"RunWitness:{command.capture_code}", + effective_parameters=effective_parameters, + external_refs=external_refs, + acknowledged_cautions=acknowledged_cautions, + safety_envelope_verdict=safety_envelope_verdict, + occurred_at=now, + ) + ] + return RunWitnessedStartEvents(run_events=run_events) diff --git a/apps/api/src/cora/run/features/record_witnessed_run/handler.py b/apps/api/src/cora/run/features/record_witnessed_run/handler.py new file mode 100644 index 00000000000..1194a08b9f7 --- /dev/null +++ b/apps/api/src/cora/run/features/record_witnessed_run/handler.py @@ -0,0 +1,239 @@ +"""Application handler for the `record_witnessed_run` slice: the witnessed genesis. + +Trimmed sibling of `start_run/handler.py`'s pre-load + scope-widening + +cross-BC lookup sequence (Plan -> Practice -> Method -> Assets -> Subject, +then controller + ancestor-chain widening, then clearance / enclosure / +caution / supply / beam reads). This is a second, independent copy rather +than a shared helper: hoisting the assembly is worth doing once a third +caller creates a genuine rule-of-three pressure, which this slice does not +yet justify on its own (see the commit history for the reasoning). + +Not wrapped in `with_idempotency`: the Run id is fresh and random per +call, so there is no retry key to collapse against. Dedup against a +repeated substrate observation (the PV re-reporting the same capture's +begin) is the RunWitness runtime's own edge-triggered state, not this +handler's concern. + +Per the roadmap's anti-scope: no REST route, no MCP tool reach this +handler (see `route.py` / `tool.py`, both stubs). The authorized path in +is the bound handler on `RunHandlers.record_witnessed_run`, called only by +the in-process RunWitness runtime as a seeded Agent principal. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.asset import Asset, AssetNotFoundError, load_asset +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny, SupplyLookupResult +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.recipe.aggregates.method import MethodNotFoundError, load_method +from cora.recipe.aggregates.plan import PlanNotFoundError, load_plan +from cora.recipe.aggregates.practice import PracticeNotFoundError, load_practice +from cora.run.aggregates.run import event_type_name, to_payload +from cora.run.errors import UnauthorizedError +from cora.run.features.record_witnessed_run.command import RecordWitnessedRun +from cora.run.features.record_witnessed_run.context import RunWitnessedStartContext +from cora.run.features.record_witnessed_run.decider import decide +from cora.shared.json_merge_patch import merge_patch +from cora.subject.aggregates.subject import SubjectNotFoundError, load_subject + +_STREAM_TYPE = "Run" +_COMMAND_NAME = "RecordWitnessedRun" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every record_witnessed_run handler implements.""" + + async def __call__( + self, + command: RecordWitnessedRun, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +def bind(deps: Kernel) -> Handler: + """Build a record_witnessed_run handler closed over the shared deps.""" + + async def handler( + command: RecordWitnessedRun, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "record_witnessed_run.start", + command_name=_COMMAND_NAME, + plan_id=str(command.plan_id), + capture_code=command.capture_code, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "record_witnessed_run.denied", + command_name=_COMMAND_NAME, + plan_id=str(command.plan_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + plan = await load_plan(deps.event_store, command.plan_id) + if plan is None: + raise PlanNotFoundError(command.plan_id) + + practice = await load_practice(deps.event_store, plan.practice_id) + if practice is None: + raise PracticeNotFoundError(plan.practice_id) + + method = await load_method(deps.event_store, practice.method_id) + if method is None: + raise MethodNotFoundError(practice.method_id) + + assets: dict[UUID, Asset] = {} + for asset_id in sorted(plan.asset_ids, key=str): + asset = await load_asset(deps.event_store, asset_id) + if asset is None: + raise AssetNotFoundError(asset_id) + assets[asset_id] = asset + + subject = None + if command.subject_id is not None: + subject = await load_subject(deps.event_store, command.subject_id) + if subject is None: + raise SubjectNotFoundError(command.subject_id) + + new_id = deps.id_generator.new_id() + + # Same controller + ancestor-chain widening as start_run/handler.py: + # see that module's docstring comment for the full rationale. + scoped_asset_ids = plan.asset_ids | { + asset.controller_id for asset in assets.values() if asset.controller_id is not None + } + ancestor_rows = await deps.asset_lookup.ancestors_of(scoped_asset_ids) + scoped_asset_ids = scoped_asset_ids | {row.id for row in ancestor_rows} + + referencing_clearances = tuple( + await deps.clearance_lookup.find_covering( + run_id=new_id, + subject_id=command.subject_id, + asset_ids=scoped_asset_ids, + ) + ) + + located_in_enclosure_ids = frozenset( + row.located_in_enclosure_id + for row in ancestor_rows + if row.located_in_enclosure_id is not None + ) + referencing_enclosures = tuple( + await deps.enclosure_lookup.find_by_ids(enclosure_ids=located_in_enclosure_ids) + ) + + active_cautions = tuple( + await deps.caution_lookup.find_active_in_scope( + asset_ids=scoped_asset_ids, + procedure_ids=frozenset(), + ) + ) + + needed_supplies_satisfaction: dict[str, tuple[SupplyLookupResult, ...]] = {} + if method.needed_supplies: + satisfaction = await deps.supply_lookup.find_supplies_by_kind( + kinds=method.needed_supplies, + ) + needed_supplies_satisfaction = { + kind: tuple(refs) for kind, refs in satisfaction.items() + } + + # Same BEAM-1 read as start_run/handler.py. Witnessed here, not + # enforced: the decider's witness_safety_envelope records this + # reading on the emitted RunStarted rather than gating on it. + beam_availability = await deps.beam_availability_lookup.read() + + context = RunWitnessedStartContext( + plan=plan, + subject=subject, + assets=assets, + referencing_clearances=referencing_clearances, + active_cautions=active_cautions, + needed_supplies_satisfaction=needed_supplies_satisfaction, + referencing_enclosures=referencing_enclosures, + beam_availability=beam_availability, + ) + + now = deps.clock.now() + + # No override_parameters on this command: the Plan's own defaults + # govern, unmodified. merge_patch against an empty patch is the + # identity operation, kept for symmetry with start_run's merge so + # the Method schema validation sees the same shape either path. + effective_parameters = merge_patch(plan.default_parameters, {}) + + run_decision = decide( + state=None, + command=command, + context=context, + needed_family_ids_snapshot=method.needed_family_ids, + needed_supplies_snapshot=method.needed_supplies, + effective_parameters=effective_parameters, + method_parameters_schema=method.parameters_schema, + now=now, + new_id=new_id, + ) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in run_decision.run_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=new_id, + expected_version=0, + events=new_events, + ) + + _log.info( + "record_witnessed_run.success", + command_name=_COMMAND_NAME, + run_id=str(new_id), + plan_id=str(command.plan_id), + capture_code=command.capture_code, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + ) + return new_id + + return handler diff --git a/apps/api/src/cora/run/features/record_witnessed_run/route.py b/apps/api/src/cora/run/features/record_witnessed_run/route.py new file mode 100644 index 00000000000..cb69f6e2def --- /dev/null +++ b/apps/api/src/cora/run/features/record_witnessed_run/route.py @@ -0,0 +1,21 @@ +"""Stub route module for `record_witnessed_run` (in-process-only slice). + +Per the roadmap's anti-scope: no operator path to a witnessed genesis. No +REST route, no MCP tool, typed `MonitorSourceId` and a `trigger` guard, +mirroring the shipped `observe_enclosure_status` lock. This is the wall +that stops the witnessed path being used to launder around a driven +refusal. In-process adapters (the capture-watch runtime) call +`RunHandlers.record_witnessed_run(...)` directly. + +The empty `router` exists only to satisfy the slice-file-shape + +routes-completeness architecture fitness functions; no routes are +registered on it. +""" + +from fastapi import APIRouter, Depends + +from cora.infrastructure.routing import get_surface_id + +router = APIRouter() + +_STUB_DEPENDS = Depends(get_surface_id) diff --git a/apps/api/src/cora/run/features/record_witnessed_run/tool.py b/apps/api/src/cora/run/features/record_witnessed_run/tool.py new file mode 100644 index 00000000000..cdc54a8259d --- /dev/null +++ b/apps/api/src/cora/run/features/record_witnessed_run/tool.py @@ -0,0 +1,29 @@ +"""Stub MCP tool module for `record_witnessed_run` (in-process-only slice). + +Per the roadmap's anti-scope: this slice is NOT exposed as an MCP tool. +In-process adapters call `RunHandlers.record_witnessed_run(...)` directly. + +The no-op `register` exists only to satisfy the slice-file-shape + +tools-completeness architecture fitness functions; no MCP tool is +registered. The `get_mcp_surface_id` import satisfies the +mcp-surface-id-injection fitness; the resolver is not actually called +because no tool consumes it. +""" + +from collections.abc import Callable + +from mcp.server.fastmcp import FastMCP + +from cora.infrastructure.routing import get_mcp_surface_id +from cora.run.features.record_witnessed_run.handler import Handler + +_STUB_RESOLVER = get_mcp_surface_id + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """No-op MCP registration: record_witnessed_run is in-process-only.""" + _ = mcp + _ = get_handler + _ = _STUB_RESOLVER + if False: # pragma: no cover -- AST satisfaction for fitness scan + _ = get_mcp_surface_id(None) # type: ignore[arg-type] diff --git a/apps/api/src/cora/run/features/start_run/command.py b/apps/api/src/cora/run/features/start_run/command.py index 3e717013231..dd1e58942a7 100644 --- a/apps/api/src/cora/run/features/start_run/command.py +++ b/apps/api/src/cora/run/features/start_run/command.py @@ -31,6 +31,14 @@ command — see Run aggregate's `state.py` docstring for the enum-in-state, derived-from-event-type-in-evolver convention. +`ConductMode` is likewise not a command field. Every Run started through +this slice is driven by CORA's own Conductor, so the decider hardcodes +`ConductMode.CONDUCTED` on the emitted `RunStarted` rather than accepting +it from the caller: the mode is a property of which decider ran, not a +value a caller could set. A `Witnessed` Run is genesis-only through the +separate witnessed-genesis slice, never through this one. See +`ConductMode`'s own docstring on `Run`. + The handler additionally pre-loads Plan + Subject (if given) + each bound Asset (from `plan.asset_ids`) to build a `RunStartContext` for the decider (gate-review Q2 / Q5 pattern), diff --git a/apps/api/src/cora/run/features/start_run/decider.py b/apps/api/src/cora/run/features/start_run/decider.py index 05a2d3e6e66..7c0b03634b1 100644 --- a/apps/api/src/cora/run/features/start_run/decider.py +++ b/apps/api/src/cora/run/features/start_run/decider.py @@ -88,6 +88,7 @@ from cora.recipe.aggregates.plan import PlanStatus, validate_wire_endpoints from cora.run.aggregates.run import ( CautionAcknowledgement, + ConductMode, Run, RunAlreadyExistsError, RunBoundPlanDeprecatedError, @@ -399,6 +400,12 @@ def decide( name=name.value, plan_id=command.plan_id, subject_id=command.subject_id, + # Hardcoded, not read from the command: this decider drives + # every Run it starts through CORA's own Conductor, so the + # mode is a property of which decider ran, never a caller's + # choice. A Witnessed Run is genesis-only through the separate + # witnessed-genesis decider. + conduct_mode=ConductMode.CONDUCTED, raid=command.raid, override_parameters=command.override_parameters, effective_parameters=effective_parameters, diff --git a/apps/api/src/cora/run/ports/__init__.py b/apps/api/src/cora/run/ports/__init__.py index dc2e7f39cc2..edfeb54cd05 100644 --- a/apps/api/src/cora/run/ports/__init__.py +++ b/apps/api/src/cora/run/ports/__init__.py @@ -1,10 +1,18 @@ """Run-BC-local hexagonal ports (seams the Run BC owns). Cross-BC ports live in `infrastructure/ports/`; these are owned by Run -because their sole consumer is the Run-watching composition-root runtime -(the RunSupervisor). See [[project_observation_signal_port_design]]. +because their sole consumer is a Run-watching composition-root runtime +(the RunSupervisor, and now the capture-observing RunWitness). See +[[project_observation_signal_port_design]]. """ +from cora.run.ports.capture_observer import ( + CaptureObservation, + CaptureObserver, + CaptureObserverScope, + CapturePhase, + QuietCaptureObserver, +) from cora.run.ports.run_channel_lookup import ( InMemoryRunChannelLookup, RunChannelLatest, @@ -14,7 +22,12 @@ ) __all__ = [ + "CaptureObservation", + "CaptureObserver", + "CaptureObserverScope", + "CapturePhase", "InMemoryRunChannelLookup", + "QuietCaptureObserver", "RunChannelLatest", "RunChannelLookup", "RunChannelSignal", diff --git a/apps/api/src/cora/run/ports/capture_observer.py b/apps/api/src/cora/run/ports/capture_observer.py new file mode 100644 index 00000000000..f0f0bd8e7f6 --- /dev/null +++ b/apps/api/src/cora/run/ports/capture_observer.py @@ -0,0 +1,180 @@ +"""CaptureObserver port: substrate-driven capture-lifecycle observation stream. + +`CaptureObserver` is the BC-local async Protocol a Run-watching runtime +uses to drain capture-lifecycle observations from an external +acquisition tool's substrate (EPICS PV monitors, P4P PVA subscriptions, +Tango attribute callbacks). Substrate details live behind concrete +adapters; the runtime never touches substrate-specific symbols +directly. Mirrors `EnclosureObserver` +([[project_enclosure_stage1_design]] L-port-1 + L-CHARTER-4) at every +layer; the two ports differ only in what they watch. + +## BC-local, not promoted to infrastructure/ports + +The sole consumer is the Run-watching composition-root runtime. There +is zero cross-BC consumption today: no other BC reads observations off +this port. Promote to `infrastructure/ports/` only on a real second +cross-BC consumer (rule-of-three), exactly the `RunChannelLookup` +precedent. + +## Domain vocabulary (substrate-neutral) + +- `CapturePhase` (re-exported from `cora.shared.capture_phase`, hoisted + there because `cora.infrastructure` validates a deployment's declared + literal table against it and cannot depend on `cora.run.ports`): the + closed, facility-neutral lifecycle a capture passes through, as CORA + understands it. NOT the literal vocabulary any one facility's tool + emits: 2-BM's TomoScan reports free-text values ("Beginning scan", + "Programming PSO", "Collecting dark fields", "Scan complete") on + `ScanStatus`, and those strings belong to one tomoscan commit, never + to this port or the spine. A deployment DECLARES the mapping from its + own literals to `CapturePhase`; unmapped or unrecognized substrate + values classify as `UNRECOGNIZED`, never silently as `PROGRESSING` or + dropped. +- `CaptureObservation`: one capture-lifecycle reading drained from the + substrate, scoped by `capture_code` (the identity surface the + runtime's configuration knows, e.g. a named acquisition path). Same + reach-tier and dual-clock shape as `EnclosureObservation`. +- `CaptureObserverScope`: the set of capture codes the substrate + adapter should subscribe to. Empty scope is valid and yields no + observations. + +## No terminal claims about a file + +A `CapturePhase.ENDED` observation states only that the external tool +reported its lifecycle as finished. It makes NO claim that any file +exists, is complete, or has arrived: 2-BM's own operations reopen the +HDF5 file to append theta AFTER reporting "Scan complete", and the +transfer-status messages mark transfer start, not arrival. Binding an +observed capture to a Dataset is a separate, later, independently- +verified act and is out of scope for this port entirely. + +## D6.L2-equivalent anti-lock posture + +There is no operator gesture on this port. `CaptureObserver` has no +write half: it is read-only by construction, mirroring the fact that +CORA's `ControlPort` at 2-BM is itself wrapped read-only. Every +observation that crosses this seam represents a substrate reading; the +inbound adapter is responsible for that constraint, exactly as +`EnclosureObserver`'s docstring states for its own seam. + +## Subscribe shape + +`observe` is a plain `def` returning `AsyncIterator[CaptureObservation]` +directly (no surrounding coroutine), iterated with +`async for observation in observer.observe(scope):`. Connect setup may +happen lazily on the first `__anext__`. Mid-stream disconnect is the +adapter's concern. +""" + +from collections.abc import AsyncGenerator, AsyncIterator +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol, runtime_checkable + +from cora.shared.capture_phase import CapturePhase +from cora.shared.reach import ReachTier + + +@dataclass(frozen=True) +class CaptureObservation: + """One capture-lifecycle reading from the substrate. + + `capture_code` is the identity surface the runtime's configuration + knows (the deployment-declared name for the acquisition path being + watched), not a Dataset or file identity. + + `reported_status` is the raw string the adapter read from the + substrate, or `None` when this observation makes no status claim + at all (a probe-only re-affirmation read, mirroring + `EnclosureObservation.observed_status`). `phase` is the same + reading already classified against `CapturePhase` by the adapter + using the deployment's declared literal table; carrying both + lets a consumer log the facility's own words alongside CORA's + classification of them. + + `reach_tier` states what kind of evidence backed this observation + (see `ReachTier`); required, not optional, so every adapter states + its own evidence rather than letting a consumer infer it. + + `observed_at` is the substrate's own time for the observation, and + is `None` when the substrate supplied none. An adapter with no + substrate time MUST answer `None` rather than supply its own + clock: a synthesized time is indistinguishable from a reported one + once it is written down. Same rule as `EnclosureObservation`. + + `source_kind` and `source_id` are the attribution pair, carried + unmodified onto any downstream record exactly as the Enclosure + seam does for `monitor_ref`. + """ + + capture_code: str + reported_status: str | None + phase: CapturePhase | None + reach_tier: ReachTier + observed_at: datetime | None + source_kind: str + source_id: str + + +@dataclass(frozen=True) +class CaptureObserverScope: + """Subscription scope: the set of capture codes to observe. + + Empty scope is valid and yields no observations (the adapter exits + the async iterator immediately). + """ + + capture_codes: frozenset[str] + + +@runtime_checkable +class CaptureObserver(Protocol): + """Async source of `CaptureObservation` values from the substrate. + + The substrate adapter owns the subscription lifecycle. A runtime + iterates and forwards each observation onward; this port makes no + claim about what the runtime does with what it drains. + + Iteration semantics mirror `EnclosureObserver`: open-ended for live + substrates, `StopAsyncIteration` is a clean teardown for one-shot + observers (tests, stubs), and disconnect handling is the adapter's + concern. + """ + + def observe(self, scope: CaptureObserverScope) -> AsyncIterator[CaptureObservation]: + """Open an observation stream over the supplied scope. + + Returns an `AsyncIterator[CaptureObservation]` directly (no + surrounding coroutine). Connect setup may happen lazily on the + first `__anext__` call. + """ + ... + + +class QuietCaptureObserver: + """Stub `CaptureObserver` that yields nothing. + + The canonical zero-substrate stub for tests and for a deployment + that has not declared any capture PVs. Mirrors + `AlwaysPermittedEnclosureObserver`'s role, but yields no + observations rather than one synthetic reading per code: there is + no safe "always" value for a capture phase the way there is for a + permit status, so silence is the honest stub here. + """ + + def observe(self, scope: CaptureObserverScope) -> AsyncGenerator[CaptureObservation]: + return self._drain(scope) + + async def _drain(self, scope: CaptureObserverScope) -> AsyncGenerator[CaptureObservation]: + for _ in (): + yield _ + + +__all__ = [ + "CaptureObservation", + "CaptureObserver", + "CaptureObserverScope", + "CapturePhase", + "QuietCaptureObserver", +] diff --git a/apps/api/src/cora/run/projections/summary.py b/apps/api/src/cora/run/projections/summary.py index 7fd008893c1..519863637e6 100644 --- a/apps/api/src/cora/run/projections/summary.py +++ b/apps/api/src/cora/run/projections/summary.py @@ -8,7 +8,8 @@ subject_id? + raid? + running_since + override_parameters_present + campaign_id? + - pinned_calibration_ids from payload) + pinned_calibration_ids + + conduct_mode from payload) - RunHeld -> UPDATE status=Held - RunResumed -> UPDATE status=Running + running_since reset - RunCompleted -> UPDATE status=Completed (terminal) @@ -27,10 +28,11 @@ [[project_fold_symmetry_design]]) All branches idempotent. Genesis-event payload values (plan_id, -subject_id, raid, override_parameters_present, pinned_calibration_ids) -land on INSERT and never change (AsShot invariant for -pinned_calibration_ids); lifecycle UPDATEs only touch `status`; -membership UPDATEs only touch `campaign_id`. +subject_id, raid, override_parameters_present, pinned_calibration_ids, +conduct_mode) land on INSERT and never change (AsShot invariant for +pinned_calibration_ids; conduct_mode is immutable for the same reason: +who drove the act cannot change after genesis); lifecycle UPDATEs +only touch `status`; membership UPDATEs only touch `campaign_id`. `override_parameters_present` is TRUE iff RunStarted's `override_parameters` payload was non-empty (operator customized @@ -72,8 +74,8 @@ INSERT INTO proj_run_summary (run_id, name, plan_id, subject_id, raid, status, created_at, running_since, override_parameters_present, campaign_id, pinned_calibration_ids, - snr_limit, expected_observation_interval_seconds) -VALUES ($1, $2, $3, $4, $5, 'Running', $6, $6, $7, $8, $9::uuid[], $10, $11) + snr_limit, expected_observation_interval_seconds, conduct_mode) +VALUES ($1, $2, $3, $4, $5, 'Running', $6, $6, $7, $8, $9::uuid[], $10, $11, $12) ON CONFLICT (run_id) DO NOTHING """ @@ -190,6 +192,11 @@ async def apply( # rows land with an empty UUID array. pinned_calibration_ids = [UUID(p) for p in payload.get("pinned_calibration_ids", [])] snr_limit, expected_interval = _rule_inputs(payload) + # Forward-compat: legacy RunStarted payloads have no + # conduct_mode key; .get(..., "Conducted") returns the + # historical default so legacy rows land as Conducted, which + # is true of every Run started before this field existed. + conduct_mode = payload.get("conduct_mode", "Conducted") await conn.execute( _INSERT_RUN_SQL, UUID(payload["run_id"]), @@ -203,6 +210,7 @@ async def apply( pinned_calibration_ids, snr_limit, expected_interval, + conduct_mode, ) return if event.event_type == "RunResumed": diff --git a/apps/api/src/cora/run/routes.py b/apps/api/src/cora/run/routes.py index 5ae16c1a499..ce5aeecbf14 100644 --- a/apps/api/src/cora/run/routes.py +++ b/apps/api/src/cora/run/routes.py @@ -50,6 +50,7 @@ - 409 (Run adjust transition guard, 6j): RunCannotAdjustError - 400 (validation, 12b-5 adds): InvalidPinnedCalibrationsError - 400 (validation): InvalidInputDatasetsError + - 400 (witnessed-genesis trigger guard): RunMonitorTriggerNotPermittedError """ from fastapi import FastAPI, Request, status @@ -90,6 +91,7 @@ RunHoldClaimsRemainError, RunInputNotReachableError, RunInputNotVerifiedError, + RunMonitorTriggerNotPermittedError, RunNotFoundError, RunObservationLogbookClosedError, RunPlanAssetDecommissionedError, @@ -110,6 +112,7 @@ get_run, hold_run, list_runs, + record_witnessed_run, resume_run, start_run, stop_run, @@ -198,6 +201,11 @@ async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONRes def register_run_routes(app: FastAPI) -> None: """Attach Run slice routers and exception handlers to the FastAPI app.""" app.include_router(start_run.router) + # Stub router inclusion for the in-process-only witnessed-genesis slice. + # The router carries no routes by design; this include satisfies the + # routes-completeness architecture fitness without exposing a public + # HTTP surface. + app.include_router(record_witnessed_run.router) app.include_router(complete_run.router) app.include_router(abort_run.router) app.include_router(hold_run.router) @@ -231,6 +239,10 @@ def register_run_routes(app: FastAPI) -> None: # Input-Dataset reference set cardinality cap (PROV `used`; # symmetric to the pinned_calibration_ids cap). InvalidInputDatasetsError, + # Watched-genesis trigger guard: mirrors the Enclosure BC's + # MonitorTriggerNotPermittedError registration for an + # in-process-only slice even with no route mounted. + RunMonitorTriggerNotPermittedError, ): app.add_exception_handler(validation_cls, _handle_validation_error) # Obligation gate (Gate III): missing/invalid justification -> 422. diff --git a/apps/api/src/cora/run/tools.py b/apps/api/src/cora/run/tools.py index 5a9cb28e2aa..f1e527c1f0d 100644 --- a/apps/api/src/cora/run/tools.py +++ b/apps/api/src/cora/run/tools.py @@ -17,6 +17,7 @@ from cora.run.features.get_run import tool as get_run_tool from cora.run.features.hold_run import tool as hold_run_tool from cora.run.features.list_runs import tool as list_runs_tool +from cora.run.features.record_witnessed_run import tool as record_witnessed_run_tool from cora.run.features.resume_run import tool as resume_run_tool from cora.run.features.start_run import tool as start_run_tool from cora.run.features.stop_run import tool as stop_run_tool @@ -34,6 +35,14 @@ def register_run_tools( mcp, get_handler=lambda: get_handlers().start_run, ) + # Stub registration for the in-process-only witnessed-genesis slice. + # The tool module's register() is a no-op by design; this invocation + # satisfies the tools-completeness architecture fitness without + # exposing a public MCP tool surface. + record_witnessed_run_tool.register( + mcp, + get_handler=lambda: get_handlers().record_witnessed_run, + ) complete_run_tool.register( mcp, get_handler=lambda: get_handlers().complete_run, diff --git a/apps/api/src/cora/run/wire.py b/apps/api/src/cora/run/wire.py index 73a6d166746..51230a58341 100644 --- a/apps/api/src/cora/run/wire.py +++ b/apps/api/src/cora/run/wire.py @@ -24,6 +24,12 @@ strict-not-idempotent (the guard rejects double-application and ConcurrencyError catches the persistence-layer double-submit case). +`record_witnessed_run` is a second, independent genesis (the witnessed +path). NOT idempotency-wrapped despite being create-style: the Run id +is fresh and random per call, so there is no Idempotency-Key to +collapse a retry against. In-process-only; no route, no MCP tool ever +call it. + `append_observations` writes the polymorphic sensor / motor observation logbook (SOSA `sampling_procedure` discriminator; lazy open-on-first- write). Not idempotency-wrapped: natural idempotence via the @@ -69,6 +75,7 @@ get_run, hold_run, list_runs, + record_witnessed_run, resume_run, start_run, stop_run, @@ -83,6 +90,7 @@ class RunHandlers: """The Run BC's handler bundle, each closed over Kernel.""" start_run: start_run.IdempotentHandler + record_witnessed_run: record_witnessed_run.Handler complete_run: complete_run.Handler abort_run: abort_run.Handler hold_run: hold_run.Handler @@ -115,6 +123,11 @@ def wire_run(deps: Kernel) -> RunHandlers: command_name="StartRun", bc=_BC, ), + record_witnessed_run=with_tracing( + record_witnessed_run.bind(deps), + command_name="RecordWitnessedRun", + bc=_BC, + ), complete_run=with_tracing( complete_run.bind(deps), command_name="CompleteRun", diff --git a/apps/api/src/cora/shared/capture_phase.py b/apps/api/src/cora/shared/capture_phase.py new file mode 100644 index 00000000000..b250e1d6170 --- /dev/null +++ b/apps/api/src/cora/shared/capture_phase.py @@ -0,0 +1,34 @@ +"""CapturePhase: the facility-neutral lifecycle phase of an observed capture. + +Originated on the Run BC's `CaptureObserver` port and hoisted here for +the same reason `ReachTier` was: `cora.infrastructure` (where +`Settings.capture_status_phases` validates a deployment's declared +literal-to-phase mapping) cannot depend on `cora.run.ports` (tach: BCs +depend on infrastructure, never the reverse), so the one enum both +sides need to agree on has to live below both of them. +""" + +from enum import StrEnum + + +class CapturePhase(StrEnum): + """The facility-neutral lifecycle phase of an observed capture. + + Closed and small on purpose: this is the vocabulary CORA's spine + reasons over, not the vocabulary any one facility's tool emits. + `UNRECOGNIZED` is a first-class member, not an absence: a substrate + literal that does not match the deployment's declared mapping + reports `UNRECOGNIZED` rather than being coerced into a nearby + phase or dropped silently, so a vocabulary drift (a tool upgrade + that renames a status) is visible in the record rather than + misread as routine progress. + """ + + BEGUN = "Begun" + PROGRESSING = "Progressing" + ENDED = "Ended" + ABORTED = "Aborted" + UNRECOGNIZED = "Unrecognized" + + +__all__ = ["CapturePhase"] diff --git a/apps/api/src/cora/shared/reach.py b/apps/api/src/cora/shared/reach.py new file mode 100644 index 00000000000..ed580f8f027 --- /dev/null +++ b/apps/api/src/cora/shared/reach.py @@ -0,0 +1,34 @@ +"""ReachTier: how CORA reached a substrate for one observation. + +Originated in the Enclosure BC's permit-probe trail +([[project_enclosure_permit_probe_design]]) and hoisted here once a +second BC (Run, for the capture-observe seam) needed the same +vocabulary. `cora.run` cannot depend on `cora.enclosure.aggregates` +(tach), and the concept is substrate-neutral: it grades CORA's own +reach to whatever it is watching, never the thing being watched. +`cora.shared` has no dependencies of its own, so any BC may use this +without adding a cross-BC edge. +""" + +from enum import StrEnum + + +class ReachTier(StrEnum): + """How CORA reached a substrate for one observation. + + Two values ship. `RELAYED` means CORA received or fetched a value + through the configured channel; `UNREACHED` means it could not, + this tick. A stronger tier for a confirmed direct round trip to + the authoritative source (as opposed to an intermediary, such as + an EPICS CA gateway that may answer from its own cache) is + deliberately NOT defined here: no producer in this codebase can + currently prove one, and an unearned strong claim is worse than + none. Adding a value later needs no migration in a consumer whose + column is a length-CHECK rather than a value-enumerating CHECK. + """ + + RELAYED = "Relayed" + UNREACHED = "Unreached" + + +__all__ = ["ReachTier"] diff --git a/apps/api/tests/architecture/test_agent_kind_doer_form.py b/apps/api/tests/architecture/test_agent_kind_doer_form.py index 31798aef83e..c77342b42e7 100644 --- a/apps/api/tests/architecture/test_agent_kind_doer_form.py +++ b/apps/api/tests/architecture/test_agent_kind_doer_form.py @@ -32,11 +32,18 @@ _PASCAL_COMPOUND = re.compile(r"^[A-Z][a-z]+([A-Z][a-z]+)+$") # Natural English doer suffixes per R5. The list is not exhaustive -# (zero-change doers like `Monitor` or `Coordinator` also count); -# extend when a new agent legitimately needs a doer form outside -# this set. +# (zero-change doers like `Monitor` or `Coordinator` also count, though +# both happen to already end in `-or`); extend when a new agent +# legitimately needs a doer form outside this set. _DOER_SUFFIXES = ("er", "or", "ist", "ant", "tor") +# Whole-word zero-change doers that are natural English doer nouns on +# their own but do not end in any of `_DOER_SUFFIXES` (a witness is one +# who witnesses, exactly as a monitor is one who monitors, but "witness" +# has no suffix to match against). Checked against the compound's final +# PascalCase segment, not the whole value. +_ZERO_CHANGE_DOER_WORDS = frozenset({"Witness"}) + _AGENT_DIR = CORA_ROOT / "agent" @@ -89,12 +96,16 @@ def test_seeded_agent_kinds_follow_r5_doer_form() -> None: "`CautionDrafter`)." ) continue - if not any(value.endswith(suffix) for suffix in _DOER_SUFFIXES): + final_segment = re.findall(r"[A-Z][a-z]+", value)[-1] + if not any(value.endswith(suffix) for suffix in _DOER_SUFFIXES) and ( + final_segment not in _ZERO_CHANGE_DOER_WORDS + ): failures.append( - f"{ref}={value!r}: doesn't end in a doer suffix {_DOER_SUFFIXES}. " - "R5 also allows zero-change doers (`Monitor`, `Coordinator`); " - "if this name is intentionally a zero-change doer, extend " - "`_DOER_SUFFIXES` to include the relevant ending." + f"{ref}={value!r}: doesn't end in a doer suffix {_DOER_SUFFIXES} " + f"or a known zero-change doer word {sorted(_ZERO_CHANGE_DOER_WORDS)}. " + "If this name is intentionally a whole-word doer noun (a " + "witness is one who witnesses, exactly as a monitor is one " + "who monitors), extend `_ZERO_CHANGE_DOER_WORDS`." ) assert not failures, "Found R5 violations:\n - " + "\n - ".join(failures) diff --git a/apps/api/tests/architecture/test_command_name_derives_event_name.py b/apps/api/tests/architecture/test_command_name_derives_event_name.py index d330419b74e..dd064a6aa3a 100644 --- a/apps/api/tests/architecture/test_command_name_derives_event_name.py +++ b/apps/api/tests/architecture/test_command_name_derives_event_name.py @@ -149,6 +149,12 @@ # the command states the act of recording it. Same shape as # `StartRun` emitting `RunStarted`. Do not "fix" this pair. "trust/record_visit_arrival": "PR #318 kept the arrival-fact event over the recording verb", + # Same shape as trust/record_visit_arrival, cited there by name: + # RecordWitnessedRun emits RunStarted, not WitnessedRunRecorded, because + # the event states the genesis fact (a Run started) and the command + # states the act of recording it. Symmetric with StartRun -> RunStarted, + # the driven genesis's own sanctioned pair. + "run/record_witnessed_run": "witnessed genesis; states the fact, not the recording verb", } _KNOWN_DRIFT: dict[str, str] = { diff --git a/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py b/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py new file mode 100644 index 00000000000..b9afc00228e --- /dev/null +++ b/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py @@ -0,0 +1,70 @@ +"""ConductMode is a closed, never-Optional vocabulary declared at genesis. + +`ConductMode` (cora.run.aggregates.run.state) reifies who drove a Run's +act: CORA's own Conductor, or an external tool CORA only observes. Three +properties make "declared by the decider that ran, never a caller's +choice" a build-time guarantee rather than a convention someone can +forget: + + - The enum stays closed to exactly {CONDUCTED, WITNESSED}. A third member + added without a design decision (see docs/reference/modeling.md's + "Conducted vs witnessed" framing) would silently widen what the axis + claims to mean. + - `conduct_mode` is never `Optional`/nullable on `RunStarted` or `Run`. + A Run's cause is always one of the two named values; there is no + "unknown" state for the decider or evolver to paper over with a + guessed default at fold time. (Defaulting to CONDUCTED at + construction is a separate, intentional choice, see each field's own + docstring, distinct from allowing the type itself to go absent.) + - `StartRun` (the driven-genesis command) carries NO `conduct_mode` + field at all. The mode is a property of which decider ran: the + driven `start_run` decider hardcodes `ConductMode.CONDUCTED`; a + `Witnessed` Run is genesis-only through the separate witnessed-genesis + slice. A field on `StartRun` would let any caller of the driven path + claim `Witnessed` while taking the enforcing path, which is exactly + the laundering hole this axis exists to close. +""" + +from __future__ import annotations + +from typing import get_args, get_type_hints + +import pytest + +from cora.run.aggregates.run.events import RunStarted +from cora.run.aggregates.run.state import ConductMode, Run +from cora.run.features.start_run.command import StartRun + + +@pytest.mark.architecture +def test_conduct_mode_has_exactly_two_members() -> None: + names = {member.name for member in ConductMode} + assert names == {"CONDUCTED", "WITNESSED"}, ( + f"ConductMode grew a member beyond {{CONDUCTED, WITNESSED}}: {names}. " + "A third mode is a design decision (who else can drive a Run's act?), " + "not a mechanical addition; see docs/reference/modeling.md's " + "'Conducted vs witnessed' framing before widening this enum." + ) + + +@pytest.mark.architecture +@pytest.mark.parametrize("carrier", [RunStarted, Run], ids=lambda c: c.__name__) +def test_conduct_mode_field_is_never_optional(carrier: type) -> None: + hints = get_type_hints(carrier) + assert "conduct_mode" in hints, f"{carrier.__name__} has no conduct_mode field" + assert type(None) not in get_args(hints["conduct_mode"]), ( + f"{carrier.__name__}.conduct_mode must never be Optional: a Run's cause " + "is always a declared ConductMode value, never silently absent. A default " + "value is fine (see the field's own docstring); an absent/None type is not." + ) + + +@pytest.mark.architecture +def test_start_run_has_no_conduct_mode_field() -> None: + hints = get_type_hints(StartRun) + assert "conduct_mode" not in hints, ( + "StartRun must never carry a conduct_mode field: the driven decider " + "hardcodes ConductMode.CONDUCTED, so the mode is a property of which " + "decider ran, never a caller's choice. A field here would let a driven " + "caller claim Witnessed while taking the enforcing path." + ) diff --git a/apps/api/tests/architecture/test_slice_test_coverage.py b/apps/api/tests/architecture/test_slice_test_coverage.py index aee025bae01..30bb5faa0f3 100644 --- a/apps/api/tests/architecture/test_slice_test_coverage.py +++ b/apps/api/tests/architecture/test_slice_test_coverage.py @@ -108,6 +108,11 @@ # project_enclosure_stage1_design). Mirrors Supply precedent. # In-process adapters call via EnclosureHandlers.observe_enclosure_status. "cora.enclosure.features.observe_enclosure_status", + # Watched-genesis slice: no REST surface by design (the roadmap's + # anti-scope: no operator path to a witnessed Run). Mirrors the + # Enclosure / Supply monitor-trigger precedent. In-process adapters + # call via RunHandlers.record_witnessed_run. + "cora.run.features.record_witnessed_run", # Frame slices: contract tests deferred to a follow-up commit # so the Frame + Mount REST + MCP suite can be authored together # against the shared PlacementBody surface. Decider tests + @@ -191,6 +196,9 @@ # Enclosure monitor trigger: in-process-only per L-D3 / D6.L2 # in project_enclosure_stage1_design. Mirrors Supply. "cora.enclosure.features.observe_enclosure_status", + # Watched-genesis slice: in-process-only by design, no MCP tool. + # Mirrors the Enclosure / Supply monitor-trigger precedent. + "cora.run.features.record_witnessed_run", # --- TODO: real gaps to fill ----------------------------------- # The slice is MCP-registered in `cora..tools.py` but no # contract test exercises the tool schema or call surface. Each @@ -528,6 +536,7 @@ def test_exempt_entries_actually_exist(allowlist_name: str) -> None: { "cora.supply.features.observe_supply_status", "cora.enclosure.features.observe_enclosure_status", + "cora.run.features.record_witnessed_run", } ) diff --git a/apps/api/tests/architecture/test_witnessed_genesis_laundering_wall.py b/apps/api/tests/architecture/test_witnessed_genesis_laundering_wall.py new file mode 100644 index 00000000000..51e5faf61f8 --- /dev/null +++ b/apps/api/tests/architecture/test_witnessed_genesis_laundering_wall.py @@ -0,0 +1,126 @@ +"""The witnessed genesis has no operator path in, by construction. + +Two structural facts the roadmap's anti-scope depends on, each pinned +here rather than left to convention: + + 1. `ConductMode.WITNESSED` is constructed in exactly one place: the + witnessed-genesis decider. If a second construction site appears + anywhere under `src/cora`, an operator-reachable path has found a + way to claim WITNESSED, which is precisely the laundering hole the + axis exists to close. + 2. The in-process-only slices (`observe_enclosure_status`, + `record_witnessed_run`) expose zero REST routes and zero MCP tools. + Their `route.py` / `tool.py` modules are stubs by design; this + confirms the stub actually stays empty rather than trusting the + docstring that says so. +""" + +import ast +from pathlib import Path + +import pytest + +from tests.architecture.conftest import CORA_ROOT, tracked_python_files + +_IN_PROCESS_ONLY_SLICES: tuple[str, ...] = ( + "enclosure/features/observe_enclosure_status", + "run/features/record_witnessed_run", +) + + +def _find_conduct_mode_witnessed_sites() -> list[Path]: + """Every tracked file under src/cora that references `ConductMode.WITNESSED`. + + Scoped to the attribute's base name (`ConductMode`), not just the + attribute name, though `WITNESSED` is unique to this enum today (no + collision risk like the old `RECORDED` name had with + `AcquisitionStatus.RECORDED` / `AttestationStatus.RECORDED` in the + Data BC) -- kept as belt-and-suspenders rather than load-bearing.""" + sites: list[Path] = [] + for path in sorted(tracked_python_files()): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: # pragma: no cover -- defensive + continue + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and node.attr == "WITNESSED" + and isinstance(node.value, ast.Name) + and node.value.id == "ConductMode" + ): + sites.append(path) + break + return sites + + +@pytest.mark.architecture +def test_conduct_mode_witnessed_has_exactly_one_construction_site() -> None: + sites = _find_conduct_mode_witnessed_sites() + relative = sorted(str(p.relative_to(CORA_ROOT)) for p in sites) + assert relative == ["run/features/record_witnessed_run/decider.py"], ( + "ConductMode.WITNESSED must be constructed in exactly one place " + f"(the witnessed-genesis decider); found it referenced in: {relative}. " + "A second site is an operator-reachable path claiming a witnessed " + "genesis, the exact laundering hole this axis exists to close." + ) + + +def _slice_route_module(slice_path: str) -> ast.Module: + path = CORA_ROOT / slice_path / "route.py" + return ast.parse(path.read_text(encoding="utf-8")) + + +def _slice_tool_module(slice_path: str) -> ast.Module: + path = CORA_ROOT / slice_path / "tool.py" + return ast.parse(path.read_text(encoding="utf-8")) + + +@pytest.mark.architecture +@pytest.mark.parametrize("slice_path", _IN_PROCESS_ONLY_SLICES) +def test_in_process_only_slice_route_module_declares_no_endpoints(slice_path: str) -> None: + """No `@router.(...)` decorator anywhere in the stub route module.""" + tree = _slice_route_module(slice_path) + decorated_routes = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + for dec in node.decorator_list + if isinstance(dec, ast.Call) + and isinstance(dec.func, ast.Attribute) + and isinstance(dec.func.value, ast.Name) + and dec.func.value.id == "router" + ] + assert decorated_routes == [], ( + f"{slice_path}/route.py declares a route decorator, but this slice is " + "in-process-only by design. If a real endpoint is intended, remove it " + "from _IN_PROCESS_ONLY_SLICES; otherwise delete the route." + ) + + +@pytest.mark.architecture +@pytest.mark.parametrize("slice_path", _IN_PROCESS_ONLY_SLICES) +def test_in_process_only_slice_tool_module_register_is_a_no_op(slice_path: str) -> None: + """`register()`'s body never calls an MCP tool-registration decorator + or `mcp.tool`/`mcp.add_tool`-shaped call.""" + tree = _slice_tool_module(slice_path) + register_fn = next( + ( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == "register" + ), + None, + ) + assert register_fn is not None, f"{slice_path}/tool.py has no register() function" + tool_registration_calls = [ + node + for node in ast.walk(register_fn) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in {"tool", "add_tool"} + ] + assert tool_registration_calls == [], ( + f"{slice_path}/tool.py's register() calls an MCP tool-registration " + "method, but this slice is in-process-only by design." + ) diff --git a/apps/api/tests/integration/scenarios/test_2bm_run_initiator_tick.py b/apps/api/tests/integration/scenarios/test_2bm_run_initiator_tick.py index 4e257e0848b..42d9d232471 100644 --- a/apps/api/tests/integration/scenarios/test_2bm_run_initiator_tick.py +++ b/apps/api/tests/integration/scenarios/test_2bm_run_initiator_tick.py @@ -31,9 +31,20 @@ from cora.api._run_initiator import initiate_tick from cora.campaign.aggregates.campaign import CampaignIntent from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.infrastructure.event_envelope import to_new_event from cora.infrastructure.kernel import Kernel from cora.infrastructure.projection import ProjectionRegistry, drain_projections from cora.run._projections import register_run_projections +from cora.run.aggregates.run import ( + ConductMode, +) +from cora.run.aggregates.run import ( + event_type_name as run_event_type_name, +) +from cora.run.aggregates.run import ( + to_payload as run_to_payload, +) +from cora.run.aggregates.run.events import RunStarted from cora.run.features.list_runs import bind as bind_list_runs from cora.subject._projections import register_subject_projections from cora.subject.features.list_subjects import bind as bind_list_subjects @@ -303,6 +314,62 @@ async def test_initiator_tick_respects_max_in_flight(db_pool: asyncpg.Pool) -> N assert second == [] +@pytest.mark.integration +async def test_initiator_tick_counts_a_witnessed_run_toward_max_in_flight( + db_pool: asyncpg.Pool, +) -> None: + """A Witnessed Run occupies the same one-stage hardware a driven + Run would: the cap counts it exactly like a Conducted Run, so the tick + starts nothing even though a ready Subject exists. Excluding it here + (unlike the RunSupervisor's hold/resume/liveness, which do skip a + Witnessed Run because CORA does not control it) would let the initiator + start a second, driven Run on the same stage an external tool is + already driving.""" + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue(with_subjects=True)) + await _setup(deps, db_pool, with_subjects=True) + await _drain_subjects(db_pool) + + witnessed_run_id = uuid4() + witnessed = RunStarted( + run_id=witnessed_run_id, + name="witnessed capture", + plan_id=_PLAN_ID, + subject_id=None, + occurred_at=_NOW, + conduct_mode=ConductMode.WITNESSED, + ) + await deps.event_store.append( + stream_type="Run", + stream_id=witnessed_run_id, + expected_version=0, + events=[ + to_new_event( + event_type=run_event_type_name(witnessed), + payload=run_to_payload(witnessed), + occurred_at=witnessed.occurred_at, + event_id=uuid4(), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + await _drain_run(db_pool) + + started: set[UUID] = set() + run_ids = await initiate_tick( + deps=deps, + list_runs=bind_list_runs(deps), + list_subjects=bind_list_subjects(deps), + plan_id=_PLAN_ID, + max_in_flight=1, + started=started, + ) + + assert run_ids == [] + + @pytest.mark.integration async def test_initiator_tick_dedups_already_started_subject(db_pool: asyncpg.Pool) -> None: """With subject A already in the started memory and max_in_flight=2, the tick diff --git a/apps/api/tests/integration/test_list_runs_handler_postgres.py b/apps/api/tests/integration/test_list_runs_handler_postgres.py index 63815c7a29a..42642581a92 100644 --- a/apps/api/tests/integration/test_list_runs_handler_postgres.py +++ b/apps/api/tests/integration/test_list_runs_handler_postgres.py @@ -49,6 +49,8 @@ from cora.run.features.hold_run import bind as bind_hold from cora.run.features.list_runs import ListRuns from cora.run.features.list_runs import bind as bind_list +from cora.run.features.record_witnessed_run import RecordWitnessedRun +from cora.run.features.record_witnessed_run import bind as bind_record_witnessed_run from cora.run.features.resume_run import ResumeRun from cora.run.features.resume_run import bind as bind_resume from cora.run.features.start_run import StartRun @@ -57,6 +59,7 @@ from cora.run.features.stop_run import bind as bind_stop from cora.run.features.truncate_run import TruncateRun from cora.run.features.truncate_run import bind as bind_truncate +from cora.shared.identity import MonitorSourceId from tests._drain import drain_deadline_s from tests.integration._helpers import build_postgres_deps, seed_capability_postgres @@ -444,3 +447,49 @@ async def test_campaign_id_filter_narrows_results(db_pool: asyncpg.Pool) -> None assert page.items[0].run_id == run_member assert page.items[0].campaign_id == campaign_id assert page.next_cursor is None + + +@pytest.mark.integration +async def test_conduct_mode_filter_narrows_to_recorded_runs_only(db_pool: asyncpg.Pool) -> None: + """One Conducted Run (via start_run) and one Recorded Run (via + record_witnessed_run) against distinct Plans; `conduct_mode="Witnessed"` + returns only the witnessed one. Proves the query-side filter (this + commit) composes with the projection column already carrying + `conduct_mode` (added in slice 5).""" + # Conducted Run. + run_conducted = uuid4() + deps_conducted = _build_deps(db_pool, [*_chain_ids(), run_conducted, uuid4()]) + plan_conducted = await _seed_plan(deps_conducted, family_name="TomographyConducted") + await bind_start(deps_conducted)( + StartRun(name="conducted-run", plan_id=plan_conducted, subject_id=None), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + # Witnessed Run. + run_id_placeholder = uuid4() + deps_recorded = _build_deps(db_pool, [*_chain_ids(), run_id_placeholder, uuid4()]) + plan_recorded = await _seed_plan(deps_recorded, family_name="TomographyRecorded") + run_recorded = await bind_record_witnessed_run(deps_recorded)( + RecordWitnessedRun( + name="witnessed-run", + plan_id=plan_recorded, + capture_code="test-capture", + monitor_source_id=MonitorSourceId(uuid4()), + trigger="Monitor", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await _drain(db_pool) + + handler = bind_list(deps_conducted) + page = await handler( + ListRuns(conduct_mode="Witnessed", limit=10), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert len(page.items) == 1 + assert page.items[0].run_id == run_recorded + assert page.items[0].conduct_mode == "Witnessed" diff --git a/apps/api/tests/integration/test_pilot_seed_postgres.py b/apps/api/tests/integration/test_pilot_seed_postgres.py index fe615020baa..8ab2cb384d9 100644 --- a/apps/api/tests/integration/test_pilot_seed_postgres.py +++ b/apps/api/tests/integration/test_pilot_seed_postgres.py @@ -1,15 +1,20 @@ """The pilot seed ceremony, end to end against real Postgres. -Three claims, one flow: a fresh database seeds (exit 2), a re-run -changes nothing (exit 0), and `ingest_scan` then records a real file -through the REAL `PostgresAssetLookup` and `PostgresSupplyLookup`, -which retires the in-memory fake as the only Capturing-bearing asset -in the test suite. This is the proof the seeder design's gate review +Four claims, one flow: a fresh database seeds (exit 2), a re-run +changes nothing (exit 0), `ingest_scan` then records a real file +through the REAL `PostgresAssetLookup` and `PostgresSupplyLookup` +(which retires the in-memory fake as the only Capturing-bearing asset +in the test suite -- the proof the seeder design's gate review demanded: the seeded camera must surface the Capturing affordance -through the projection join, or the ceremony is decoration. +through the projection join, or the ceremony is decoration), and the +Recipe BC ladder (Capability -> Method -> Practice -> Plan) the +ceremony also registers actually resolves -- the family-superset and +affordance-cover cross-aggregate checks in `define_plan`'s decider are +exactly the ones a hand-rolled seed script gets wrong first, so this +is proof the ceremony's Plan is real, not just present. """ -# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnusedFunction=false from dataclasses import replace as dc_replace from datetime import UTC, datetime @@ -23,15 +28,20 @@ import pytest_asyncio from testcontainers.postgres import PostgresContainer -from cora.api.pilot_seed import asset_seed_id, seed_pilot_beamline +from cora.api.pilot_seed import asset_seed_id, recipe_seed_id, seed_pilot_beamline from cora.data.adapters.data_exchange_scan_reader import DataExchangeScanReader from cora.data.adapters.posix_checksum import PosixChecksumAdapter from cora.data.features import ingest_scan from cora.data.wire import ( _build_dataset_by_checksum_lookup, # pyright: ignore[reportPrivateUsage] ) +from cora.enclosure.adapters.postgres_enclosure_lookup import PostgresEnclosureLookup from cora.equipment.adapters.postgres_asset_lookup import PostgresAssetLookup from cora.infrastructure.postgres.pool import create_pool +from cora.recipe.aggregates.capability import load_capability +from cora.recipe.aggregates.method import load_method +from cora.recipe.aggregates.plan import load_plan +from cora.recipe.aggregates.practice import load_practice from cora.supply.adapters.postgres_supply_lookup import PostgresSupplyLookup from tests._postgres import normalize_async_url from tests.integration._helpers import build_postgres_deps @@ -44,6 +54,20 @@ _FACILITY = "cora" _BEAMLINE = "2-bm" _CAMERA = "Camera" +_SHUTTER = "StationShutter" +_ACQUISITION_CAMERA = "AcquisitionCamera" +_ROTARY_STAGE = "RotaryStage" + + +@pytest.fixture(autouse=True) +def _enclosure_permit_pvs(monkeypatch: pytest.MonkeyPatch) -> None: + """The ceremony resolves 2-BM-B via `seed_enclosures`, which only + seeds names present in `Settings.enclosure_permit_pvs` (empty by + default). The PV values themselves are never read by the ceremony + (it never subscribes); any placeholder string is fine.""" + monkeypatch.setenv( + "ENCLOSURE_PERMIT_PVS", '{"2-BM-A": "test:2bma:permit", "2-BM-B": "test:2bmb:permit"}' + ) @pytest_asyncio.fixture @@ -122,6 +146,27 @@ async def test_dry_run_writes_nothing_beyond_bootstrap(seed_database: SeedDataba ) assert camera_streams == 0, "dry run must not write the camera asset" + ladder_stream_ids = [ + asset_seed_id(_FACILITY, _BEAMLINE, _SHUTTER), + asset_seed_id(_FACILITY, _BEAMLINE, _ACQUISITION_CAMERA), + asset_seed_id(_FACILITY, _BEAMLINE, _ROTARY_STAGE), + recipe_seed_id(_FACILITY, _BEAMLINE, "capability", "acquisition"), + recipe_seed_id(_FACILITY, _BEAMLINE, "method", "dark_field"), + recipe_seed_id(_FACILITY, _BEAMLINE, "method", "flat_field"), + recipe_seed_id(_FACILITY, _BEAMLINE, "method", "fly_scan"), + recipe_seed_id(_FACILITY, _BEAMLINE, "practice", "2BM_dark_field_practice"), + recipe_seed_id(_FACILITY, _BEAMLINE, "practice", "2BM_flat_field_practice"), + recipe_seed_id(_FACILITY, _BEAMLINE, "practice", "2BM_fly_scan_practice"), + recipe_seed_id(_FACILITY, _BEAMLINE, "plan", "2BM_dark_field_plan"), + recipe_seed_id(_FACILITY, _BEAMLINE, "plan", "2BM_flat_field_plan"), + recipe_seed_id(_FACILITY, _BEAMLINE, "plan", "2BM_fly_scan_plan_v1"), + ] + ladder_events = await pool.fetchval( + "SELECT COUNT(*) FROM events WHERE stream_id = ANY($1::uuid[])", + ladder_stream_ids, + ) + assert ladder_events == 0, "dry run must not write the StationShutter/camera/ladder chain" + async def test_seeded_camera_carries_capturing_through_the_real_lookup( seed_database: SeedDatabase, @@ -134,6 +179,71 @@ async def test_seeded_camera_carries_capturing_through_the_real_lookup( assert "Capturing" in asset.family_affordances +async def test_seeded_ladder_resolves_for_all_acquisition_recipes( + seed_database: SeedDatabase, +) -> None: + """The Recipe BC ladder the ceremony registers is not just present, + it RESOLVES: `define_plan`'s cross-aggregate decider (family- + superset + affordance-cover checks) accepted every Plan without + raising. dark_field / flat_field each bind exactly the StationShutter + + the acquisition camera -- the two Assets `docs/deployments/2-bm/ + recipes.md`'s recipes actually target. fly_scan (the real TomoScan + workflow the RunWitness's promotion path watches) additionally binds + the Rotary stage: continuous sample rotation is the defining feature + of a real fly-scan, unlike the two static baseline captures.""" + pool, url = seed_database + assert await _run_ceremony(url) == 2 + + event_store = build_postgres_deps(pool, now=_NOW).event_store + + capability_id = recipe_seed_id(_FACILITY, _BEAMLINE, "capability", "acquisition") + shutter_id = asset_seed_id(_FACILITY, _BEAMLINE, f"{_SHUTTER}_v2") + acquisition_camera_id = asset_seed_id(_FACILITY, _BEAMLINE, f"{_ACQUISITION_CAMERA}_v2") + rotary_stage_id = asset_seed_id(_FACILITY, _BEAMLINE, _ROTARY_STAGE) + + enclosure_b = await PostgresEnclosureLookup(pool).lookup_by_name( + facility_code=_FACILITY, name="2-BM-B" + ) + assert enclosure_b is not None + for asset_id in (shutter_id, acquisition_camera_id, rotary_stage_id): + asset = await PostgresAssetLookup(pool).lookup(asset_id) + assert asset is not None + assert asset.located_in_enclosure_id == enclosure_b.enclosure_id + + baseline_asset_ids = frozenset({shutter_id, acquisition_camera_id}) + expected_asset_ids = { + "dark_field": baseline_asset_ids, + "flat_field": baseline_asset_ids, + "fly_scan": baseline_asset_ids | {rotary_stage_id}, + } + + for method_name, practice_name, plan_name in ( + ("dark_field", "2BM_dark_field_practice", "2BM_dark_field_plan_v2"), + ("flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan_v2"), + ("fly_scan", "2BM_fly_scan_practice", "2BM_fly_scan_plan_v1"), + ): + method_id = recipe_seed_id(_FACILITY, _BEAMLINE, "method", method_name) + practice_id = recipe_seed_id(_FACILITY, _BEAMLINE, "practice", practice_name) + plan_id = recipe_seed_id(_FACILITY, _BEAMLINE, "plan", plan_name) + + method = await load_method(event_store, method_id) + assert method is not None + assert method.capability_id == capability_id + + practice = await load_practice(event_store, practice_id) + assert practice is not None + assert practice.method_id == method_id + + plan = await load_plan(event_store, plan_id) + assert plan is not None + assert plan.practice_id == practice_id + assert plan.asset_ids == expected_asset_ids[method_name] + + capability = await load_capability(event_store, capability_id) + assert capability is not None + assert capability.code.value == "cora.capability.acquisition" + + async def test_ingest_against_the_seeded_beamline_records_the_dataset( seed_database: SeedDatabase, tmp_path: Path ) -> None: diff --git a/apps/api/tests/integration/test_record_witnessed_run_handler_postgres.py b/apps/api/tests/integration/test_record_witnessed_run_handler_postgres.py new file mode 100644 index 00000000000..11af705758e --- /dev/null +++ b/apps/api/tests/integration/test_record_witnessed_run_handler_postgres.py @@ -0,0 +1,184 @@ +"""Postgres integration test for the `record_witnessed_run` handler. + +Round-trips the witnessed genesis through a real event store: authorize, +load the Plan -> Practice -> Method -> Asset chain, decide, append, and +reload. Confirms the new nested `safety_envelope_verdict` VO survives the jsonb +round-trip and that `conduct_mode` lands as `Witnessed`. + +Reuses the shared 2-BM tomography fixture (`_tomography_fixture.py`) +already exercised by the RunInitiator tick scenario, minus the +beamtime / Subject setup this genesis does not need +(`subject_id=None`, the common case for a watched capture). +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.run.aggregates.run import ( + ConductMode, + RunMonitorTriggerNotPermittedError, + fold, + from_stored, +) +from cora.run.features.record_witnessed_run import RecordWitnessedRun, bind +from cora.shared.identity import MonitorSourceId +from tests.integration._helpers import build_postgres_deps, make_pg_profile_store +from tests.integration.scenarios._facility_fixture import operator_for +from tests.integration.scenarios._tomography_fixture import ( + RecipeSpec, + TomographyAssetIds, + define_recipe_ladder, + install_and_activate_tomography_assets, + recipe_ladder_id_prefix, + tomography_install_id_prefix, +) + +_NOW = datetime(2026, 8, 14, 3, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = operator_for(__file__) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000004c9601") + +# Scenario tag: 4c96 (record_witnessed_run handler round-trip). +_2BM_UNIT_ID = UUID("01900000-0000-7000-8000-00000004c9a1") + +_CAP_ROTARY_STAGE_ID = family_stream_id(FamilyName("RotaryStage")) +_CAP_LINEAR_STAGE_ID = family_stream_id(FamilyName("LinearStage")) +_CAP_CAMERA_ID = family_stream_id(FamilyName("Camera")) +_CAP_SCINTILLATOR_ID = family_stream_id(FamilyName("Scintillator")) + +_ASSET_ROTARY_ID = UUID("01900000-0000-7000-8000-00000004c9b1") +_ASSET_LINEAR_X_ID = UUID("01900000-0000-7000-8000-00000004c9b2") +_ASSET_CAMERA_ID = UUID("01900000-0000-7000-8000-00000004c9b3") +_ASSET_SCINTILLATOR_ID = UUID("01900000-0000-7000-8000-00000004c9b4") + +_METHOD_ID = UUID("01900000-0000-7000-8000-00000004c9c1") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-00000004c9c2") +_PRACTICE_ID = UUID("01900000-0000-7000-8000-00000004c9c3") +_PLAN_ID = UUID("01900000-0000-7000-8000-00000004c9c4") + +_TOMO_ASSETS = TomographyAssetIds( + unit_id=_2BM_UNIT_ID, + rotary_cap_id=_CAP_ROTARY_STAGE_ID, + linear_x_cap_id=_CAP_LINEAR_STAGE_ID, + camera_cap_id=_CAP_CAMERA_ID, + scintillator_cap_id=_CAP_SCINTILLATOR_ID, + rotary_id=_ASSET_ROTARY_ID, + linear_x_id=_ASSET_LINEAR_X_ID, + camera_id=_ASSET_CAMERA_ID, + scintillator_id=_ASSET_SCINTILLATOR_ID, +) + +_RECIPE = RecipeSpec( + capability_id=_CAPABILITY_ID, + capability_code="cora.capability.tomography", + capability_name="Tomography", + method_id=_METHOD_ID, + method_name="tomography", + needed_family_ids=frozenset( + {_CAP_ROTARY_STAGE_ID, _CAP_LINEAR_STAGE_ID, _CAP_CAMERA_ID, _CAP_SCINTILLATOR_ID} + ), + practice_id=_PRACTICE_ID, + practice_name="2BM_tomography_practice", + site_id=_2BM_UNIT_ID, + plan_id=_PLAN_ID, + plan_name="2BM_witnessed_tomography_plan", + plan_asset_ids=frozenset( + {_ASSET_ROTARY_ID, _ASSET_LINEAR_X_ID, _ASSET_CAMERA_ID, _ASSET_SCINTILLATOR_ID} + ), +) + + +def _id_queue() -> list[UUID]: + e = uuid4 + return [ + *tomography_install_id_prefix(asset_ids=_TOMO_ASSETS), + *recipe_ladder_id_prefix(spec=_RECIPE), + *[e() for _ in range(20)], # headroom: record_witnessed_run's own event ids + ] + + +@pytest.mark.integration +async def test_record_witnessed_run_persists_witnessed_run_with_safety_envelope_verdict( + db_pool: asyncpg.Pool, +) -> None: + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue()) + await install_and_activate_tomography_assets( + deps, + profile_store=make_pg_profile_store(db_pool), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + asset_ids=_TOMO_ASSETS, + ) + await define_recipe_ladder( + deps, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + spec=_RECIPE, + ) + + handler = bind(deps) + run_id = await handler( + RecordWitnessedRun( + name="2BM witnessed capture", + plan_id=_PLAN_ID, + capture_code="2bmb-tomoscan", + monitor_source_id=MonitorSourceId(UUID("01900000-0000-7000-8000-000063617001")), + trigger="Monitor", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, stream_version = await deps.event_store.load("Run", run_id) + assert stream_version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "RunStarted" + assert stored.payload["conduct_mode"] == "Witnessed" + assert stored.payload["subject_id"] is None + assert stored.payload["safety_envelope_verdict"] == { + "enclosure_permitted": True, + "beam_available": True, + } + assert stored.payload["external_refs"] == [{"scheme": "capture-code", "value": "2bmb-tomoscan"}] + + # Reload through from_stored/fold to confirm the nested VO + # reconstructs correctly, not just that raw jsonb round-trips. + state = fold([from_stored(e) for e in events]) + assert state is not None + assert state.conduct_mode is ConductMode.WITNESSED + + +@pytest.mark.integration +async def test_record_witnessed_run_rejects_non_monitor_trigger(db_pool: asyncpg.Pool) -> None: + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue()) + await install_and_activate_tomography_assets( + deps, + profile_store=make_pg_profile_store(db_pool), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + asset_ids=_TOMO_ASSETS, + ) + await define_recipe_ladder( + deps, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + spec=_RECIPE, + ) + + handler = bind(deps) + with pytest.raises(RunMonitorTriggerNotPermittedError): + await handler( + RecordWitnessedRun( + name="2BM witnessed capture", + plan_id=_PLAN_ID, + capture_code="2bmb-tomoscan", + monitor_source_id=MonitorSourceId(UUID("01900000-0000-7000-8000-000063617001")), + trigger="Operator", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/apps/api/tests/integration/test_run_witness_rebuild_postgres.py b/apps/api/tests/integration/test_run_witness_rebuild_postgres.py new file mode 100644 index 00000000000..068a860ca71 --- /dev/null +++ b/apps/api/tests/integration/test_run_witness_rebuild_postgres.py @@ -0,0 +1,147 @@ +"""Postgres integration test for `rebuild_open_captures` +(cora.api._run_witness). + +Proves the restart-rebuild query composes correctly end-to-end against +real Postgres: the `conduct_mode` list_runs filter, the +`proj_run_summary` projection, `load_run`'s stream read, and the +`Identifier(scheme="capture-code", ...)` extraction. No unit test (all +fake handlers) can prove this chain. + +Reuses the shared 2-BM tomography fixture, the same one +`test_record_witnessed_run_handler_postgres.py` exercises. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.api._run_witness import rebuild_open_captures +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.family import FamilyName, family_stream_id +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from cora.recipe._projections import register_recipe_projections +from cora.run._projections import register_run_projections +from cora.run.features.list_runs import bind as bind_list_runs +from cora.run.features.record_witnessed_run import RecordWitnessedRun, bind +from cora.shared.identity import MonitorSourceId +from tests._drain import drain_deadline_s +from tests.integration._helpers import build_postgres_deps, make_pg_profile_store +from tests.integration.scenarios._facility_fixture import operator_for +from tests.integration.scenarios._tomography_fixture import ( + RecipeSpec, + TomographyAssetIds, + define_recipe_ladder, + install_and_activate_tomography_assets, + recipe_ladder_id_prefix, + tomography_install_id_prefix, +) + +_NOW = datetime(2026, 8, 14, 4, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = operator_for(__file__) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000004caa01") + +# Scenario tag: 4caa (RunWitness restart-rebuild round-trip). +_2BM_UNIT_ID = UUID("01900000-0000-7000-8000-00000004caa2") + +_CAP_ROTARY_STAGE_ID = family_stream_id(FamilyName("RotaryStage")) +_CAP_LINEAR_STAGE_ID = family_stream_id(FamilyName("LinearStage")) +_CAP_CAMERA_ID = family_stream_id(FamilyName("Camera")) +_CAP_SCINTILLATOR_ID = family_stream_id(FamilyName("Scintillator")) + +_ASSET_ROTARY_ID = UUID("01900000-0000-7000-8000-00000004cab1") +_ASSET_LINEAR_X_ID = UUID("01900000-0000-7000-8000-00000004cab2") +_ASSET_CAMERA_ID = UUID("01900000-0000-7000-8000-00000004cab3") +_ASSET_SCINTILLATOR_ID = UUID("01900000-0000-7000-8000-00000004cab4") + +_METHOD_ID = UUID("01900000-0000-7000-8000-00000004cac1") +_CAPABILITY_ID = UUID("01900000-0000-7000-8000-00000004cac2") +_PRACTICE_ID = UUID("01900000-0000-7000-8000-00000004cac3") +_PLAN_ID = UUID("01900000-0000-7000-8000-00000004cac4") + +_TOMO_ASSETS = TomographyAssetIds( + unit_id=_2BM_UNIT_ID, + rotary_cap_id=_CAP_ROTARY_STAGE_ID, + linear_x_cap_id=_CAP_LINEAR_STAGE_ID, + camera_cap_id=_CAP_CAMERA_ID, + scintillator_cap_id=_CAP_SCINTILLATOR_ID, + rotary_id=_ASSET_ROTARY_ID, + linear_x_id=_ASSET_LINEAR_X_ID, + camera_id=_ASSET_CAMERA_ID, + scintillator_id=_ASSET_SCINTILLATOR_ID, +) + +_RECIPE = RecipeSpec( + capability_id=_CAPABILITY_ID, + capability_code="cora.capability.tomography", + capability_name="Tomography", + method_id=_METHOD_ID, + method_name="tomography", + needed_family_ids=frozenset( + {_CAP_ROTARY_STAGE_ID, _CAP_LINEAR_STAGE_ID, _CAP_CAMERA_ID, _CAP_SCINTILLATOR_ID} + ), + practice_id=_PRACTICE_ID, + practice_name="2BM_tomography_practice_witness_rebuild", + site_id=_2BM_UNIT_ID, + plan_id=_PLAN_ID, + plan_name="2BM_witnessed_tomography_plan_witness_rebuild", + plan_asset_ids=frozenset( + {_ASSET_ROTARY_ID, _ASSET_LINEAR_X_ID, _ASSET_CAMERA_ID, _ASSET_SCINTILLATOR_ID} + ), +) + + +def _id_queue() -> list[UUID]: + e = uuid4 + return [ + *tomography_install_id_prefix(asset_ids=_TOMO_ASSETS), + *recipe_ladder_id_prefix(spec=_RECIPE), + *[e() for _ in range(20)], + ] + + +async def _drain(db_pool: asyncpg.Pool) -> None: + registry = ProjectionRegistry() + register_equipment_projections(registry) + register_recipe_projections(registry) + register_run_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=drain_deadline_s()) + + +@pytest.mark.integration +async def test_rebuild_open_captures_seeds_dedup_map_from_a_real_open_witnessed_run( + db_pool: asyncpg.Pool, +) -> None: + deps = build_postgres_deps(db_pool, now=_NOW, ids=_id_queue()) + await install_and_activate_tomography_assets( + deps, + profile_store=make_pg_profile_store(db_pool), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + asset_ids=_TOMO_ASSETS, + ) + await define_recipe_ladder( + deps, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + spec=_RECIPE, + ) + + handler = bind(deps) + run_id = await handler( + RecordWitnessedRun( + name="2BM witnessed capture", + plan_id=_PLAN_ID, + capture_code="2bmb-tomoscan", + monitor_source_id=MonitorSourceId(UUID("01900000-0000-7000-8000-000063617001")), + trigger="Monitor", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain(db_pool) + + result = await rebuild_open_captures(deps, list_runs=bind_list_runs(deps)) + + assert result == {"2bmb-tomoscan": run_id} diff --git a/apps/api/tests/integration/test_start_run_handler_postgres.py b/apps/api/tests/integration/test_start_run_handler_postgres.py index 8f26c9940f4..6dfa6ef645f 100644 --- a/apps/api/tests/integration/test_start_run_handler_postgres.py +++ b/apps/api/tests/integration/test_start_run_handler_postgres.py @@ -177,6 +177,14 @@ async def test_start_run_persists_event_with_full_upstream_chain_against_postgre "plan_id": str(plan_id), "subject_id": str(subject_id), "raid": None, + # who drove this act: every driven start_run is Conducted, hardcoded + # by the decider (not caller-settable). Forward-compat via + # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)`. + "conduct_mode": "Conducted", + # Additive payload field: the witnessed-genesis envelope reading. + # Always None on a driven Run; forward-compat via + # `payload.get("safety_envelope_verdict")`. + "safety_envelope_verdict": None, # 6g-c additive payload fields default to {} / None when no # overrides / no Plan defaults / no trigger_source are supplied. "override_parameters": {}, diff --git a/apps/api/tests/unit/agent/test_run_witness_seed.py b/apps/api/tests/unit/agent/test_run_witness_seed.py new file mode 100644 index 00000000000..1d83c3df5d9 --- /dev/null +++ b/apps/api/tests/unit/agent/test_run_witness_seed.py @@ -0,0 +1,93 @@ +"""Unit tests for the RunWitness Agent bootstrap seed. + +RunWitness is another DETERMINISTIC agent: no prompt template and a +sentinel ModelRef (it is rule-based, never builds an LLM). These tests +pin that shape alongside the shared seed scaffolding, mirroring +`test_run_supervisor_seed.py`. +""" + +from datetime import UTC, datetime + +import pytest + +from cora.agent.aggregates.agent import load_agent +from cora.agent.seed_run_witness import ( + RUN_WITNESS_AGENT_ID, + RUN_WITNESS_AGENT_KIND, + RUN_WITNESS_AGENT_NAME, + RUN_WITNESS_AGENT_VERSION, + seed_run_witness_agent, +) +from cora.infrastructure.config import Settings +from cora.infrastructure.deps import make_inmemory_kernel +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports import AllowAllAuthorize, FakeClock, FixedIdGenerator + + +def _kernel() -> Kernel: + settings = Settings() # type: ignore[call-arg] + return make_inmemory_kernel( + settings=settings, + clock=FakeClock(datetime(2026, 5, 17, 14, 0, 0, tzinfo=UTC)), + id_generator=FixedIdGenerator([]), + authz=AllowAllAuthorize(), + ) + + +@pytest.mark.unit +async def test_seed_creates_run_witness_at_pinned_id() -> None: + kernel = _kernel() + await seed_run_witness_agent(kernel) + + agent = await load_agent(kernel.event_store, RUN_WITNESS_AGENT_ID) + assert agent is not None + assert agent.id == RUN_WITNESS_AGENT_ID + assert agent.name.value == RUN_WITNESS_AGENT_NAME + assert agent.kind.value == RUN_WITNESS_AGENT_KIND + assert agent.version.value == RUN_WITNESS_AGENT_VERSION + + +@pytest.mark.unit +async def test_seed_is_deterministic_no_prompt_sentinel_model() -> None: + """Deterministic agent: no prompt template, sentinel (non-LLM) model_ref.""" + kernel = _kernel() + await seed_run_witness_agent(kernel) + + agent = await load_agent(kernel.event_store, RUN_WITNESS_AGENT_ID) + assert agent is not None + assert agent.prompt_template_id is None + assert agent.model_ref.provider == "deterministic" + assert agent.model_ref.model == "agent:RunWitness:v1" + + +@pytest.mark.unit +async def test_seed_creates_co_registered_actor() -> None: + """The cross-BC genesis: Actor (kind=agent) at the pinned id.""" + from cora.access.aggregates.actor import load_actor + + kernel = _kernel() + await seed_run_witness_agent(kernel) + + actor = await load_actor(kernel.event_store, RUN_WITNESS_AGENT_ID) + assert actor is not None + assert actor.id == RUN_WITNESS_AGENT_ID + assert actor.kind.value == "agent" + + +@pytest.mark.unit +async def test_seed_is_idempotent() -> None: + """Re-running the seed is a no-op (ConcurrencyError-as-success pattern).""" + kernel = _kernel() + await seed_run_witness_agent(kernel) + await seed_run_witness_agent(kernel) + + +@pytest.mark.unit +async def test_run_witness_id_distinct_from_other_agents() -> None: + """RunWitness shares the UUID-range scheme with its sibling runtimes + but must NOT collide with either.""" + from cora.agent.seed_run_initiator import RUN_INITIATOR_AGENT_ID + from cora.agent.seed_run_supervisor import RUN_SUPERVISOR_AGENT_ID + + assert RUN_WITNESS_AGENT_ID != RUN_SUPERVISOR_AGENT_ID + assert RUN_WITNESS_AGENT_ID != RUN_INITIATOR_AGENT_ID diff --git a/apps/api/tests/unit/api/test_capture_observer.py b/apps/api/tests/unit/api/test_capture_observer.py new file mode 100644 index 00000000000..ea48cef7c1a --- /dev/null +++ b/apps/api/tests/unit/api/test_capture_observer.py @@ -0,0 +1,353 @@ +"""Unit tests for the composition-root capture observer bridge. + +Mirrors `test_enclosure_permit_observer.py`'s two-layer shape: the pure +`classify_capture_status` mapping, and the async multi-PV merge / +clean-stream-end / disconnect behaviour of `ControlPortCaptureObserver` +driven against a scripted fake `ControlPort`. The one behavioral +difference pinned throughout: where the Enclosure bridge synthesizes an +`Unknown` status on disconnect (to fail a real gate closed), +`ControlPortCaptureObserver` synthesizes NO status claim at all, because +there is no gate here and a synthesized phase would fabricate a +terminal. +""" + +import asyncio +from collections.abc import AsyncGenerator, AsyncIterator, Callable +from datetime import UTC, datetime + +import pytest + +from cora.api._capture_observer import ControlPortCaptureObserver, classify_capture_status +from cora.operation.ports.control_port import ControlNotConnectedError, Measurement +from cora.run.ports.capture_observer import CaptureObservation, CaptureObserverScope, CapturePhase +from cora.shared.reach import ReachTier + +_T = datetime(2026, 8, 13, 12, 0, 0, tzinfo=UTC) + +_PHASES = { + "Beginning scan": "Begun", + "Collecting projections": "Progressing", + "Scan complete": "Ended", + "Scan aborted": "Aborted", +} + + +def _reading(value: object, produced_at: datetime | None = _T) -> Measurement: + return Measurement(value=value, kind="Categorical", quality="Good", produced_at=produced_at) # type: ignore[arg-type] + + +@pytest.mark.unit +def test_classify_maps_a_declared_literal_to_its_phase() -> None: + assert classify_capture_status("Beginning scan", _PHASES) is CapturePhase.BEGUN + assert classify_capture_status("Scan complete", _PHASES) is CapturePhase.ENDED + assert classify_capture_status("Scan aborted", _PHASES) is CapturePhase.ABORTED + + +@pytest.mark.unit +def test_classify_an_undeclared_literal_is_unrecognized() -> None: + """A vocabulary drift (a tool upgrade renaming a status) must be + visible, never silently dropped or coerced into a nearby phase.""" + assert classify_capture_status("Some new status", _PHASES) is CapturePhase.UNRECOGNIZED + + +@pytest.mark.unit +def test_classify_against_an_empty_table_is_always_unrecognized() -> None: + assert classify_capture_status("Scan complete", {}) is CapturePhase.UNRECOGNIZED + + +class _ScriptedControlPort: + """Fake `ControlPort`: replays a per-address reading script. + + Same shape as `test_enclosure_permit_observer.py`'s + `_ScriptedControlPort`: each address yields its scripted readings in + order, then ends cleanly, hangs (models a live subscription with no + more traffic), or disconnects. `read_results` scripts the poll path. + """ + + def __init__( + self, + *, + readings: dict[str, list[Measurement]], + disconnect: frozenset[str] = frozenset(), + hang: frozenset[str] = frozenset(), + read_results: dict[str, list[Measurement | Exception]] | None = None, + ) -> None: + self._readings = readings + self._disconnect = disconnect + self._hang = hang + self._read_results = {k: list(v) for k, v in (read_results or {}).items()} + + def subscribe(self, address: str) -> AsyncIterator[Measurement]: + return self._stream(address) + + async def _stream(self, address: str) -> AsyncGenerator[Measurement]: + for reading in self._readings.get(address, []): + yield reading + if address in self._hang: + await asyncio.Event().wait() # never released; models a live subscription + return # pragma: no cover - unreachable + if address in self._disconnect: + raise ControlNotConnectedError(address) + + async def read(self, address: str) -> Measurement: + results = self._read_results.get(address) + if not results: + raise ControlNotConnectedError(address) + result = results.pop(0) + if isinstance(result, Exception): + raise result + return result + + +def _observer( + port: _ScriptedControlPort, + capture_pvs: dict[str, dict[str, str]], + *, + status_phases: dict[str, str] | None = None, + tick_seconds: float | None = None, +) -> ControlPortCaptureObserver: + return ControlPortCaptureObserver( + control_port=port, # type: ignore[arg-type] + capture_pvs=capture_pvs, + status_phases=status_phases if status_phases is not None else _PHASES, + tick_seconds=tick_seconds, + ) + + +async def _collect( + observer: ControlPortCaptureObserver, codes: set[str] +) -> list[CaptureObservation]: + scope = CaptureObserverScope(capture_codes=frozenset(codes)) + return [observation async for observation in observer.observe(scope)] + + +@pytest.mark.unit +async def test_observe_empty_scope_yields_nothing() -> None: + observer = _observer(_ScriptedControlPort(readings={}), {"tomoscan": {"status": "pvA"}}) + assert await _collect(observer, set()) == [] + + +@pytest.mark.unit +async def test_observe_unconfigured_code_yields_nothing() -> None: + observer = _observer(_ScriptedControlPort(readings={}), {"tomoscan": {"status": "pvA"}}) + assert await _collect(observer, {"other-tomoscan"}) == [] + + +@pytest.mark.unit +async def test_observe_a_code_with_no_status_role_is_excluded_from_scope() -> None: + """A configured code missing the `status` role cannot be watched: it + is silently excluded rather than raising, mirroring the Enclosure + adapter's unconfigured-code behaviour.""" + observer = _observer(_ScriptedControlPort(readings={}), {"tomoscan": {"server_running": "pvA"}}) + assert await _collect(observer, {"tomoscan"}) == [] + + +@pytest.mark.unit +async def test_observe_maps_readings_then_no_status_claim_on_clean_end() -> None: + port = _ScriptedControlPort( + readings={"pvA": [_reading("Beginning scan"), _reading("Scan complete")]} + ) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}) + + observations = await _collect(observer, {"tomoscan"}) + + assert [(o.capture_code, o.reported_status, o.phase) for o in observations] == [ + ("tomoscan", "Beginning scan", CapturePhase.BEGUN), + ("tomoscan", "Scan complete", CapturePhase.ENDED), + ("tomoscan", None, None), + ] + assert observations[0].observed_at == _T + assert observations[0].source_kind == "EpicsPv" + assert observations[0].source_id == "pvA" + # The clean-stream-end observation has NO substrate time, matching + # the disconnect case: nothing was reported, so nothing is stamped. + assert observations[-1].observed_at is None + assert observations[-1].reach_tier is ReachTier.UNREACHED + + +@pytest.mark.unit +async def test_observe_disconnect_yields_a_single_no_status_observation() -> None: + """The deliberate inversion from Enclosure: a disconnect must NOT + carry `phase=CapturePhase.ENDED` or any other phase. Reading a + disconnect as a real terminal would fabricate one the substrate + never reported.""" + port = _ScriptedControlPort(readings={"pvA": []}, disconnect=frozenset({"pvA"})) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}) + + observations = await _collect(observer, {"tomoscan"}) + + assert len(observations) == 1 + assert observations[0].reported_status is None + assert observations[0].phase is None + assert observations[0].reach_tier is ReachTier.UNREACHED + assert observations[0].observed_at is None + + +@pytest.mark.unit +async def test_observe_an_undeclared_literal_classifies_unrecognized_not_dropped() -> None: + port = _ScriptedControlPort(readings={"pvA": [_reading("Some future firmware status")]}) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}) + + observations = await _collect(observer, {"tomoscan"}) + + assert observations[0].reported_status == "Some future firmware status" + assert observations[0].phase is CapturePhase.UNRECOGNIZED + # A reading was still delivered: RELAYED, not UNREACHED. Classification + # failure is not the same fact as a communication failure. + assert observations[0].reach_tier is ReachTier.RELAYED + + +@pytest.mark.unit +async def test_observe_passes_through_an_absent_substrate_time() -> None: + port = _ScriptedControlPort(readings={"pvA": [_reading("Scan complete", produced_at=None)]}) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}) + + observations = await _collect(observer, {"tomoscan"}) + + assert observations[0].phase is CapturePhase.ENDED + assert observations[0].observed_at is None + + +@pytest.mark.unit +async def test_observe_preserves_a_present_substrate_time() -> None: + port = _ScriptedControlPort(readings={"pvA": [_reading("Scan complete", produced_at=_T)]}) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}) + + observations = await _collect(observer, {"tomoscan"}) + + assert observations[0].observed_at == _T + + +@pytest.mark.unit +async def test_observe_merges_multiple_codes() -> None: + port = _ScriptedControlPort( + readings={"pvA": [_reading("Beginning scan")], "pvB": [_reading("Scan complete")]} + ) + observer = _observer(port, {"tomoscan-a": {"status": "pvA"}, "tomoscan-b": {"status": "pvB"}}) + + observations = await _collect(observer, {"tomoscan-a", "tomoscan-b"}) + + emitted = {(o.capture_code, o.phase) for o in observations if o.phase is not None} + assert emitted == { + ("tomoscan-a", CapturePhase.BEGUN), + ("tomoscan-b", CapturePhase.ENDED), + } + + +async def _collect_until( + gen: AsyncGenerator[CaptureObservation], + predicate: Callable[[list[CaptureObservation]], bool], + *, + timeout_seconds: float = 2.0, +) -> list[CaptureObservation]: + collected: list[CaptureObservation] = [] + + async def _drain() -> None: + async for observation in gen: + collected.append(observation) + if predicate(collected): + break + + try: + await asyncio.wait_for(_drain(), timeout=timeout_seconds) + finally: + await gen.aclose() + return collected + + +@pytest.mark.unit +async def test_poll_disabled_by_default_emits_nothing_extra() -> None: + port = _ScriptedControlPort(readings={"pvA": []}, hang=frozenset({"pvA"})) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}) + gen = observer.observe(CaptureObserverScope(capture_codes=frozenset({"tomoscan"}))) + try: + with pytest.raises(TimeoutError): + await asyncio.wait_for(anext(gen), timeout=0.05) + finally: + await gen.aclose() + + +@pytest.mark.unit +async def test_poll_emits_relayed_probe_on_successful_read() -> None: + port = _ScriptedControlPort( + readings={"pvA": []}, + hang=frozenset({"pvA"}), + read_results={"pvA": [_reading("Scan complete")]}, + ) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}, tick_seconds=0.01) + gen = observer.observe(CaptureObserverScope(capture_codes=frozenset({"tomoscan"}))) + + collected = await _collect_until(gen, lambda obs: len(obs) >= 1) + + assert len(collected) == 1 + probe = collected[0] + assert probe.capture_code == "tomoscan" + assert probe.reported_status is None # probe-only: makes no status claim + assert probe.phase is None + assert probe.reach_tier is ReachTier.RELAYED + assert probe.source_kind == "EpicsPv" + assert probe.source_id == "pvA" + + +@pytest.mark.unit +async def test_poll_emits_unreached_probe_on_failed_read() -> None: + port = _ScriptedControlPort( + readings={"pvA": []}, + hang=frozenset({"pvA"}), + read_results={"pvA": [ControlNotConnectedError("pvA")]}, + ) + observer = _observer(port, {"tomoscan": {"status": "pvA"}}, tick_seconds=0.01) + gen = observer.observe(CaptureObserverScope(capture_codes=frozenset({"tomoscan"}))) + + collected = await _collect_until(gen, lambda obs: len(obs) >= 1) + + assert len(collected) == 1 + assert collected[0].reported_status is None + assert collected[0].reach_tier is ReachTier.UNREACHED + + +@pytest.mark.unit +async def test_poll_survives_a_disconnected_sibling_pump() -> None: + """A poller keeps ticking for its own PV even after ANOTHER PV's pump + disconnects, matching the Enclosure adapter's sibling-poller + guarantee exactly. See that test for the full reasoning.""" + port = _ScriptedControlPort( + readings={"pvB": []}, + hang=frozenset({"pvB"}), + disconnect=frozenset({"pvA"}), + read_results={"pvA": [_reading("Scan complete"), _reading("Scan complete")]}, + ) + observer = _observer( + port, {"tomoscan-a": {"status": "pvA"}, "tomoscan-b": {"status": "pvB"}}, tick_seconds=0.01 + ) + gen = observer.observe( + CaptureObserverScope(capture_codes=frozenset({"tomoscan-a", "tomoscan-b"})) + ) + + def _seen_disconnect_and_a_probe(obs: list[CaptureObservation]) -> bool: + disconnected = any( + o.capture_code == "tomoscan-a" + and o.reach_tier is ReachTier.UNREACHED + and o.reported_status is None + for o in obs + ) + probed = any( + o.capture_code == "tomoscan-a" + and o.reach_tier is ReachTier.RELAYED + and o.reported_status is None + for o in obs + ) + return disconnected and probed + + collected = await _collect_until(gen, _seen_disconnect_and_a_probe, timeout_seconds=2.0) + + disconnect_obs = [ + o + for o in collected + if o.capture_code == "tomoscan-a" and o.reach_tier is ReachTier.UNREACHED + ] + probe_obs = [ + o for o in collected if o.capture_code == "tomoscan-a" and o.reach_tier is ReachTier.RELAYED + ] + assert disconnect_obs, "pump A's disconnect must still be observed" + assert probe_obs, "the poller for A must keep ticking after A's pump has died" diff --git a/apps/api/tests/unit/api/test_pilot_seed.py b/apps/api/tests/unit/api/test_pilot_seed.py index ecaecfb1be3..5c5592cb04c 100644 --- a/apps/api/tests/unit/api/test_pilot_seed.py +++ b/apps/api/tests/unit/api/test_pilot_seed.py @@ -13,9 +13,11 @@ from cora.api.pilot_seed import ( ASSET_SEED_NAMESPACE, + RECIPE_SEED_NAMESPACE, _Report, # pyright: ignore[reportPrivateUsage] asset_seed_id, build_parser, + recipe_seed_id, ) pytestmark = pytest.mark.unit @@ -88,10 +90,64 @@ def test_parser_accepts_overrides() -> None: assert args.dry_run is True +def test_parser_shutter_and_acquisition_camera_defaults_do_not_collide_with_camera_name() -> None: + """--camera-name and --acquisition-camera-name must never share a + default: asset_seed_id hashes only on name, so identical defaults + would derive the SAME id for what are meant to be two distinct + Device Assets.""" + args = build_parser().parse_args([]) + assert args.shutter_name == "StationShutter" + assert args.acquisition_camera_name == "AcquisitionCamera" + assert args.acquisition_camera_name != args.camera_name + + +def test_parser_accepts_shutter_and_acquisition_camera_overrides() -> None: + args = build_parser().parse_args( + ["--shutter-name", "Shutter1", "--acquisition-camera-name", "Camera"] + ) + assert args.shutter_name == "Shutter1" + assert args.acquisition_camera_name == "Camera" + + def test_asset_seed_namespace_is_the_locked_constant() -> None: assert UUID("6c1f4a52-8f2e-4bb0-9d59-1a4c9be1a23d") == ASSET_SEED_NAMESPACE +def test_recipe_seed_id_repeated_calls_return_the_same_id() -> None: + first = recipe_seed_id("aps", "2-bm", "method", "dark_field") + second = recipe_seed_id("aps", "2-bm", "method", "dark_field") + assert first == second + + +def test_recipe_seed_id_pins_the_key_format() -> None: + """The exact uuid5 over "facility:beamline:kind:name". Changing the + namespace or the format orphans every previously seeded ladder + instance, so this pin must only ever move with a migration story.""" + from uuid import uuid5 + + assert recipe_seed_id("aps", "2-bm", "method", "dark_field") == uuid5( + RECIPE_SEED_NAMESPACE, "aps:2-bm:method:dark_field" + ) + + +def test_recipe_seed_id_distinguishes_kind_and_name() -> None: + ids = { + recipe_seed_id("aps", "2-bm", "method", "dark_field"), + recipe_seed_id("aps", "2-bm", "method", "flat_field"), + recipe_seed_id("aps", "2-bm", "practice", "dark_field"), + recipe_seed_id("maxiv", "2-bm", "method", "dark_field"), + } + assert len(ids) == 4 + + +def test_recipe_seed_namespace_is_distinct_from_asset_seed_namespace() -> None: + assert RECIPE_SEED_NAMESPACE != ASSET_SEED_NAMESPACE + + +def test_recipe_seed_namespace_is_the_locked_constant() -> None: + assert UUID("48eb0d48-8fc2-482c-9e9e-d3547b1ff37b") == RECIPE_SEED_NAMESPACE + + def test_main_parses_argv_and_returns_the_ceremony_exit_code( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -110,3 +166,22 @@ async def fake_ceremony(**kwargs: object) -> int: assert exit_code == 2 assert received["camera_name"] == "Oryx" assert received["dry_run"] is True + + +def test_main_forwards_shutter_and_acquisition_camera_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from cora.api import pilot_seed + + received: dict[str, object] = {} + + async def fake_ceremony(**kwargs: object) -> int: + received.update(kwargs) + return 0 + + monkeypatch.setattr(pilot_seed, "seed_pilot_beamline", fake_ceremony) + + pilot_seed.main(["--shutter-name", "Shutter1", "--acquisition-camera-name", "Camera"]) + + assert received["shutter_name"] == "Shutter1" + assert received["acquisition_camera_name"] == "Camera" diff --git a/apps/api/tests/unit/api/test_run_supervisor.py b/apps/api/tests/unit/api/test_run_supervisor.py index 06e35153b06..4a1be9c0758 100644 --- a/apps/api/tests/unit/api/test_run_supervisor.py +++ b/apps/api/tests/unit/api/test_run_supervisor.py @@ -250,6 +250,7 @@ def _running_item( running_since: datetime | None = _NOW, snr_limit: float | None = None, expected_observation_interval_seconds: float | None = None, + conduct_mode: str = "Conducted", ) -> RunSummaryItem: return RunSummaryItem( run_id=run_id, @@ -264,6 +265,7 @@ def _running_item( campaign_id=None, snr_limit=snr_limit, expected_observation_interval_seconds=expected_observation_interval_seconds, + conduct_mode=conduct_mode, ) @@ -481,6 +483,31 @@ async def test_tick_holds_running_run_when_beam_down_and_records_decision() -> N assert decision.decided_by == ActorId(RUN_SUPERVISOR_AGENT_ID) +@pytest.mark.unit +async def test_tick_never_holds_a_witnessed_run_when_beam_down() -> None: + """A Witnessed Run is driven by an external tool CORA only + witnessed at genesis; the hold FSM cannot actually hold anything on that + Run's behalf. Filtered out before the hold pass ever sees it, regardless + of the beam reading.""" + kernel = _kernel() + await seed_run_supervisor_agent(kernel) + run_id = uuid4() + list_runs = _make_list_runs([_running_item(run_id, conduct_mode="Witnessed")]) + hold_run, hold_calls = _make_recording_hold() + memory: dict[UUID, str] = {} + + await _tick( + kernel, + list_runs=list_runs, + hold_run=hold_run, + beam_lookup=_BeamDown(), + memory=memory, + ) + + assert hold_calls == [] + assert run_id not in memory + + @pytest.mark.unit async def test_tick_is_noop_when_supervisor_actor_absent() -> None: """Revocation gate: with no seeded (active) supervisor Actor, do nothing.""" @@ -812,6 +839,7 @@ def _held_item(run_id: UUID) -> RunSummaryItem: campaign_id=None, snr_limit=None, expected_observation_interval_seconds=None, + conduct_mode="Conducted", ) @@ -1163,6 +1191,37 @@ async def test_shadow_liveness_flags_stale_run_observe_only() -> None: assert resume_calls == [] +@pytest.mark.unit +async def test_shadow_liveness_never_flags_a_witnessed_run() -> None: + """A Witnessed Run's duration is set by the external tool driving it, not + by anything CORA can truncate; the liveness/truncate population excludes + it before the ceiling check ever runs, however long it has been open.""" + kernel = _kernel() + await seed_run_supervisor_agent(kernel) + run_id = uuid4() + list_runs = _make_list_runs( + [_running_item(run_id, running_since=_NOW - timedelta(hours=2), conduct_mode="Witnessed")] + ) + hold_run, hold_calls = _make_recording_hold() + resume_run, resume_calls = _make_recording_resume() + liveness: set[UUID] = set() + + await _tick( + kernel, + list_runs=list_runs, + hold_run=hold_run, + resume_run=resume_run, + beam_lookup=_BeamOpen(), + memory={}, + liveness=liveness, + liveness_ceiling_seconds=3600.0, + ) + + assert liveness == set() + assert hold_calls == [] + assert resume_calls == [] + + @pytest.mark.unit async def test_shadow_liveness_does_not_flag_when_ceiling_none() -> None: """No ceiling set (default): even a multi-day-running Run is never flagged.""" diff --git a/apps/api/tests/unit/api/test_run_witness.py b/apps/api/tests/unit/api/test_run_witness.py new file mode 100644 index 00000000000..2d1fbac112d --- /dev/null +++ b/apps/api/tests/unit/api/test_run_witness.py @@ -0,0 +1,591 @@ +"""Tests for the RunWitness shadow runtime (cora.api._run_witness). + +Covers the no-op-when-unconfigured lifespan shape, that every observed +phase (and the two no-phase cases, unreached and probe-only) logs the +right event with the right fields, that a bad observation is logged +and skipped rather than killing the loop, and that a stream ending +triggers reconnect rather than the loop exiting silently. + +Every assertion is against `structlog.testing.capture_logs()`. There is +nothing else to assert: shadow mode has no event store, no entries +table, and no Run command, so "it wrote nothing" is a structural fact +about the module's imports, not a per-test behavior to pin. +""" + +# white-box test of the runtime internals (private constants) +# pyright: reportPrivateUsage=false + +import asyncio +import contextlib +from collections.abc import AsyncGenerator +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest +import structlog.testing + +from cora.api._run_witness import ( + RUN_WITNESS_MONITOR_SOURCE_ID, + RunWitnessRecorder, + observe_capture, + rebuild_open_captures, + run_witness_lifespan, + run_witness_loop, +) +from cora.infrastructure.config import Settings +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.run.aggregates.run import ConductMode, RunStarted, event_type_name, to_payload +from cora.run.errors import UnauthorizedError +from cora.run.features.list_runs import RunListPage, RunSummaryItem +from cora.run.features.record_witnessed_run.command import RecordWitnessedRun +from cora.run.ports.capture_observer import CaptureObservation, CaptureObserverScope, CapturePhase +from cora.shared.reach import ReachTier +from tests.unit._helpers import build_deps + +_CODE = "2bmb-tomoscan" +_NOW = datetime(2026, 8, 13, 12, 0, 0, tzinfo=UTC) +_PLAN_ID = UUID("01900000-0000-7000-8000-000000007107") + + +def _obs( + *, + reported_status: str | None, + phase: CapturePhase | None, + reach_tier: ReachTier = ReachTier.RELAYED, + observed_at: datetime | None = _NOW, + capture_code: str = _CODE, +) -> CaptureObservation: + return CaptureObservation( + capture_code=capture_code, + reported_status=reported_status, + phase=phase, + reach_tier=reach_tier, + observed_at=observed_at, + source_kind="EpicsPv", + source_id="2bmb:TomoScan:ScanStatus", + ) + + +class _FakeObserver: + """Yields a fixed observation sequence once, then ends the stream.""" + + def __init__(self, observations: list[CaptureObservation]) -> None: + self._observations = observations + + def observe(self, scope: CaptureObserverScope) -> AsyncGenerator[CaptureObservation]: + return self._drain() + + async def _drain(self) -> AsyncGenerator[CaptureObservation]: + for observation in self._observations: + yield observation + + +class _BoomObserver: + """Raises mid-iteration so the loop's outer resilience branch fires.""" + + def observe(self, scope: CaptureObserverScope) -> AsyncGenerator[CaptureObservation]: + return self._drain() + + async def _drain(self) -> AsyncGenerator[CaptureObservation]: + raise RuntimeError("observer boom") + yield # pragma: no cover - unreachable, marks this body an async generator + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("phase", "expected_event"), + [ + (CapturePhase.BEGUN, "run_witness.capture_begun"), + (CapturePhase.PROGRESSING, "run_witness.capture_progressing"), + (CapturePhase.ENDED, "run_witness.capture_ended"), + (CapturePhase.ABORTED, "run_witness.capture_aborted"), + (CapturePhase.UNRECOGNIZED, "run_witness.capture_unrecognized"), + ], +) +def test_observe_capture_logs_the_matching_event_per_phase( + phase: CapturePhase, expected_event: str +) -> None: + with structlog.testing.capture_logs() as logs: + observe_capture(_obs(reported_status="whatever", phase=phase)) + assert [entry["event"] for entry in logs] == [expected_event] + + +@pytest.mark.unit +def test_observe_capture_logs_unreached_for_a_probe_only_observation() -> None: + """A `None` phase (no status claim at all) logs as unreached rather + than being silently dropped.""" + with structlog.testing.capture_logs() as logs: + observe_capture(_obs(reported_status=None, phase=None)) + assert [entry["event"] for entry in logs] == ["run_witness.capture_unreached"] + + +@pytest.mark.unit +def test_observe_capture_carries_the_full_attribution() -> None: + with structlog.testing.capture_logs() as logs: + observe_capture(_obs(reported_status="Scan complete", phase=CapturePhase.ENDED)) + entry = logs[0] + assert entry["capture_code"] == _CODE + assert entry["reported_status"] == "Scan complete" + assert entry["source_kind"] == "EpicsPv" + assert entry["source_id"] == "2bmb:TomoScan:ScanStatus" + assert entry["observed_at"] == _NOW.isoformat() + + +@pytest.mark.unit +def test_observe_capture_reports_no_substrate_time_as_none_not_a_string() -> None: + """An adapter with no substrate time reports `None`; the log line + must carry that faithfully rather than stringifying a sentinel.""" + with structlog.testing.capture_logs() as logs: + observe_capture( + _obs(reported_status="Scan complete", phase=CapturePhase.ENDED, observed_at=None) + ) + assert logs[0]["observed_at"] is None + + +@pytest.mark.unit +async def test_run_witness_loop_is_a_no_op_for_empty_capture_codes() -> None: + observer = _FakeObserver([_obs(reported_status="Scan complete", phase=CapturePhase.ENDED)]) + await run_witness_loop(observer=observer, capture_codes=frozenset()) + # No assertion needed beyond "returns": an observer never drained + # would hang forever if the empty-scope short-circuit were missing. + + +@pytest.mark.unit +async def test_run_witness_loop_logs_every_observation_in_sequence() -> None: + observer = _FakeObserver( + [ + _obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN), + _obs(reported_status="Collecting projections", phase=CapturePhase.PROGRESSING), + _obs(reported_status="Scan complete", phase=CapturePhase.ENDED), + ] + ) + task = asyncio.create_task( + run_witness_loop(observer=observer, capture_codes=frozenset({_CODE})) + ) + with structlog.testing.capture_logs() as logs: + await asyncio.sleep(0.05) # one full drain of the fixed sequence + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + events = [entry["event"] for entry in logs] + assert events == [ + "run_witness.capture_begun", + "run_witness.capture_progressing", + "run_witness.capture_ended", + ] + + +@pytest.mark.unit +async def test_run_witness_loop_survives_an_observer_that_raises() -> None: + """The outer resilience branch logs and reconnects rather than the + loop propagating the exception and dying silently.""" + task = asyncio.create_task( + run_witness_loop( + observer=_BoomObserver(), + capture_codes=frozenset({_CODE}), + reconnect_delay_seconds=0.01, + ) + ) + with structlog.testing.capture_logs() as logs: + await asyncio.sleep(0.05) # several reconnect passes at this cadence + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + events = [entry["event"] for entry in logs] + assert "run_witness.iteration_failed" in events + + +@pytest.mark.unit +async def test_lifespan_is_a_no_op_when_no_capture_codes_configured() -> None: + entered = False + async with run_witness_lifespan(observer=_FakeObserver([]), capture_codes=frozenset()): + entered = True + assert entered + + +@pytest.mark.unit +async def test_lifespan_spawns_and_cleanly_cancels_the_background_task() -> None: + observer = _FakeObserver([_obs(reported_status="Scan complete", phase=CapturePhase.ENDED)]) + with structlog.testing.capture_logs() as logs: + async with run_witness_lifespan(observer=observer, capture_codes=frozenset({_CODE})): + await asyncio.sleep(0.02) + events = [entry["event"] for entry in logs] + assert "run_witness.capture_ended" in events + + +class _FakeRecordWitnessedRun: + """Fake `record_witnessed_run` handler: records every call, returns a + fixed run_id, or raises a configured exception instead.""" + + def __init__(self, *, run_id: UUID | None = None, raises: Exception | None = None) -> None: + self.run_id = run_id if run_id is not None else uuid4() + self.raises = raises + self.calls: list[RecordWitnessedRun] = [] + + async def __call__( + self, + command: RecordWitnessedRun, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + self.calls.append(command) + if self.raises is not None: + raise self.raises + return self.run_id + + +def _recorder( + *, + record_witnessed_run: _FakeRecordWitnessedRun, + run_witness_recording_enabled: bool = True, + capture_watch_plan_id: UUID | None = _PLAN_ID, + open_captures: dict[str, UUID] | None = None, +) -> RunWitnessRecorder: + settings = Settings( # type: ignore[call-arg] + run_witness_recording_enabled=run_witness_recording_enabled, + capture_watch_plan_id=capture_watch_plan_id, + ) + return RunWitnessRecorder( + deps=build_deps(ids=[uuid4() for _ in range(10)]), + record_witnessed_run=record_witnessed_run, + settings=settings, + open_captures=open_captures, + ) + + +@pytest.mark.unit +async def test_run_witness_recorder_promotes_a_begun_capture_while_idle() -> None: + fake = _FakeRecordWitnessedRun() + recorder = _recorder(record_witnessed_run=fake) + + await recorder.observe_capture(_obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN)) + + assert len(fake.calls) == 1 + command = fake.calls[0] + assert command.capture_code == _CODE + assert command.plan_id == _PLAN_ID + assert command.trigger == "Monitor" + assert command.monitor_source_id == RUN_WITNESS_MONITOR_SOURCE_ID + + +@pytest.mark.unit +async def test_run_witness_recorder_does_not_repromote_a_begun_capture_while_open() -> None: + fake = _FakeRecordWitnessedRun() + recorder = _recorder(record_witnessed_run=fake) + + begun = _obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN) + await recorder.observe_capture(begun) + await recorder.observe_capture(begun) + + assert len(fake.calls) == 1 + + +@pytest.mark.unit +async def test_run_witness_recorder_stays_idle_after_a_promotion_failure() -> None: + fake = _FakeRecordWitnessedRun(raises=RuntimeError("clearance refused")) + recorder = _recorder(record_witnessed_run=fake) + + begun = _obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN) + await recorder.observe_capture(begun) + assert len(fake.calls) == 1 + + fake.raises = None + await recorder.observe_capture(begun) + assert len(fake.calls) == 2 + + +@pytest.mark.unit +async def test_run_witness_recorder_logs_a_distinct_event_on_unauthorized() -> None: + fake = _FakeRecordWitnessedRun(raises=UnauthorizedError("not granted")) + recorder = _recorder(record_witnessed_run=fake) + + begun = _obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN) + with structlog.testing.capture_logs() as logs: + await recorder.observe_capture(begun) + + events = [entry["event"] for entry in logs] + assert "run_witness.promotion_unauthorized" in events + + +@pytest.mark.unit +async def test_run_witness_recorder_clears_on_ended_while_open() -> None: + run_id = uuid4() + fake = _FakeRecordWitnessedRun(run_id=run_id) + recorder = _recorder(record_witnessed_run=fake, open_captures={_CODE: run_id}) + + await recorder.observe_capture(_obs(reported_status="Scan complete", phase=CapturePhase.ENDED)) + + # Reopening after the close promotes again: proves the entry was + # actually cleared, not merely left stale. + await recorder.observe_capture(_obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN)) + assert len(fake.calls) == 1 + + +@pytest.mark.unit +async def test_run_witness_recorder_clears_on_aborted_while_open() -> None: + run_id = uuid4() + fake = _FakeRecordWitnessedRun(run_id=run_id) + recorder = _recorder(record_witnessed_run=fake, open_captures={_CODE: run_id}) + + await recorder.observe_capture(_obs(reported_status="Scan aborted", phase=CapturePhase.ABORTED)) + await recorder.observe_capture(_obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN)) + + assert len(fake.calls) == 1 + + +@pytest.mark.unit +async def test_run_witness_recorder_noop_on_ended_while_idle() -> None: + fake = _FakeRecordWitnessedRun() + recorder = _recorder(record_witnessed_run=fake) + + await recorder.observe_capture(_obs(reported_status="Scan complete", phase=CapturePhase.ENDED)) + + assert fake.calls == [] + + +@pytest.mark.unit +async def test_run_witness_recorder_noop_on_aborted_while_idle() -> None: + fake = _FakeRecordWitnessedRun() + recorder = _recorder(record_witnessed_run=fake) + + await recorder.observe_capture(_obs(reported_status="Scan aborted", phase=CapturePhase.ABORTED)) + + assert fake.calls == [] + + +@pytest.mark.unit +@pytest.mark.parametrize("preopened", [False, True]) +async def test_run_witness_recorder_noop_on_progressing_regardless_of_state( + preopened: bool, +) -> None: + fake = _FakeRecordWitnessedRun() + open_captures = {_CODE: uuid4()} if preopened else None + recorder = _recorder(record_witnessed_run=fake, open_captures=open_captures) + + await recorder.observe_capture( + _obs(reported_status="Collecting projections", phase=CapturePhase.PROGRESSING) + ) + + assert fake.calls == [] + + +@pytest.mark.unit +@pytest.mark.parametrize("preopened", [False, True]) +async def test_run_witness_recorder_noop_on_unrecognized_regardless_of_state( + preopened: bool, +) -> None: + fake = _FakeRecordWitnessedRun() + open_captures = {_CODE: uuid4()} if preopened else None + recorder = _recorder(record_witnessed_run=fake, open_captures=open_captures) + + await recorder.observe_capture(_obs(reported_status="???", phase=CapturePhase.UNRECOGNIZED)) + + assert fake.calls == [] + + +@pytest.mark.unit +@pytest.mark.parametrize("preopened", [False, True]) +async def test_run_witness_recorder_noop_on_none_phase_regardless_of_state( + preopened: bool, +) -> None: + """The roadmap's explicit rule: a `phase is None` observation must + neither promote nor clear the dedup state.""" + fake = _FakeRecordWitnessedRun() + open_captures = {_CODE: uuid4()} if preopened else None + recorder = _recorder(record_witnessed_run=fake, open_captures=open_captures) + + await recorder.observe_capture(_obs(reported_status=None, phase=None)) + + assert fake.calls == [] + + +@pytest.mark.unit +async def test_run_witness_recorder_is_a_pass_through_when_recording_disabled() -> None: + """The hard no-regression requirement: with recording off, the fake + handler is never called and the log output matches bare + `observe_capture` exactly.""" + fake = _FakeRecordWitnessedRun() + recorder = _recorder(record_witnessed_run=fake, run_witness_recording_enabled=False) + observation = _obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN) + + with structlog.testing.capture_logs() as recorder_logs: + await recorder.observe_capture(observation) + with structlog.testing.capture_logs() as bare_logs: + observe_capture(observation) + + assert fake.calls == [] + assert recorder_logs == bare_logs + + +@pytest.mark.unit +async def test_run_witness_lifespan_seeds_open_captures_from_the_supplied_map() -> None: + run_id = uuid4() + fake = _FakeRecordWitnessedRun() + observer = _FakeObserver([_obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN)]) + deps = build_deps() + + async with run_witness_lifespan( + observer=observer, + capture_codes=frozenset({_CODE}), + deps=deps, + record_witnessed_run=fake, + open_captures={_CODE: run_id}, + ): + await asyncio.sleep(0.02) + + assert fake.calls == [] + + +@pytest.mark.unit +async def test_run_witness_lifespan_rejects_a_handler_without_deps() -> None: + with pytest.raises(ValueError, match="requires deps"): + async with run_witness_lifespan( + observer=_FakeObserver([]), + capture_codes=frozenset({_CODE}), + record_witnessed_run=_FakeRecordWitnessedRun(), + ): + pass + + +class _FakeListRuns: + """Fake `list_runs` handler: returns one canned page per call, in order.""" + + def __init__(self, pages: list[Any]) -> None: + self._pages = pages + self.queries: list[Any] = [] + + async def __call__( + self, + query: Any, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> Any: + self.queries.append(query) + return self._pages[len(self.queries) - 1] + + +def _summary_item(*, run_id: UUID, conduct_mode: str = "Witnessed") -> RunSummaryItem: + return RunSummaryItem( + run_id=run_id, + name="watched capture", + plan_id=_PLAN_ID, + subject_id=None, + raid=None, + status="Running", + created_at=_NOW, + running_since=_NOW, + override_parameters_present=False, + campaign_id=None, + snr_limit=None, + expected_observation_interval_seconds=None, + conduct_mode=conduct_mode, + ) + + +async def _append_witnessed_run_started(deps: Any, *, run_id: UUID, capture_code: str) -> None: + """Directly append a RunStarted(conduct_mode=WITNESSED) event carrying + the given capture_code as an external_ref, mirroring the shipped + `test_initiator_tick_counts_a_witnessed_run_toward_max_in_flight` + seeding pattern (record_witnessed_run's own decider is exercised + elsewhere; this test only needs the resulting stream shape).""" + event = RunStarted( + run_id=run_id, + name="watched capture", + plan_id=_PLAN_ID, + subject_id=None, + occurred_at=_NOW, + conduct_mode=ConductMode.WITNESSED, + external_refs=({"scheme": "capture-code", "value": capture_code},), + ) + await deps.event_store.append( + stream_type="Run", + stream_id=run_id, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=uuid4(), + command_name="seed", + correlation_id=uuid4(), + causation_id=None, + principal_id=uuid4(), + ) + ], + ) + + +@pytest.mark.unit +async def test_rebuild_open_captures_extracts_capture_code_from_external_refs() -> None: + deps = build_deps(ids=[uuid4() for _ in range(10)]) + run_id = uuid4() + await _append_witnessed_run_started(deps, run_id=run_id, capture_code=_CODE) + list_runs = _FakeListRuns([RunListPage(items=[_summary_item(run_id=run_id)], next_cursor=None)]) + + result = await rebuild_open_captures(deps, list_runs=list_runs) + + assert result == {_CODE: run_id} + + +@pytest.mark.unit +async def test_rebuild_open_captures_pages_through_multiple_pages() -> None: + deps = build_deps(ids=[uuid4() for _ in range(10)]) + run_id_a = uuid4() + run_id_b = uuid4() + await _append_witnessed_run_started(deps, run_id=run_id_a, capture_code="code-a") + await _append_witnessed_run_started(deps, run_id=run_id_b, capture_code="code-b") + list_runs = _FakeListRuns( + [ + RunListPage(items=[_summary_item(run_id=run_id_a)], next_cursor="more"), + RunListPage(items=[_summary_item(run_id=run_id_b)], next_cursor=None), + ] + ) + + result = await rebuild_open_captures(deps, list_runs=list_runs) + + assert result == {"code-a": run_id_a, "code-b": run_id_b} + assert [q.cursor for q in list_runs.queries] == [None, "more"] + + +@pytest.mark.unit +async def test_rebuild_open_captures_skips_a_run_with_no_capture_code_ref() -> None: + deps = build_deps(ids=[uuid4() for _ in range(10)]) + run_id = uuid4() + event = RunStarted( + run_id=run_id, + name="watched capture, no ref", + plan_id=_PLAN_ID, + subject_id=None, + occurred_at=_NOW, + conduct_mode=ConductMode.WITNESSED, + ) + await deps.event_store.append( + stream_type="Run", + stream_id=run_id, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=uuid4(), + command_name="seed", + correlation_id=uuid4(), + causation_id=None, + principal_id=uuid4(), + ) + ], + ) + list_runs = _FakeListRuns([RunListPage(items=[_summary_item(run_id=run_id)], next_cursor=None)]) + + result = await rebuild_open_captures(deps, list_runs=list_runs) + + assert result == {} diff --git a/apps/api/tests/unit/api/test_run_witness_recording_gate.py b/apps/api/tests/unit/api/test_run_witness_recording_gate.py new file mode 100644 index 00000000000..fd73638e8d3 --- /dev/null +++ b/apps/api/tests/unit/api/test_run_witness_recording_gate.py @@ -0,0 +1,89 @@ +# pyright: reportPrivateUsage=false +"""Unit tests for the RunWitness recording boot guard. + +`_enforce_run_witness_recording_gate` refuses to boot with +`run_witness_recording_enabled=True` unless both `run_witness_enabled` +and `capture_watch_plan_id` are also set: promotion has no shadow +observer to promote from, or no Plan to bind the promoted Run to, +without both. Unlike the production signing/principal guards, this one +is not keyed on `app_env`: a half-configured recording gate is a +misconfiguration in every environment. +""" + +from uuid import UUID, uuid4 + +import pytest + +from cora.api.main import _enforce_run_witness_recording_gate +from cora.infrastructure.config import Settings + + +def _settings( + *, + run_witness_enabled: bool = False, + capture_watch_plan_id: UUID | None = None, + run_witness_recording_enabled: bool = False, +) -> Settings: + return Settings( # type: ignore[call-arg] + run_witness_enabled=run_witness_enabled, + capture_watch_plan_id=capture_watch_plan_id, + run_witness_recording_enabled=run_witness_recording_enabled, + ) + + +def test_recording_disabled_is_always_a_no_op() -> None: + for run_witness_enabled in (True, False): + for capture_watch_plan_id in (None, uuid4()): + _enforce_run_witness_recording_gate( + _settings( + run_witness_enabled=run_witness_enabled, + capture_watch_plan_id=capture_watch_plan_id, + run_witness_recording_enabled=False, + ) + ) + + +def test_recording_enabled_without_witness_enabled_refuses_boot() -> None: + with pytest.raises(RuntimeError, match="RUN_WITNESS_ENABLED=true"): + _enforce_run_witness_recording_gate( + _settings( + run_witness_enabled=False, + capture_watch_plan_id=uuid4(), + run_witness_recording_enabled=True, + ) + ) + + +def test_recording_enabled_without_plan_id_refuses_boot() -> None: + with pytest.raises(RuntimeError, match="CAPTURE_WATCH_PLAN_ID"): + _enforce_run_witness_recording_gate( + _settings( + run_witness_enabled=True, + capture_watch_plan_id=None, + run_witness_recording_enabled=True, + ) + ) + + +def test_recording_enabled_missing_both_refuses_boot_naming_both_vars() -> None: + with pytest.raises(RuntimeError) as exc: + _enforce_run_witness_recording_gate( + _settings( + run_witness_enabled=False, + capture_watch_plan_id=None, + run_witness_recording_enabled=True, + ) + ) + message = str(exc.value) + assert "RUN_WITNESS_ENABLED=true" in message + assert "CAPTURE_WATCH_PLAN_ID" in message + + +def test_recording_enabled_with_both_prerequisites_passes() -> None: + _enforce_run_witness_recording_gate( + _settings( + run_witness_enabled=True, + capture_watch_plan_id=uuid4(), + run_witness_recording_enabled=True, + ) + ) diff --git a/apps/api/tests/unit/infrastructure/record_export/test_redact_tier1.py b/apps/api/tests/unit/infrastructure/record_export/test_redact_tier1.py index 7f45a88d8b1..f9297821291 100644 --- a/apps/api/tests/unit/infrastructure/record_export/test_redact_tier1.py +++ b/apps/api/tests/unit/infrastructure/record_export/test_redact_tier1.py @@ -67,6 +67,45 @@ def test_recursed_value_object_drops_its_own_drop_text_subfields() -> None: assert "model_ref" not in redacted or redacted["model_ref"] == {} +def test_safety_envelope_verdict_bools_are_redacted_not_kept_whole() -> None: + """`RunStarted.safety_envelope_verdict.enclosure_permitted` / + `.beam_available` are a point-in-time live PSS/interlock and + beam-shutter reading, the same class of fact `EnclosurePermitObserved` + already drops entirely. `_OVERRIDE_DISPOSITIONS` + (`tools/gen_record_dispositions.py`) overrides the generic + `bool -> keep:number` default for exactly these two fields; this pins + the outcome against the real, generated table rather than a + hand-tuned one, so a future regeneration that lost the override + would fail this test.""" + payload = { + "run_id": "01900000-0000-7000-8000-0000000000d1", + "name": "2BM watched capture", + "plan_id": "01900000-0000-7000-8000-0000000000d2", + "subject_id": None, + "raid": None, + "conduct_mode": "Witnessed", + "safety_envelope_verdict": { + "enclosure_permitted": True, + "beam_available": False, + }, + "override_parameters": {}, + "effective_parameters": {}, + "trigger_source": "Monitor:2bmb-tomoscan", + "external_refs": [{"scheme": "capture-code", "value": "2bmb-tomoscan"}], + "acknowledged_cautions": [], + "campaign_id": None, + "decided_by_decision_id": None, + "pinned_calibration_ids": [], + "input_dataset_ids": [], + "occurred_at": "2026-08-14T12:00:00+00:00", + } + redacted = redact_tier1_payload("RunStarted", payload, token_map=TokenMap()) + assert "safety_envelope_verdict" not in redacted or redacted["safety_envelope_verdict"] == {} + # conduct_mode (keep:enum) survives, proving the drop is specific to + # the verdict's two bools, not a blanket omission of the whole event. + assert redacted["conduct_mode"] == "Witnessed" + + def test_a_payload_key_absent_from_a_known_events_field_list_drops() -> None: """Schema evolution: an older schema_version's row can carry a field the current dataclass no longer declares. Must drop, not abort -- diff --git a/apps/api/tests/unit/run/test_capture_observer.py b/apps/api/tests/unit/run/test_capture_observer.py new file mode 100644 index 00000000000..b34ffe7f207 --- /dev/null +++ b/apps/api/tests/unit/run/test_capture_observer.py @@ -0,0 +1,97 @@ +"""Unit tests for the `CaptureObserver` port and its `QuietCaptureObserver` stub. + +Mirrors `test_enclosure_observer.py`'s shape, adjusted for the one real +difference between the two ports: there is no safe "always" reading for +a capture phase the way `AlwaysPermittedEnclosureObserver` has one for a +permit status, so the stub here yields nothing rather than one +observation per code. +""" + +from datetime import UTC, datetime + +import pytest + +from cora.run.ports.capture_observer import ( + CaptureObservation, + CaptureObserver, + CaptureObserverScope, + CapturePhase, + QuietCaptureObserver, +) +from cora.shared.reach import ReachTier + +_TOMOSCAN = "2bmb-tomoscan" +_OTHER = "2bmb-tomoscan-alt" +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) + + +@pytest.mark.unit +def test_quiet_observer_satisfies_capture_observer_protocol() -> None: + assert isinstance(QuietCaptureObserver(), CaptureObserver) + + +@pytest.mark.unit +async def test_quiet_observer_yields_nothing_for_empty_scope() -> None: + observer = QuietCaptureObserver() + scope = CaptureObserverScope(capture_codes=frozenset()) + observations = [obs async for obs in observer.observe(scope)] + assert observations == [] + + +@pytest.mark.unit +async def test_quiet_observer_yields_nothing_for_a_populated_scope() -> None: + """Unlike the Enclosure stub's optimistic default, there is no safe + 'always' capture phase, so a populated scope still yields nothing + rather than a synthesized reading.""" + observer = QuietCaptureObserver() + scope = CaptureObserverScope(capture_codes=frozenset({_TOMOSCAN, _OTHER})) + observations = [obs async for obs in observer.observe(scope)] + assert observations == [] + + +@pytest.mark.unit +def test_capture_observation_is_frozen_dataclass() -> None: + obs = CaptureObservation( + capture_code=_TOMOSCAN, + reported_status="Scan complete", + phase=CapturePhase.ENDED, + reach_tier=ReachTier.RELAYED, + observed_at=_EPOCH, + source_kind="EpicsPv", + source_id="2bmb:TomoScan:ScanStatus", + ) + with pytest.raises(AttributeError): + obs.phase = CapturePhase.BEGUN # type: ignore[misc] + + +@pytest.mark.unit +def test_capture_observer_scope_is_frozen_dataclass() -> None: + scope = CaptureObserverScope(capture_codes=frozenset({_TOMOSCAN})) + with pytest.raises(AttributeError): + scope.capture_codes = frozenset({_OTHER}) # type: ignore[misc] + + +@pytest.mark.unit +def test_capture_observation_permits_a_probe_only_reading_with_no_phase() -> None: + """A probe-only re-affirmation carries no status claim and therefore + no phase, mirroring `EnclosureObservation.observed_status=None`.""" + obs = CaptureObservation( + capture_code=_TOMOSCAN, + reported_status=None, + phase=None, + reach_tier=ReachTier.RELAYED, + observed_at=None, + source_kind="EpicsPv", + source_id="2bmb:TomoScan:ScanStatus", + ) + assert obs.reported_status is None + assert obs.phase is None + + +@pytest.mark.unit +def test_capture_phase_has_an_unrecognized_member_for_vocabulary_drift() -> None: + """A substrate literal that does not match the deployment's declared + mapping must classify as UNRECOGNIZED, never be dropped or coerced + into a nearby phase.""" + assert CapturePhase.UNRECOGNIZED in CapturePhase + assert CapturePhase.UNRECOGNIZED.value == "Unrecognized" diff --git a/apps/api/tests/unit/run/test_list_runs_handler.py b/apps/api/tests/unit/run/test_list_runs_handler.py index 71b9d7c58a4..4cd1aba8671 100644 --- a/apps/api/tests/unit/run/test_list_runs_handler.py +++ b/apps/api/tests/unit/run/test_list_runs_handler.py @@ -68,7 +68,23 @@ async def test_handler_accepts_well_formed_cursor() -> None: async def test_handler_accepts_combined_filters() -> None: handler = bind(build_deps()) page = await handler( - ListRuns(status="Running", plan_id=uuid4(), campaign_id=uuid4()), + ListRuns( + status="Running", + plan_id=uuid4(), + campaign_id=uuid4(), + conduct_mode="Witnessed", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert page.items == [] + + +@pytest.mark.unit +async def test_handler_accepts_conduct_mode_filter() -> None: + handler = bind(build_deps()) + page = await handler( + ListRuns(conduct_mode="Witnessed"), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) diff --git a/apps/api/tests/unit/run/test_record_witnessed_run_decider.py b/apps/api/tests/unit/run/test_record_witnessed_run_decider.py new file mode 100644 index 00000000000..9501468c017 --- /dev/null +++ b/apps/api/tests/unit/run/test_record_witnessed_run_decider.py @@ -0,0 +1,511 @@ +"""Unit tests for the `record_witnessed_run` slice's pure decider. + +The witnessed-genesis counterpart to `test_start_run_decider.py`. Pins the +governing rule: CORA-side data faults (deprecated Plan, decommissioned +Asset, capability shortfall, absent Clearance or Supply) stay refusals +here exactly as at a driven start; only the enclosure and beam gates are +witnessed instead of enforced. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates.asset import ( + Asset, + AssetLifecycle, + AssetName, + AssetTier, +) +from cora.infrastructure.ports.beam_availability_lookup import BeamAvailabilityLookupResult +from cora.infrastructure.ports.clearance_lookup import ClearanceLookupResult +from cora.infrastructure.ports.enclosure_lookup import EnclosureLookupResult +from cora.recipe.aggregates.plan import Plan, PlanName, PlanStatus +from cora.run.aggregates.run import ( + ConductMode, + InvalidRunNameError, + Run, + RunAlreadyExistsError, + RunBoundPlanDeprecatedError, + RunCapabilitiesNotSatisfiedError, + RunClearanceCoverageMismatchError, + RunMonitorTriggerNotPermittedError, + RunName, + RunPlanAssetDecommissionedError, + RunRequiresActiveClearanceError, + RunStatus, + RunSubjectNotMountableError, + SafetyEnvelopeVerdict, +) +from cora.run.features import record_witnessed_run +from cora.run.features.record_witnessed_run import RecordWitnessedRun, RunWitnessedStartContext +from cora.subject.aggregates.subject import Subject, SubjectName, SubjectStatus + +_NOW = datetime(2026, 8, 14, 12, 0, 0, tzinfo=UTC) +_TRIGGER = "Monitor" + + +def _active_clearance_stub() -> tuple[ClearanceLookupResult, ...]: + return ( + ClearanceLookupResult( + clearance_id=UUID(int=0), + status="Active", + template_id=UUID(int=1), + template_code="ESAF", + facility_code="aps", + ), + ) + + +def _plan( + *, + plan_id: UUID | None = None, + practice_id: UUID | None = None, + asset_ids: frozenset[UUID] | None = None, + status: PlanStatus = PlanStatus.DEFINED, +) -> Plan: + return Plan( + id=plan_id or uuid4(), + name=PlanName("2BM watched capture"), + practice_id=practice_id or uuid4(), + asset_ids=asset_ids or frozenset({uuid4()}), + status=status, + ) + + +def _asset( + *, + asset_id: UUID | None = None, + family_ids: frozenset[UUID] | None = None, + lifecycle: AssetLifecycle = AssetLifecycle.ACTIVE, +) -> Asset: + return Asset( + id=asset_id or uuid4(), + name=AssetName("2bmSP1Camera"), + tier=AssetTier.DEVICE, + parent_id=uuid4(), + lifecycle=lifecycle, + family_ids=family_ids if family_ids is not None else frozenset(), + ) + + +def _subject( + *, + subject_id: UUID | None = None, + status: SubjectStatus = SubjectStatus.MOUNTED, +) -> Subject: + return Subject( + id=subject_id or uuid4(), + name=SubjectName("PorousCeramicSample-A"), + status=status, + ) + + +def _command(**overrides: object) -> RecordWitnessedRun: + defaults: dict[str, object] = { + "name": "watched capture", + "plan_id": uuid4(), + "capture_code": "2bmb-tomoscan", + "monitor_source_id": UUID("01900000-0000-7000-8000-000063617001"), + "trigger": _TRIGGER, + } + defaults.update(overrides) + return RecordWitnessedRun(**defaults) # type: ignore[arg-type] + + +def _beam( + *, + fes_open: bool = True, + sbs_open: bool = True, + fes_permit: bool = True, + quality_ok: bool = True, +) -> BeamAvailabilityLookupResult: + return BeamAvailabilityLookupResult( + fes_open=fes_open, sbs_open=sbs_open, fes_permit=fes_permit, quality_ok=quality_ok + ) + + +def _enclosure(permit_status: str, lifecycle: str) -> EnclosureLookupResult: + return EnclosureLookupResult( + enclosure_id=uuid4(), + name="2-BM-B", + permit_status=permit_status, + lifecycle=lifecycle, + permit_status_changed_at=None, + source_kind=None, + source_id=None, + ) + + +# ---------- Happy path ---------- + + +@pytest.mark.unit +def test_decide_emits_run_started_witnessed_for_a_valid_capture() -> None: + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + new_id = uuid4() + decision = record_witnessed_run.decide( + state=None, + command=_command(name="watched capture", plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset({cap}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=new_id, + ) + assert len(decision.run_events) == 1 + event = decision.run_events[0] + assert event.conduct_mode is ConductMode.WITNESSED + assert event.safety_envelope_verdict == SafetyEnvelopeVerdict( + enclosure_permitted=True, beam_available=True + ) + assert event.trigger_source == "RunWitness:2bmb-tomoscan" + assert dict(event.external_refs[0]) == {"scheme": "capture-code", "value": "2bmb-tomoscan"} + + +@pytest.mark.unit +def test_decide_hardcodes_recorded_regardless_of_input() -> None: + """RecordWitnessedRun carries no conduct_mode field for a caller to set; + the decider always stamps WITNESSED.""" + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + decision = record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset({cap}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + assert decision.run_events[0].conduct_mode is ConductMode.WITNESSED + + +# ---------- The trigger guard ---------- + + +@pytest.mark.unit +@pytest.mark.parametrize("bad_trigger", ["Operator", "API", "", "monitor"]) +def test_decide_rejects_any_non_monitor_trigger(bad_trigger: str) -> None: + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunMonitorTriggerNotPermittedError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id, trigger=bad_trigger), + context=context, + needed_family_ids_snapshot=frozenset({cap}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +# ---------- Witnessed, not enforced: enclosure + beam ---------- + + +@pytest.mark.unit +def test_decide_records_failing_enclosure_and_beam_instead_of_raising() -> None: + """The roadmap's central claim for slice 8: every gate failing still + writes, and the failures are recorded, not refused.""" + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + referencing_enclosures=(_enclosure("NotPermitted", "Active"),), + beam_availability=_beam(quality_ok=False), + ) + decision = record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset({cap}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + event = decision.run_events[0] + assert event.safety_envelope_verdict == SafetyEnvelopeVerdict( + enclosure_permitted=False, beam_available=False + ) + + +# ---------- Still refusals: clearance + supply ---------- + + +@pytest.mark.unit +def test_decide_no_clearance_still_raises() -> None: + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=(), + ) + with pytest.raises(RunRequiresActiveClearanceError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset({cap}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_clearance_present_but_inactive_still_raises() -> None: + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=( + ClearanceLookupResult( + clearance_id=uuid4(), + status="Expired", + template_id=uuid4(), + template_code="ESAF", + facility_code="aps", + ), + ), + ) + with pytest.raises(RunClearanceCoverageMismatchError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset({cap}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +# ---------- CORA-side genesis invariants: still refusals ---------- + + +@pytest.mark.unit +def test_decide_on_existing_state_raises_already_exists() -> None: + existing = Run( + id=uuid4(), + name=RunName("prior"), + plan_id=uuid4(), + subject_id=None, + status=RunStatus.RUNNING, + ) + context = RunWitnessedStartContext( + plan=_plan(), + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunAlreadyExistsError) as exc: + record_witnessed_run.decide( + state=existing, + command=_command(), + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + assert exc.value.run_id == existing.id + + +@pytest.mark.unit +def test_decide_deprecated_plan_raises() -> None: + plan = _plan(status=PlanStatus.DEPRECATED) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunBoundPlanDeprecatedError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_subject_not_mountable_raises() -> None: + plan = _plan() + subject = _subject(status=SubjectStatus.RECEIVED) + context = RunWitnessedStartContext( + plan=plan, + subject=subject, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunSubjectNotMountableError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id, subject_id=subject.id), + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_decommissioned_asset_raises() -> None: + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, lifecycle=AssetLifecycle.DECOMMISSIONED) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunPlanAssetDecommissionedError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_capability_shortfall_raises() -> None: + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset()) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunCapabilitiesNotSatisfiedError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset({uuid4()}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +@pytest.mark.unit +def test_decide_invalid_name_raises() -> None: + plan = _plan() + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(InvalidRunNameError): + record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id, name=" "), + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + + +# ---------- Cautions: non-blocking snapshot ---------- + + +@pytest.mark.unit +def test_decide_embeds_active_cautions_snapshot() -> None: + from cora.infrastructure.ports.caution_lookup import CautionLookupResult + + plan = _plan() + caution = CautionLookupResult( + caution_id=uuid4(), + target_kind="Asset", + target_id=uuid4(), + category="Mechanical", + severity="Caution", + text_excerpt="Fragile mount", + workaround_excerpt="Handle gently", + ) + context = RunWitnessedStartContext( + plan=plan, + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + active_cautions=(caution,), + ) + decision = record_witnessed_run.decide( + state=None, + command=_command(plan_id=plan.id), + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=uuid4(), + ) + assert len(decision.run_events[0].acknowledged_cautions) == 1 + assert decision.run_events[0].acknowledged_cautions[0].caution_id == caution.caution_id diff --git a/apps/api/tests/unit/run/test_record_witnessed_run_decider_properties.py b/apps/api/tests/unit/run/test_record_witnessed_run_decider_properties.py new file mode 100644 index 00000000000..afe9cfddad1 --- /dev/null +++ b/apps/api/tests/unit/run/test_record_witnessed_run_decider_properties.py @@ -0,0 +1,320 @@ +"""Property-based tests for `record_witnessed_run.decide` (Run BC). + +Complements the example-based `test_record_witnessed_run_decider.py`. +Universal claims across generated inputs: + + - Any non-None state always raises `RunAlreadyExistsError`. + - Any trigger other than the literal "Monitor" always raises + `RunMonitorTriggerNotPermittedError`, regardless of every other + input. + - Zero referencing clearances always raises + `RunRequiresActiveClearanceError` (still a refusal, per the + roadmap's rule). + - On the happy path, the single `RunStarted` always carries + `conduct_mode=WITNESSED` and a non-None `safety_envelope_verdict`. + - Pure: same inputs return equal results. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.infrastructure.ports.clearance_lookup import ClearanceLookupResult +from cora.recipe.aggregates.plan import Plan, PlanName, PlanStatus +from cora.run.aggregates.run import ( + ConductMode, + Run, + RunAlreadyExistsError, + RunClearanceCoverageMismatchError, + RunMonitorTriggerNotPermittedError, + RunName, + RunRequiresActiveClearanceError, + RunStarted, + RunStatus, +) +from cora.run.features import record_witnessed_run +from cora.run.features.record_witnessed_run import RecordWitnessedRun, RunWitnessedStartContext +from cora.shared.identity import MonitorSourceId +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + +_NAME = printable_ascii_text(min_size=1, max_size=200) +_CAPTURE_CODE = printable_ascii_text(min_size=1, max_size=50) +_MONITOR_SOURCE_ID = MonitorSourceId(UUID("01900000-0000-7000-8000-000063617001")) + + +def _plan(*, status: PlanStatus = PlanStatus.DEFINED) -> Plan: + return Plan( + id=UUID(int=30), + name=PlanName("2BM watched capture"), + practice_id=UUID(int=31), + asset_ids=frozenset(), + status=status, + ) + + +def _active_clearances() -> tuple[ClearanceLookupResult, ...]: + return ( + ClearanceLookupResult( + clearance_id=UUID(int=40), + status="Active", + template_id=UUID(int=41), + template_code="ESAF", + facility_code="aps", + ), + ) + + +def _context( + *, + plan_status: PlanStatus = PlanStatus.DEFINED, + clearances: tuple[ClearanceLookupResult, ...] | None = None, +) -> RunWitnessedStartContext: + return RunWitnessedStartContext( + plan=_plan(status=plan_status), + subject=None, + assets={}, + referencing_clearances=_active_clearances() if clearances is None else clearances, + ) + + +def _command( + *, name: str, plan_id: UUID, capture_code: str, trigger: str = "Monitor" +) -> RecordWitnessedRun: + return RecordWitnessedRun( + name=name, + plan_id=plan_id, + capture_code=capture_code, + monitor_source_id=_MONITOR_SOURCE_ID, + trigger=trigger, + ) + + +@pytest.mark.unit +@given( + existing_id=st.uuids(), + existing_status=st.sampled_from(list(RunStatus)), + name=_NAME, + plan_id=st.uuids(), + capture_code=_CAPTURE_CODE, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_witnessed_on_existing_state_always_raises_already_exists( + existing_id: UUID, + existing_status: RunStatus, + name: str, + plan_id: UUID, + capture_code: str, + now: datetime, + new_id: UUID, +) -> None: + existing = Run( + id=existing_id, + name=RunName("prior"), + plan_id=UUID(int=1), + subject_id=None, + status=existing_status, + ) + with pytest.raises(RunAlreadyExistsError) as exc: + record_witnessed_run.decide( + state=existing, + command=_command(name=name, plan_id=plan_id, capture_code=capture_code), + context=_context(), + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=now, + new_id=new_id, + ) + assert exc.value.run_id == existing_id + + +@pytest.mark.unit +@given( + name=_NAME, + plan_id=st.uuids(), + capture_code=_CAPTURE_CODE, + trigger=st.text(min_size=0, max_size=20).filter(lambda t: t != "Monitor"), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_witnessed_any_non_monitor_trigger_always_raises( + name: str, + plan_id: UUID, + capture_code: str, + trigger: str, + now: datetime, + new_id: UUID, +) -> None: + """No input shape can make a non-Monitor trigger pass: the laundering + wall holds regardless of every other field.""" + with pytest.raises(RunMonitorTriggerNotPermittedError): + record_witnessed_run.decide( + state=None, + command=_command( + name=name, plan_id=plan_id, capture_code=capture_code, trigger=trigger + ), + context=_context(), + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=now, + new_id=new_id, + ) + + +@pytest.mark.unit +@given( + name=_NAME, + plan_id=st.uuids(), + capture_code=_CAPTURE_CODE, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_witnessed_without_referencing_clearance_always_raises_requires_clearance( + name: str, + plan_id: UUID, + capture_code: str, + now: datetime, + new_id: UUID, +) -> None: + with pytest.raises(RunRequiresActiveClearanceError): + record_witnessed_run.decide( + state=None, + command=_command(name=name, plan_id=plan_id, capture_code=capture_code), + context=_context(clearances=()), + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=now, + new_id=new_id, + ) + + +@pytest.mark.unit +@given( + name=_NAME, + plan_id=st.uuids(), + capture_code=_CAPTURE_CODE, + clearance_status=st.text(min_size=1, max_size=20).filter(lambda s: s != "Active"), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_witnessed_clearance_present_but_never_active_always_raises_coverage_mismatch( + name: str, + plan_id: UUID, + capture_code: str, + clearance_status: str, + now: datetime, + new_id: UUID, +) -> None: + """Whatever non-Active status a referencing clearance carries, the + witnessed path still refuses -- it never treats a present-but-inactive + clearance as witnessed.""" + clearances = ( + ClearanceLookupResult( + clearance_id=UUID(int=50), + status=clearance_status, + template_id=UUID(int=51), + template_code="ESAF", + facility_code="aps", + ), + ) + with pytest.raises(RunClearanceCoverageMismatchError): + record_witnessed_run.decide( + state=None, + command=_command(name=name, plan_id=plan_id, capture_code=capture_code), + context=_context(clearances=clearances), + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=now, + new_id=new_id, + ) + + +@pytest.mark.unit +@given( + name=_NAME, + plan_id=st.uuids(), + capture_code=_CAPTURE_CODE, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_witnessed_happy_path_always_emits_witnessed_with_a_verdict( + name: str, + plan_id: UUID, + capture_code: str, + now: datetime, + new_id: UUID, +) -> None: + """The happy path always stamps WITNESSED and a non-None verdict, + across the whole generated input space -- never CONDUCTED, never a + bare safety_envelope_verdict=None.""" + result = record_witnessed_run.decide( + state=None, + command=_command(name=name, plan_id=plan_id, capture_code=capture_code), + context=_context(), + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=now, + new_id=new_id, + ) + assert len(result.run_events) == 1 + event = result.run_events[0] + assert isinstance(event, RunStarted) + assert event.conduct_mode is ConductMode.WITNESSED + assert event.safety_envelope_verdict is not None + assert event.run_id == new_id + assert event.name == name + assert event.plan_id == plan_id + assert event.occurred_at == now + + +@pytest.mark.unit +@given( + name=_NAME, + plan_id=st.uuids(), + capture_code=_CAPTURE_CODE, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_witnessed_is_pure_same_input_same_output( + name: str, + plan_id: UUID, + capture_code: str, + now: datetime, + new_id: UUID, +) -> None: + command = _command(name=name, plan_id=plan_id, capture_code=capture_code) + context = _context() + first = record_witnessed_run.decide( + state=None, + command=command, + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=now, + new_id=new_id, + ) + second = record_witnessed_run.decide( + state=None, + command=command, + context=context, + needed_family_ids_snapshot=frozenset(), + effective_parameters={}, + method_parameters_schema=None, + now=now, + new_id=new_id, + ) + assert first.run_events == second.run_events diff --git a/apps/api/tests/unit/run/test_run_events.py b/apps/api/tests/unit/run/test_run_events.py index 55f116b86ea..8b1ea612ed4 100644 --- a/apps/api/tests/unit/run/test_run_events.py +++ b/apps/api/tests/unit/run/test_run_events.py @@ -18,6 +18,7 @@ from_stored, to_payload, ) +from cora.run.aggregates.run.state import ConductMode from cora.shared.identity import ActorId _NOW = datetime(2026, 5, 11, 12, 0, 0, tzinfo=UTC) @@ -74,6 +75,14 @@ def test_to_payload_serializes_run_started_with_subject_to_primitives() -> None: "name": "32-ID FlyScan", "plan_id": str(plan_id), "subject_id": str(subject_id), + # Additive payload field: who drove this act. Defaults to + # "Conducted" when not supplied; forward-compat via + # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)`. + "conduct_mode": "Conducted", + # Additive payload field: the witnessed-genesis envelope reading. + # Always None on a driven Run; forward-compat via + # `payload.get("safety_envelope_verdict")`. + "safety_envelope_verdict": None, "raid": None, # Additive payload fields default to {} / None when not # supplied; legacy stored events stay forward-compat via @@ -208,6 +217,7 @@ def test_from_stored_rebuilds_run_started_without_legacy_keys_as_defaults() -> N assert event.override_parameters == {} assert event.effective_parameters == {} assert event.trigger_source is None + assert event.conduct_mode is ConductMode.CONDUCTED @pytest.mark.unit diff --git a/apps/api/tests/unit/run/test_run_evolver.py b/apps/api/tests/unit/run/test_run_evolver.py index 44f8132e637..d0b560ed644 100644 --- a/apps/api/tests/unit/run/test_run_evolver.py +++ b/apps/api/tests/unit/run/test_run_evolver.py @@ -10,6 +10,7 @@ from cora.run.aggregates.run import ( LEGACY_CAUSE, LEGACY_CLAIM_ID, + ConductMode, Run, RunName, RunStatus, @@ -35,6 +36,7 @@ def _run_started( run_id: UUID | None = None, plan_id: UUID | None = None, subject_id: UUID | None = None, + conduct_mode: ConductMode = ConductMode.CONDUCTED, ) -> RunStarted: """Test helper: RunStarted with sensible defaults.""" return RunStarted( @@ -42,6 +44,7 @@ def _run_started( name="32-ID FlyScan", plan_id=plan_id or uuid4(), subject_id=subject_id, + conduct_mode=conduct_mode, occurred_at=_NOW, ) @@ -79,6 +82,36 @@ def test_evolve_run_started_without_subject_sets_subject_id_to_none() -> None: assert state.status is RunStatus.RUNNING +@pytest.mark.unit +def test_evolve_run_started_defaults_conduct_mode_to_conducted() -> None: + """Every StartRun caller today is Conducted; the default declares that + closed, currently-total fact rather than inferring it.""" + state = evolve(None, _run_started()) + assert state.conduct_mode is ConductMode.CONDUCTED + + +@pytest.mark.unit +def test_evolve_run_started_honors_explicit_witnessed_conduct_mode() -> None: + """A Witnessed genesis (record_witnessed_run's decider) must declare + its mode explicitly; the evolver copies it verbatim, never computing + or guessing it.""" + state = evolve(None, _run_started(conduct_mode=ConductMode.WITNESSED)) + assert state.conduct_mode is ConductMode.WITNESSED + + +@pytest.mark.unit +def test_conduct_mode_survives_hold_resume_complete_round_trip() -> None: + """conduct_mode is immutable after genesis: it must ride through every + transition arm unchanged, same as pinned_calibration_ids.""" + started = _run_started(conduct_mode=ConductMode.WITNESSED) + running = evolve(None, started) + held = evolve(running, RunHeld(run_id=started.run_id, occurred_at=_NOW)) + resumed = evolve(held, RunResumed(run_id=started.run_id, occurred_at=_NOW)) + completed = evolve(resumed, RunCompleted(run_id=started.run_id, occurred_at=_NOW)) + for state in (running, held, resumed, completed): + assert state.conduct_mode is ConductMode.WITNESSED + + @pytest.mark.unit def test_fold_empty_event_list_returns_none() -> None: assert fold([]) is None diff --git a/apps/api/tests/unit/run/test_run_summary_projection.py b/apps/api/tests/unit/run/test_run_summary_projection.py index e8baa053e47..a33af1bb8f7 100644 --- a/apps/api/tests/unit/run/test_run_summary_projection.py +++ b/apps/api/tests/unit/run/test_run_summary_projection.py @@ -111,6 +111,10 @@ async def test_run_started_inserts_with_running_status_and_genesis_refs() -> Non # payloads have no `pinned_calibration_ids` key; .get(..., []) lands # an empty UUID list. assert args.args[9] == [] + # conduct_mode: legacy/pre-slice-5 payloads have no `conduct_mode` + # key; .get("conduct_mode", "Conducted") lands the historical + # default, true of every Run started before this field existed. + assert args.args[12] == "Conducted" @pytest.mark.unit @@ -497,3 +501,29 @@ async def test_run_started_with_pinned_calibration_ids_inserts_uuid_array() -> N pinned: list[Any] = args.args[9] assert isinstance(pinned, list) assert set(pinned) == {pin_a, pin_b} + + +@pytest.mark.unit +async def test_run_started_with_witnessed_conduct_mode_inserts_witnessed() -> None: + """A WITNESSED-mode RunStarted payload (record_witnessed_run's real + emit shape) lands its declared value on the row verbatim, not the + historical Conducted default.""" + proj = RunSummaryProjection() + conn = AsyncMock() + event = _stored( + "RunStarted", + { + "run_id": str(_RUN_ID), + "name": "witnessed-scan", + "plan_id": str(_PLAN_ID), + "subject_id": None, + "conduct_mode": "Witnessed", + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[12] == "Witnessed" diff --git a/apps/api/tests/unit/run/test_safety_envelope.py b/apps/api/tests/unit/run/test_safety_envelope.py index 387cf5a619e..761a0f15fc5 100644 --- a/apps/api/tests/unit/run/test_safety_envelope.py +++ b/apps/api/tests/unit/run/test_safety_envelope.py @@ -1,10 +1,12 @@ """Unit tests for the shared start-safety-envelope check. -`check_safety_envelope` is the extracted core of the four cross-BC -live-signal gates (clearance / supply / enclosure / beam) shared by -`start_run` and the RunSupervisor's pre-resume re-check. These pin the -pass path and each failing gate so the two callers can rely on one -definition of "safe to (re)start". +`check_safety_envelope` and `witness_safety_envelope` are the two entry +points built from the four cross-BC live-signal gates (clearance / supply +/ enclosure / beam) shared by `start_run`, the RunSupervisor's pre-resume +re-check, and the witnessed-genesis decider. These pin the pass path and +each failing gate so every caller can rely on one definition of "safe to +(re)start", and prove the two entry points cannot drift: they are built +from the exact same gate functions, not two hand-synchronized copies. The full-decider gate tests (`test_start_run_*_gate_decider.py`) remain the guardrails proving the extraction is behavior-preserving; these add @@ -12,6 +14,7 @@ """ from collections.abc import Mapping +from unittest.mock import patch from uuid import UUID, uuid4 import pytest @@ -29,8 +32,11 @@ RunRequiresOpenBeamShuttersError, RunRequiresPermittedEnclosureError, RunSupplyCoverageMismatchError, + SafetyEnvelopeVerdict, check_safety_envelope, + witness_safety_envelope, ) +from cora.run.aggregates.run import safety_envelope as safety_envelope_module _RUN_ID = UUID("01900000-0000-7000-8000-0000000005a1") @@ -207,3 +213,111 @@ def test_beam_unknown_quality_raises_availability_unknown() -> None: def test_closed_shutter_raises_requires_open_shutters() -> None: with pytest.raises(RunRequiresOpenBeamShuttersError): _check(beam_availability=_beam(sbs_open=False)) + + +def _witness( + *, + referencing_clearances: tuple[ClearanceLookupResult, ...] | None = None, + needed_supplies_snapshot: frozenset[str] | None = None, + needed_supplies_satisfaction: Mapping[str, tuple[SupplyLookupResult, ...]] | None = None, + referencing_enclosures: tuple[EnclosureLookupResult, ...] | None = None, + beam_availability: BeamAvailabilityLookupResult | None = None, +) -> SafetyEnvelopeVerdict: + """Same fully-passing-envelope default shape as `_check`, routed through + `witness_safety_envelope` instead.""" + return witness_safety_envelope( + run_id=_RUN_ID, + referencing_clearances=( + referencing_clearances + if referencing_clearances is not None + else (_clearance("Active"),) + ), + needed_supplies_snapshot=( + needed_supplies_snapshot if needed_supplies_snapshot is not None else frozenset({"LN2"}) + ), + needed_supplies_satisfaction=( + needed_supplies_satisfaction + if needed_supplies_satisfaction is not None + else {"LN2": (_supply("Available"),)} + ), + referencing_enclosures=( + referencing_enclosures + if referencing_enclosures is not None + else (_enclosure("Permitted", "Active"),) + ), + beam_availability=beam_availability if beam_availability is not None else _beam(), + ) + + +@pytest.mark.unit +def test_witness_full_envelope_returns_verdict_all_gates_passed() -> None: + verdict = _witness() + assert verdict == SafetyEnvelopeVerdict(enclosure_permitted=True, beam_available=True) + assert verdict.all_gates_passed is True + + +@pytest.mark.unit +def test_witness_records_enclosure_not_permitted_instead_of_raising() -> None: + verdict = _witness(referencing_enclosures=(_enclosure("NotPermitted", "Active"),)) + assert verdict == SafetyEnvelopeVerdict(enclosure_permitted=False, beam_available=True) + assert verdict.all_gates_passed is False + + +@pytest.mark.unit +def test_witness_records_beam_not_available_instead_of_raising() -> None: + verdict = _witness(beam_availability=_beam(sbs_open=False)) + assert verdict == SafetyEnvelopeVerdict(enclosure_permitted=True, beam_available=False) + assert verdict.all_gates_passed is False + + +@pytest.mark.unit +def test_witness_every_gate_failing_records_both_false() -> None: + verdict = _witness( + referencing_enclosures=(_enclosure("NotPermitted", "Active"),), + beam_availability=_beam(quality_ok=False), + ) + assert verdict == SafetyEnvelopeVerdict(enclosure_permitted=False, beam_available=False) + + +@pytest.mark.unit +def test_witness_no_clearance_still_raises() -> None: + """Per the roadmap's rule, clearance is CORA's own aggregate, not a live + facility reading: the witnessed path refuses on it exactly as the driven + path does.""" + with pytest.raises(RunRequiresActiveClearanceError): + _witness(referencing_clearances=()) + + +@pytest.mark.unit +def test_witness_supply_present_but_none_available_still_raises() -> None: + with pytest.raises(RunSupplyCoverageMismatchError): + _witness(needed_supplies_satisfaction={"LN2": (_supply("Degraded"),)}) + + +@pytest.mark.unit +def test_check_and_witness_call_the_same_enclosure_gate_function() -> None: + """Patching `enclosure_gate_refusal` once must change what BOTH entry + points see: this is what makes 'both paths provably call the same + predicates' a structural fact rather than a claim to trust.""" + with patch.object( + safety_envelope_module, + "enclosure_gate_refusal", + return_value=RunRequiresPermittedEnclosureError(_RUN_ID, frozenset()), + ): + with pytest.raises(RunRequiresPermittedEnclosureError): + _check() + verdict = _witness() + assert verdict.enclosure_permitted is False + + +@pytest.mark.unit +def test_check_and_witness_call_the_same_beam_gate_function() -> None: + with patch.object( + safety_envelope_module, + "beam_gate_refusal", + return_value=RunBeamAvailabilityUnknownError(_RUN_ID), + ): + with pytest.raises(RunBeamAvailabilityUnknownError): + _check() + verdict = _witness() + assert verdict.beam_available is False diff --git a/apps/api/tests/unit/run/test_start_run_decider.py b/apps/api/tests/unit/run/test_start_run_decider.py index 5eeed3f3a21..3ae55f13c23 100644 --- a/apps/api/tests/unit/run/test_start_run_decider.py +++ b/apps/api/tests/unit/run/test_start_run_decider.py @@ -41,6 +41,7 @@ Wire, ) from cora.run.aggregates.run import ( + ConductMode, InvalidRunNameError, InvalidRunParametersError, Run, @@ -162,6 +163,50 @@ def test_decide_emits_run_started_for_valid_sample_run() -> None: ] +@pytest.mark.unit +def test_decide_hardcodes_conducted_regardless_of_input() -> None: + """The decider always stamps `ConductMode.CONDUCTED` on the emitted + `RunStarted`: `StartRun` carries no `conduct_mode` field for a caller to + set, so there is nothing to copy. A `Witnessed` Run is genesis-only + through the separate witnessed-genesis decider.""" + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + subject = _subject() + context = RunStartContext( + plan=plan, + subject=subject, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + new_id = uuid4() + decision = start_run.decide( + state=None, + command=StartRun( + name="Run", + plan_id=plan.id, + subject_id=subject.id, + ), + context=context, + needed_family_ids_snapshot=frozenset({cap}), + effective_parameters={}, + method_parameters_schema=None, + now=_NOW, + new_id=new_id, + ) + assert decision.run_events == [ + RunStarted( + run_id=new_id, + name="Run", + plan_id=plan.id, + subject_id=subject.id, + conduct_mode=ConductMode.CONDUCTED, + occurred_at=_NOW, + ) + ] + + @pytest.mark.unit def test_decide_emits_run_started_for_dark_field_run_without_subject() -> None: """Calibration / dark-field runs have command.subject_id=None and diff --git a/apps/api/tests/unit/test_settings.py b/apps/api/tests/unit/test_settings.py index 0ed646704f7..8b8ddcaa0f1 100644 --- a/apps/api/tests/unit/test_settings.py +++ b/apps/api/tests/unit/test_settings.py @@ -287,3 +287,84 @@ def test_settings_idempotency_lock_stale_seconds_rejects_below_one( pydantic.ValidationError, match="idempotency_lock_stale_seconds must be >= 1" ): Settings() + + +# --------------------------------------------------------------------------- +# capture_status_phases: the deployment-declared literal-to-CapturePhase map +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_settings_capture_watch_defaults_are_empty_and_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A generic boot declares no capture PVs and runs no watcher.""" + monkeypatch.delenv("CAPTURE_WATCH_PVS", raising=False) + monkeypatch.delenv("CAPTURE_STATUS_PHASES", raising=False) + monkeypatch.delenv("RUN_WITNESS_ENABLED", raising=False) + + settings = Settings() + + assert settings.capture_watch_pvs == {} + assert settings.capture_status_phases == {} + assert settings.capture_watch_probe_tick_seconds is None + assert settings.run_witness_enabled is False + + +@pytest.mark.unit +def test_settings_capture_watch_pvs_reads_role_keyed_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Outer key is the capture code, inner dict is role -> PV.""" + monkeypatch.setenv( + "CAPTURE_WATCH_PVS", + '{"2bmb-tomoscan": {"status": "2bmb:TomoScan:ScanStatus"}}', + ) + settings = Settings() + assert settings.capture_watch_pvs == {"2bmb-tomoscan": {"status": "2bmb:TomoScan:ScanStatus"}} + + +@pytest.mark.unit +def test_settings_capture_status_phases_accepts_every_real_capture_phase_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every non-UNRECOGNIZED CapturePhase value is a legal mapping target.""" + monkeypatch.setenv( + "CAPTURE_STATUS_PHASES", + '{"Beginning scan": "Begun", "Collecting projections": "Progressing", ' + '"Scan complete": "Ended", "Scan aborted": "Aborted"}', + ) + settings = Settings() + assert settings.capture_status_phases == { + "Beginning scan": "Begun", + "Collecting projections": "Progressing", + "Scan complete": "Ended", + "Scan aborted": "Aborted", + } + + +@pytest.mark.unit +def test_settings_capture_status_phases_rejects_a_value_outside_capture_phase( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A typo in the mapped-to phase must fail at boot, not classify + silently as UNRECOGNIZED until someone reads the log.""" + import pydantic + + monkeypatch.setenv("CAPTURE_STATUS_PHASES", '{"Scan complete": "Endedd"}') + with pytest.raises(pydantic.ValidationError, match="capture_status_phases has values"): + Settings() + + +@pytest.mark.unit +def test_settings_capture_status_phases_rejects_explicit_unrecognized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """UNRECOGNIZED is what an absent literal already means; mapping a + literal to it explicitly would be a second way to say the same + thing, so it is rejected rather than silently accepted.""" + import pydantic + + monkeypatch.setenv("CAPTURE_STATUS_PHASES", '{"Weird status": "Unrecognized"}') + with pytest.raises(pydantic.ValidationError, match="capture_status_phases has values"): + Settings() diff --git a/apps/api/tools/gen_record_dispositions.py b/apps/api/tools/gen_record_dispositions.py index 4c028a85ce0..6027e0a9039 100644 --- a/apps/api/tools/gen_record_dispositions.py +++ b/apps/api/tools/gen_record_dispositions.py @@ -79,6 +79,27 @@ ("SealRepublishingCompleted", "facility_code"): "facility_id", } +# A field's TYPE-DRIVEN disposition is occasionally the wrong call for +# what the field actually names, not what it happens to be declared as. +# Every entry here is a documented design decision, not a generic +# catch-all: `bool` defaults to `keep:number` (see `_SCALAR_KEEP`) because +# most booleans are operator-facing flags safe to publish whole, but +# `SafetyEnvelopeVerdict.enclosure_permitted` / `.beam_available` are a +# point-in-time reading of live PSS/interlock and beam-shutter facility +# state -- the same class of fact `EnclosurePermitObserved.from_status` / +# `.to_status` already treat as `drop:text` (dropped entirely), not +# `keep:*`. Gate-review finding (record/publishing lens, watched-genesis +# review): the two events described the same category of fact but got +# opposite export treatment purely because one used `str` and the other +# `bool`. Keeping the fields genuinely typed `bool` (correct domain +# modeling; no `str` coercion) while overriding their export disposition +# to match the precedent this table already sets for the same class of +# reading. +_OVERRIDE_DISPOSITIONS: dict[tuple[str, str], str] = { + ("SafetyEnvelopeVerdict", "enclosure_permitted"): DROP_TEXT, + ("SafetyEnvelopeVerdict", "beam_available"): DROP_TEXT, +} + _SCALAR_KEEP: Mapping[type, str] = { bool: KEEP_NUMBER, int: KEEP_NUMBER, @@ -224,13 +245,21 @@ def _resolve_fields(cls: type) -> dict[str, Any]: resolved, so an override keyed on an EVENT class name (e.g. `("CredentialRegistered", "facility_code")`) cannot accidentally apply while recursing into an unrelated nested value object that - happens to share a field name. + happens to share a field name. `_OVERRIDE_DISPOSITIONS` replaces the + type-driven classification outright, under the same recursion-safety + guarantee, for the rarer case where the type's default answer is + wrong for what the field actually names (see that dict's docstring). """ hints = typing.get_type_hints(cls) out: dict[str, Any] = {} for spec in dataclasses.fields(cls): wire_key = _OVERRIDE_WIRE_KEYS.get((cls.__name__, spec.name), spec.name) - out[wire_key] = _classify(hints[spec.name], cls.__name__, spec.name) + override = _OVERRIDE_DISPOSITIONS.get((cls.__name__, spec.name)) + out[wire_key] = ( + override + if override is not None + else _classify(hints[spec.name], cls.__name__, spec.name) + ) return out diff --git a/docs/reference/modeling.md b/docs/reference/modeling.md index d94388521c7..3f1b108a77d 100644 --- a/docs/reference/modeling.md +++ b/docs/reference/modeling.md @@ -125,7 +125,7 @@ Two structural facts already enforce most of this, so it is mostly derivation, n Orthogonal axes, do not conflate with selection: -- **Conducted vs recorded** (who drives the act): CORA's conducting engine drives either spine aggregate across the relevant port (control over `ControlPort`, compute over `ComputePort`, transfer over `TransferPort`); an externally-driven act (a scan loop a facility tool runs) is recorded. Both Runs and Procedures span both modes. +- **Conducted vs witnessed** (who drives the act): CORA's conducting engine drives either spine aggregate across the relevant port (control over `ControlPort`, compute over `ComputePort`, transfer over `TransferPort`); an externally-driven act (a scan loop a facility tool runs) is witnessed. Both Runs and Procedures span both modes. ("Recorded" is not this axis's name: every Conducted act is also recorded, in the event store and elsewhere, so it does not distinguish the two.) - **Compute** homes by the same test: a reconstruction leaves a Dataset, so it is a Run (conducted over `ComputePort`); its provenance is the Dataset's `derived_from` plus `used_calibration_ids`. - **Transfer** moves bytes onto a `Distribution` and leaves no new Dataset of record, so it is an edge job, not a spine aggregate, until a publish / custody invariant earns it one. diff --git a/infra/atlas/migrations/20260814041035_add_proj_run_summary_conduct_mode.sql b/infra/atlas/migrations/20260814041035_add_proj_run_summary_conduct_mode.sql new file mode 100644 index 00000000000..72700f02ca3 --- /dev/null +++ b/infra/atlas/migrations/20260814041035_add_proj_run_summary_conduct_mode.sql @@ -0,0 +1,14 @@ +-- Who drove this Run's act: CORA's own Conductor, or an external tool +-- CORA only observes. See cora.run.aggregates.run.state.ConductMode. +-- +-- NOT NULL DEFAULT 'Conducted': unlike the nullable snr_limit / +-- expected_observation_interval_seconds columns, "no value" is not a +-- legitimate state for this axis. Every Run genesis at the time this +-- migration was written really was Conducted (nothing in the codebase +-- constructed a Witnessed-mode Run yet), so backfilling existing rows +-- to 'Conducted' is a true fact, not a guess. Additive + forward-only; +-- immutable after genesis by aggregate-level invariant, same as +-- pinned_calibration_ids. + +ALTER TABLE proj_run_summary + ADD COLUMN conduct_mode text NOT NULL DEFAULT 'Conducted'; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index 7daeaab19f2..1586c68a9c7 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:dnh/fWUfqZPraHDnlG7pcOs20kUk3ampmAsKq9IXLmc= +h1:mlpLiUcmVMHvVR7QS4DPygTUY9ngJqkQyzR5F+aGUW8= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -163,3 +163,4 @@ h1:dnh/fWUfqZPraHDnlG7pcOs20kUk3ampmAsKq9IXLmc= 20260809190000_split_enclosure_permit_transition_and_source_times.sql h1:b+0GB3dqh0olt1eRCU0fa/j4wwvCsgrnhTjTZz262ps= 20260810000000_init_entries_enclosure_permit_probes.sql h1:AgExM2HGWE6XsXJbKyNI3/DCoy8224PH+GQjCL3itkI= 20260810120000_grant_cora_app_entries_table_access.sql h1:f8IxkQu8R7AUVyaItHjCQxDnt/nda2Y7mtRC8blqvB4= +20260814041035_add_proj_run_summary_conduct_mode.sql h1:WVbP5BfF78sLkryrrmmIMUOpetNnUfGIgsFQpZsKCOw=