From 06562816cc1887c21a1f5916106765ca75947bff Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:59:26 -0500 Subject: [PATCH 01/20] Give the Run BC a capture-observer port and hoist ReachTier to shared Rung 1 of watching a TomoScan capture live over EPICS needs the same reach-tier vocabulary the Enclosure permit-probe trail already uses, but cora.run cannot depend on cora.enclosure.aggregates. Hoist ReachTier to cora.shared (zero dependencies, any BC may use it) and re-export it from its original home so every existing import keeps working unchanged. CaptureObserver mirrors EnclosureObserver at every layer: BC-local Protocol, a closed CapturePhase vocabulary with a first-class UNRECOGNIZED member for substrate vocabulary drift, and the same dual-clock and reach-tier shape. QuietCaptureObserver yields nothing rather than a synthesized reading, since there is no safe "always" value for a capture phase the way AlwaysPermittedEnclosureObserver has one for a permit status. --- .../aggregates/enclosure/permit_probes.py | 24 +-- apps/api/src/cora/run/ports/__init__.py | 17 +- .../src/cora/run/ports/capture_observer.py | 197 ++++++++++++++++++ apps/api/src/cora/shared/reach.py | 34 +++ .../tests/unit/run/test_capture_observer.py | 97 +++++++++ 5 files changed, 349 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/cora/run/ports/capture_observer.py create mode 100644 apps/api/src/cora/shared/reach.py create mode 100644 apps/api/tests/unit/run/test_capture_observer.py 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/run/ports/__init__.py b/apps/api/src/cora/run/ports/__init__.py index dc2e7f39cc2..bec33333fb6 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 RunWatcher). 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..90c8d05d633 --- /dev/null +++ b/apps/api/src/cora/run/ports/capture_observer.py @@ -0,0 +1,197 @@ +"""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`: 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 enum import StrEnum +from typing import Protocol, runtime_checkable + +from cora.shared.reach import ReachTier + + +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" + + +@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/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/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" From 22a37135277a697ccb4cf12ce02eeac0b11b9f63 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:13:21 -0500 Subject: [PATCH 02/20] Declare the capture-watch vocabulary and settings as deployment config Rung 1 needs a place to declare which PVs to watch and what a deployment's tomoscan literal vocabulary means, without hardcoding 2-BM's status strings in the spine. capture_watch_pvs is code -> role -> PV (2-BM runs several tomoscan variants off one base class, so the outer key is the capture code); capture_status_phases maps a deployment's own ScanStatus literals onto the closed CapturePhase vocabulary, validated at boot so a typo fails loud rather than silently classifying everything as UNRECOGNIZED. CapturePhase itself gets the same hoist ReachTier already got: cora.infrastructure validates the deployment's declared table against it and cannot depend on cora.run.ports (tach), so the enum moves to cora.shared and is re-exported from its original home. Everything defaults empty/off, so a generic boot is unaffected. --- apps/api/src/cora/infrastructure/config.py | 90 +++++++++++++++++++ .../src/cora/run/ports/capture_observer.py | 43 +++------ apps/api/src/cora/shared/capture_phase.py | 34 +++++++ apps/api/tests/unit/test_settings.py | 81 +++++++++++++++++ 4 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/cora/shared/capture_phase.py diff --git a/apps/api/src/cora/infrastructure/config.py b/apps/api/src/cora/infrastructure/config.py index c62be7d08a8..19ef6fdbe0b 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,95 @@ 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) regardless of any + # other setting. Default off; irrelevant when `capture_watch_pvs` + # is empty. See `cora.api._run_watcher`. + run_watcher_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/run/ports/capture_observer.py b/apps/api/src/cora/run/ports/capture_observer.py index 90c8d05d633..f0f0bd8e7f6 100644 --- a/apps/api/src/cora/run/ports/capture_observer.py +++ b/apps/api/src/cora/run/ports/capture_observer.py @@ -19,15 +19,18 @@ ## Domain vocabulary (substrate-neutral) -- `CapturePhase`: 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. +- `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 @@ -67,32 +70,12 @@ from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass from datetime import datetime -from enum import StrEnum from typing import Protocol, runtime_checkable +from cora.shared.capture_phase import CapturePhase from cora.shared.reach import ReachTier -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" - - @dataclass(frozen=True) class CaptureObservation: """One capture-lifecycle reading from the substrate. 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/tests/unit/test_settings.py b/apps/api/tests/unit/test_settings.py index 0ed646704f7..b7eea94bc2e 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_WATCHER_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_watcher_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() From 391352c3c000df23ca5a3ca5c1cbd3b976f39663 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:30:20 -0500 Subject: [PATCH 03/20] Add the RunWatcher shadow runtime: log captures, write nothing The first slice with anything to see on the floor. RunWatcher drains a CaptureObserver and logs one event per observed phase (or the unreached/probe-only case), and does nothing else: no event store, no entries table, no Run command. This lets a deployment watch a real capture end to end, over a real substrate, before any write path exists to get wrong. Not yet wired into main.py: it takes any CaptureObserver, and the real ControlPort-backed one is the next slice. Tested here against a fake observer exercising every phase, the probe-only/unreached case, an observer that raises mid-stream, and the no-op-when-unconfigured lifespan shape. --- apps/api/src/cora/api/_run_watcher.py | 149 +++++++++++++++ apps/api/tests/unit/api/test_run_watcher.py | 195 ++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 apps/api/src/cora/api/_run_watcher.py create mode 100644 apps/api/tests/unit/api/test_run_watcher.py diff --git a/apps/api/src/cora/api/_run_watcher.py b/apps/api/src/cora/api/_run_watcher.py new file mode 100644 index 00000000000..faeb1365859 --- /dev/null +++ b/apps/api/src/cora/api/_run_watcher.py @@ -0,0 +1,149 @@ +"""RunWatcher runtime: shadow-observe an external tool's captures. + +Background loop draining a `CaptureObserver` and logging each +observation's classified phase. SHADOW ONLY: this module writes +nothing anywhere, ever, by construction, regardless of any Settings +flag. There is no event append, no entries-table write, and no Run +command issued from this file. Promoting from shadow to a real +recorded Run genesis is deliberately a separate, not-yet-built module: +shadow mode exists so a deployment can watch a real capture end to end, +over a real substrate, before any write path exists to get wrong. + +Hosted at the composition root (`cora.api`), like `_run_initiator.py` +and `_enclosure_permit_observer.py`: it will later compose a Run BC +command with an Agent principal, and only `cora.api` may depend on both. + +## Log lines, one per observation + +- `run_watcher.capture_begun` +- `run_watcher.capture_progressing` +- `run_watcher.capture_ended` +- `run_watcher.capture_aborted` +- `run_watcher.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_watcher.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). + +## 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. Shadow mode makes no claim anything +depends on, 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 cora.infrastructure.logging import get_logger +from cora.run.ports.capture_observer import CaptureObserverScope, CapturePhase + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from cora.run.ports.capture_observer import CaptureObservation, CaptureObserver + +_RECONNECT_DELAY_SECONDS = 5.0 + +_log = get_logger(__name__) + +_PHASE_LOG_EVENT: dict[CapturePhase, str] = { + CapturePhase.BEGUN: "run_watcher.capture_begun", + CapturePhase.PROGRESSING: "run_watcher.capture_progressing", + CapturePhase.ENDED: "run_watcher.capture_ended", + CapturePhase.ABORTED: "run_watcher.capture_aborted", + CapturePhase.UNRECOGNIZED: "run_watcher.capture_unrecognized", +} + + +def observe_capture(observation: CaptureObservation) -> None: + """Log one observation. The entire body of shadow mode: no writes.""" + if observation.phase is None: + event = "run_watcher.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, + ) + + +async def run_run_watcher( + *, + observer: CaptureObserver, + capture_codes: frozenset[str], + reconnect_delay_seconds: float = _RECONNECT_DELAY_SECONDS, +) -> None: + """Drain the observer, logging 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: + observe_capture(observation) + except asyncio.CancelledError: + raise + except Exception: + _log.exception( + "run_watcher.record_failed", + capture_code=observation.capture_code, + ) + except asyncio.CancelledError: + raise + except Exception: + _log.exception("run_watcher.iteration_failed") + await asyncio.sleep(reconnect_delay_seconds) + + +@contextlib.asynccontextmanager +async def run_watcher_lifespan( + *, + observer: CaptureObserver, + capture_codes: frozenset[str], +) -> AsyncGenerator[None]: + """Run the shadow 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. + """ + if not capture_codes: + yield + return + task = asyncio.create_task( + run_run_watcher(observer=observer, capture_codes=capture_codes), + name="run-watcher", + ) + try: + yield + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +__all__ = ["observe_capture", "run_run_watcher", "run_watcher_lifespan"] diff --git a/apps/api/tests/unit/api/test_run_watcher.py b/apps/api/tests/unit/api/test_run_watcher.py new file mode 100644 index 00000000000..e4f0aab3d51 --- /dev/null +++ b/apps/api/tests/unit/api/test_run_watcher.py @@ -0,0 +1,195 @@ +"""Tests for the RunWatcher shadow runtime (cora.api._run_watcher). + +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 + +import pytest +import structlog.testing + +from cora.api._run_watcher import observe_capture, run_run_watcher, run_watcher_lifespan +from cora.run.ports.capture_observer import CaptureObservation, CaptureObserverScope, CapturePhase +from cora.shared.reach import ReachTier + +_CODE = "2bmb-tomoscan" +_NOW = datetime(2026, 8, 13, 12, 0, 0, tzinfo=UTC) + + +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_watcher.capture_begun"), + (CapturePhase.PROGRESSING, "run_watcher.capture_progressing"), + (CapturePhase.ENDED, "run_watcher.capture_ended"), + (CapturePhase.ABORTED, "run_watcher.capture_aborted"), + (CapturePhase.UNRECOGNIZED, "run_watcher.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_watcher.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_run_watcher_is_a_no_op_for_empty_capture_codes() -> None: + observer = _FakeObserver([_obs(reported_status="Scan complete", phase=CapturePhase.ENDED)]) + await run_run_watcher(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_run_watcher_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_run_watcher(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_watcher.capture_begun", + "run_watcher.capture_progressing", + "run_watcher.capture_ended", + ] + + +@pytest.mark.unit +async def test_run_run_watcher_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_run_watcher( + 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_watcher.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_watcher_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_watcher_lifespan(observer=observer, capture_codes=frozenset({_CODE})): + await asyncio.sleep(0.02) + events = [entry["event"] for entry in logs] + assert "run_watcher.capture_ended" in events From bda240ba9271822ca87dae8f171aa055a0ce87b5 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:36:08 -0500 Subject: [PATCH 04/20] Wire RunWatcher to a real ControlPort-backed capture observer ControlPortCaptureObserver bridges the Run BC's CaptureObserver port to the Operation BC's ControlPort at the composition root, mirroring ControlPortEnclosureObserver at every layer: sibling poll tasks feeding a shared queue, the same _PumpDone-counted drain, the same queue-drain race fix on teardown. One deliberate inversion from the Enclosure precedent, load-bearing enough to be its own module docstring section: the Enclosure adapter synthesizes an Unknown STATUS on disconnect so the run-start gate fails closed. There is no such gate here, so a disconnect carries no status claim and no phase at all, the same shape a probe-only poll tick already uses. Reading a disconnect as a real terminal would fabricate one the substrate never reported. Wired into main.py's lifespan behind run_watcher_enabled, a separate gate from capture_watch_pvs being configured, so a deployment can declare its PVs ahead of turning the watcher on. Still shadow only: nothing this slice adds can write anything. --- apps/api/src/cora/api/_capture_observer.py | 237 ++++++++++++ apps/api/src/cora/api/main.py | 24 ++ .../tests/unit/api/test_capture_observer.py | 353 ++++++++++++++++++ 3 files changed, 614 insertions(+) create mode 100644 apps/api/src/cora/api/_capture_observer.py create mode 100644 apps/api/tests/unit/api/test_capture_observer.py 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/main.py b/apps/api/src/cora/api/main.py index 858b85b8fca..2bca2c79c97 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -75,6 +75,7 @@ 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 @@ -91,6 +92,7 @@ ) from cora.api._run_initiator import run_initiator_lifespan from cora.api._run_supervisor import run_supervisor_lifespan +from cora.api._run_watcher import run_watcher_lifespan from cora.api.middleware import BodySizeLimitMiddleware from cora.api.protected_resource_metadata import register_protected_resource_metadata_route from cora.budget import ( @@ -1012,6 +1014,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: tick_seconds=settings.enclosure_permit_probe_tick_seconds, ) + # RunWatcher: shadow-observe an external tool's captures (2-BM + # commissioning ladder rung 1). SHADOW ONLY: writes nothing, + # regardless of settings. run_watcher_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. + 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_watcher_enabled + else frozenset() + ) + try: async with ( projection_worker_lifespan(deps, registry, settings), @@ -1062,6 +1082,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: deps, list_campaigns=app.state.campaign.list_campaigns, ), + run_watcher_lifespan( + observer=capture_watch_observer, + capture_codes=capture_watch_codes, + ), ): yield finally: 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" From 29e5d6828e93acc32630a07d3cbf9899c3147c05 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:40:45 -0500 Subject: [PATCH 05/20] Reify who drove a Run's act: ConductMode (slice 5) docs/reference/modeling.md already named "Conducted vs recorded (who drives the act)" as an axis orthogonal to the Run/Procedure boundary, but nothing encoded it. Adds ConductMode (CONDUCTED / RECORDED) to Run's genesis, defaulting to CONDUCTED since every StartRun caller today is CORA's own Conductor. Nothing yet constructs a RECORDED Run: that is slice 8's not-yet-built promotion from RunWatcher's shadow observations into a real recorded genesis. This slice only gives that future work a field to import and pass RECORDED into, instead of inventing the vocabulary under gate-review pressure later. Pure additive threading: RunStarted event, Run state, the evolver's per-arm preservation, StartRun command, decider, and the proj_run_summary read model (new migration). No change to check_safety_envelope (its gates apply uniformly regardless of who drove the act) and no Procedure-side mirror (no second consumer yet). Co-Authored-By: Claude Sonnet 5 --- .../record_export/_dispositions.py | 1 + .../src/cora/infrastructure/schema_version.py | 2 +- .../src/cora/run/aggregates/run/__init__.py | 2 + .../api/src/cora/run/aggregates/run/events.py | 18 ++++++- .../src/cora/run/aggregates/run/evolver.py | 35 +++++++++++++ apps/api/src/cora/run/aggregates/run/state.py | 38 ++++++++++++++ .../cora/run/features/start_run/command.py | 14 +++++ .../cora/run/features/start_run/decider.py | 1 + apps/api/src/cora/run/projections/summary.py | 22 +++++--- .../test_run_conduct_mode_always_declared.py | 51 +++++++++++++++++++ apps/api/tests/unit/run/test_run_events.py | 6 +++ apps/api/tests/unit/run/test_run_evolver.py | 33 ++++++++++++ .../unit/run/test_run_summary_projection.py | 30 +++++++++++ .../tests/unit/run/test_start_run_decider.py | 46 +++++++++++++++++ ...1035_add_proj_run_summary_conduct_mode.sql | 13 +++++ infra/atlas/migrations/atlas.sum | 3 +- 16 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 apps/api/tests/architecture/test_run_conduct_mode_always_declared.py create mode 100644 infra/atlas/migrations/20260814041035_add_proj_run_summary_conduct_mode.sql diff --git a/apps/api/src/cora/infrastructure/record_export/_dispositions.py b/apps/api/src/cora/infrastructure/record_export/_dispositions.py index 61be2a414df..9158e50e3f6 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", 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..f2ac3dc0951 100644 --- a/apps/api/src/cora/run/aggregates/run/__init__.py +++ b/apps/api/src/cora/run/aggregates/run/__init__.py @@ -73,6 +73,7 @@ RUN_PINNED_CALIBRATIONS_MAX_ENTRIES, SAMPLING_PROCEDURE_VALUES, ChannelName, + ConductMode, InvalidChannelNameError, InvalidInputDatasetsError, InvalidObservationValueError, @@ -148,6 +149,7 @@ "SAMPLING_PROCEDURE_VALUES", "CautionAcknowledgement", "ChannelName", + "ConductMode", "DecisionDebriefRequested", "FeedHeartbeat", "FeedHeartbeatStore", diff --git a/apps/api/src/cora/run/aggregates/run/events.py b/apps/api/src/cora/run/aggregates/run/events.py index e3c76ec4b27..915bb84f949 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 from cora.shared.identity import ActorId from cora.shared.logbook import LogbookSchema @@ -197,6 +198,16 @@ 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. Declared explicitly by + # `StartRun.conduct_mode`, never inferred; the decider copies it + # verbatim. Defaults to CONDUCTED: every StartRun caller today is + # Conducted, so the default declares the closed, currently-total set + # of reality rather than guessing a live signal. Forward-compat via + # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)` in + # `from_stored` for legacy streams without the key. + conduct_mode: ConductMode = ConductMode.CONDUCTED 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 +753,7 @@ def to_payload(event: RunEvent) -> dict[str, Any]: name=name, plan_id=plan_id, subject_id=subject_id, + conduct_mode=conduct_mode, raid=raid, override_parameters=override_parameters, effective_parameters=effective_parameters, @@ -759,6 +771,7 @@ 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, "raid": raid, "override_parameters": override_parameters, "effective_parameters": effective_parameters, @@ -993,7 +1006,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`, @@ -1010,6 +1023,9 @@ def _build_run_started() -> RunStarted: 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) + ), 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/state.py b/apps/api/src/cora/run/aggregates/run/state.py index 0c61bb23a94..d7ba4b3a6cf 100644 --- a/apps/api/src/cora/run/aggregates/run/state.py +++ b/apps/api/src/cora/run/aggregates/run/state.py @@ -276,6 +276,36 @@ 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). `RECORDED` is an externally-driven act CORA only + observes after the fact, for example a 2-BM tomoscan scan watched + by `RunWatcher` (see `cora.api._run_watcher`). Nothing + yet constructs a `RECORDED` Run: promoting a shadow observation + into one is deliberately a separate, not-yet-built module (see + `_run_watcher.py`'s own docstring). + + 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" + RECORDED = "Recorded" + + class InvalidRunNameError(ValueError): """The supplied name is empty, whitespace-only, or too long.""" @@ -1292,6 +1322,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/start_run/command.py b/apps/api/src/cora/run/features/start_run/command.py index 3e717013231..0989c37dd77 100644 --- a/apps/api/src/cora/run/features/start_run/command.py +++ b/apps/api/src/cora/run/features/start_run/command.py @@ -7,6 +7,10 @@ existence verified at handler-load time) - `subject_id` — the Subject being measured, or None for calibration / dark-field runs + - `conduct_mode`: who drove this act, CORA's own Conductor + (`Conducted`) or an external tool CORA only observes + (`Recorded`). Defaults to `Conducted`, true of every caller + today; see the field's own comment for the RECORDED-mode plan. - `raid` — Research Activity Identifier (ISO 23527) of the research activity this Run belongs to. Optional; opaque string carried verbatim. RAiD is project/activity scoped: one RAiD is @@ -41,6 +45,7 @@ from typing import Any from uuid import UUID +from cora.run.aggregates.run.state import ConductMode from cora.shared.identifier import Identifier @@ -65,6 +70,15 @@ class StartRun: name: str plan_id: UUID subject_id: UUID | None + # who drove this act: CORA's own Conductor, or an external tool CORA + # only observes. Defaults to CONDUCTED: the only production caller + # today (`cora.api._run_initiator`) is always Conducted, so the + # default declares the closed, currently-total set of reality rather + # than guessing a live signal. A future RECORDED-mode genesis (the + # not-yet-built shadow-to-real promotion, see `cora.api._run_watcher`) + # is new code passing this explicitly, never an old caller inheriting + # the default silently. See `ConductMode`'s own docstring. + conduct_mode: ConductMode = ConductMode.CONDUCTED raid: str | None = None override_parameters: dict[str, Any] = field(default_factory=dict[str, Any]) trigger_source: str | None = None 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..70566421f7d 100644 --- a/apps/api/src/cora/run/features/start_run/decider.py +++ b/apps/api/src/cora/run/features/start_run/decider.py @@ -399,6 +399,7 @@ def decide( name=name.value, plan_id=command.plan_id, subject_id=command.subject_id, + conduct_mode=command.conduct_mode, raid=command.raid, override_parameters=command.override_parameters, effective_parameters=effective_parameters, 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/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..ca38e59a383 --- /dev/null +++ b/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py @@ -0,0 +1,51 @@ +"""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. Two +properties make "declared, never inferred" a build-time guarantee rather +than a convention someone can forget: + + - The enum stays closed to exactly {CONDUCTED, RECORDED}. A third member + added without a design decision (see docs/reference/modeling.md's + "Conducted vs recorded" framing) would silently widen what the axis + claims to mean. + - `conduct_mode` is never `Optional`/nullable on `RunStarted`, `Run`, or + `StartRun`. 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.) +""" + +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", "RECORDED"}, ( + f"ConductMode grew a member beyond {{CONDUCTED, RECORDED}}: {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 recorded' framing before widening this enum." + ) + + +@pytest.mark.architecture +@pytest.mark.parametrize("carrier", [RunStarted, Run, StartRun], 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." + ) diff --git a/apps/api/tests/unit/run/test_run_events.py b/apps/api/tests/unit/run/test_run_events.py index 55f116b86ea..66bd3639937 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,10 @@ 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", "raid": None, # Additive payload fields default to {} / None when not # supplied; legacy stored events stay forward-compat via @@ -208,6 +213,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..4d00540e01b 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_recorded_conduct_mode() -> None: + """A RECORDED genesis (the not-yet-built shadow-promotion path) must + declare its mode explicitly; the evolver copies it verbatim, never + computing or guessing it.""" + state = evolve(None, _run_started(conduct_mode=ConductMode.RECORDED)) + assert state.conduct_mode is ConductMode.RECORDED + + +@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.RECORDED) + 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.RECORDED + + @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..d43655428b9 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_recorded_conduct_mode_inserts_recorded() -> None: + """A RECORDED-mode RunStarted payload (the not-yet-built shadow- + promotion path's future 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": "watched-scan", + "plan_id": str(_PLAN_ID), + "subject_id": None, + "conduct_mode": "Recorded", + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[12] == "Recorded" 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..8385207c2c3 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,51 @@ def test_decide_emits_run_started_for_valid_sample_run() -> None: ] +@pytest.mark.unit +def test_decide_copies_conduct_mode_from_command_unmodified() -> None: + """The decider copies `StartRun.conduct_mode` verbatim onto the emitted + `RunStarted` — it must never compute or guess the value itself. Explicit + RECORDED (the not-yet-built shadow-promotion path's future call shape) + reaches the event exactly as declared.""" + 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, + conduct_mode=ConductMode.RECORDED, + ), + 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.RECORDED, + 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/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..493e2bc6e05 --- /dev/null +++ b/infra/atlas/migrations/20260814041035_add_proj_run_summary_conduct_mode.sql @@ -0,0 +1,13 @@ +-- 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 to date really was +-- Conducted (nothing in the codebase constructs a Recorded-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..d4aa4f58e3e 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:dnh/fWUfqZPraHDnlG7pcOs20kUk3ampmAsKq9IXLmc= +h1:VAd2LX0bZJy/3clCYhf/ux2PAKI9cn4BriMCeDij7Uc= 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:i34cwORg/LmkqEcT0O5xhE0z6geK91cJr0GtCiNcrLg= From e1e9e028d9ad183a6dc71a28817ab82499d00b31 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:03:10 -0500 Subject: [PATCH 06/20] Widen the pilot seed ceremony to register the 2-BM recipe ladder (slice 6) start_run needs a real plan_id (Plan -> Practice -> Method -> Capability); nothing in that chain had ever been registered for 2-BM, so a real Run would 404 with PlanNotFoundError on its first precondition. Checking what actually exists on arcturus also surfaced that the dark_field/flat_field recipes target StationShutter and the 2bmSP1: camera, neither of which was registered either (only the beamline root and the 31MP Camera_HighRes existed). Extends pilot_seed.py, the existing 2-BM ceremony pattern, rather than inventing a second mechanism: registers the two missing Assets, then the Capability/Method/Practice/Plan chain for dark_field and flat_field (both "conductible today" per recipes.md), bound to those Assets. Factored a shared seed_genesis helper for the four Recipe BC aggregates, since a fourth near-identical closure alongside Asset's and Supply's own crossed the rule-of-three the file's existing pattern otherwise avoids. 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. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/cora/api/pilot_seed.py | 429 ++++++++++++++++-- .../integration/test_pilot_seed_postgres.py | 86 +++- apps/api/tests/unit/api/test_pilot_seed.py | 75 +++ 3 files changed, 536 insertions(+), 54 deletions(-) diff --git a/apps/api/src/cora/api/pilot_seed.py b/apps/api/src/cora/api/pilot_seed.py index 337b5290a9d..7ef32be1331 100644 --- a/apps/api/src/cora/api/pilot_seed.py +++ b/apps/api/src/cora/api/pilot_seed.py @@ -1,16 +1,29 @@ -"""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), plus the Capability -> Method -> Practice -> Plan chain + for those same two recipes, bound to the two new Assets -- what + `start_run` needs. `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. ## Identity is per-aggregate, matching each aggregate's locked design @@ -53,7 +66,9 @@ 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.equipment._bootstrap import bootstrap_equipment, bootstrap_families @@ -87,6 +102,29 @@ 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.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.shared.facility_code import FacilityCode from cora.shared.identity import ActorId from cora.supply._projections import register_supply_projections @@ -99,23 +137,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 +196,8 @@ async def seed_pilot_beamline( camera_family_name: str, supply_name: str, dry_run: bool, + shutter_name: str = "StationShutter", + acquisition_camera_name: str = "AcquisitionCamera", database_url: str | None = None, ) -> int: """Run the ceremony. `database_url` overrides the Settings value so @@ -293,37 +351,88 @@ 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(), - ) - 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, + 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, ) - report.note("seeded", f"{camera_name} family attachment") + 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 ----- + shutter_id = asset_seed_id(facility_code, beamline, shutter_name) + acquisition_camera_id = asset_seed_id(facility_code, beamline, acquisition_camera_name) + + shutter = await seed_asset( + shutter_id, + RegisterAsset( + name=shutter_name, + tier=AssetTier.DEVICE, + parent_id=root_id, + facility_code=None, + ), + f"asset {shutter_name} (Device)", + ) + acquisition_camera = await seed_asset( + acquisition_camera_id, + RegisterAsset( + name=acquisition_camera_name, + tier=AssetTier.DEVICE, + parent_id=root_id, + facility_code=None, + ), + f"asset {acquisition_camera_name} (Device)", + ) + + 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") + + await attach_family(shutter, shutter_id, shutter_family_id, shutter_name) + await attach_family( + acquisition_camera, acquisition_camera_id, family_id, acquisition_camera_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) # Storage supply: minted id, address-pre-checked idempotency. supplies_by_kind = await kernel.supply_lookup.find_supplies_by_kind( @@ -425,6 +534,219 @@ 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), + ) + + # Both recipes 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). + recipe_family_ids = frozenset({shutter_family_id, family_id}) + + async def seed_acquisition_recipe( + method_name: str, practice_name: str, plan_name: str + ) -> None: + 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=recipe_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 + context = PlanBindingContext( + practice=practice, + method=method, + assets={ + shutter_id: shutter, + acquisition_camera_id: acquisition_camera, + }, + capability=capability, + family_affordances={ + shutter_family_id: shutter_family.affordances, + family_id: family.affordances, + }, + ) + return decide_plan( + state=None, + command=DefinePlan( + name=plan_name, + practice_id=practice_id, + asset_ids=frozenset({shutter_id, acquisition_camera_id}), + ), + 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 + + await seed_acquisition_recipe( + "dark_field", "2BM_dark_field_practice", "2BM_dark_field_plan" + ) + await seed_acquisition_recipe( + "flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan" + ) + _ = root if not dry_run: # Leave the projections current so a re-run's supply @@ -479,10 +801,14 @@ 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. " - "Idempotent; re-runs report and change nothing." + "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, 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." ), ) parser.add_argument("--facility-code", default="cora") @@ -491,6 +817,13 @@ 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("--dry-run", action="store_true") return parser @@ -505,6 +838,8 @@ 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, dry_run=args.dry_run, ) ) diff --git a/apps/api/tests/integration/test_pilot_seed_postgres.py b/apps/api/tests/integration/test_pilot_seed_postgres.py index fe615020baa..02bfa88d338 100644 --- a/apps/api/tests/integration/test_pilot_seed_postgres.py +++ b/apps/api/tests/integration/test_pilot_seed_postgres.py @@ -1,12 +1,17 @@ """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 @@ -23,7 +28,7 @@ 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 @@ -32,6 +37,10 @@ ) 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 +53,8 @@ _FACILITY = "cora" _BEAMLINE = "2-bm" _CAMERA = "Camera" +_SHUTTER = "StationShutter" +_ACQUISITION_CAMERA = "AcquisitionCamera" @pytest_asyncio.fixture @@ -122,6 +133,23 @@ 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), + 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, "practice", "2BM_dark_field_practice"), + recipe_seed_id(_FACILITY, _BEAMLINE, "practice", "2BM_flat_field_practice"), + recipe_seed_id(_FACILITY, _BEAMLINE, "plan", "2BM_dark_field_plan"), + recipe_seed_id(_FACILITY, _BEAMLINE, "plan", "2BM_flat_field_plan"), + ] + 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 +162,50 @@ async def test_seeded_camera_carries_capturing_through_the_real_lookup( assert "Capturing" in asset.family_affordances +async def test_seeded_ladder_resolves_for_both_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 both Plans without + raising, and each Plan binds exactly the StationShutter + the + acquisition camera -- the two Assets `docs/deployments/2-bm/ + recipes.md`'s dark_field / flat_field recipes actually target.""" + 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, _SHUTTER) + acquisition_camera_id = asset_seed_id(_FACILITY, _BEAMLINE, _ACQUISITION_CAMERA) + + for method_name, practice_name, plan_name in ( + ("dark_field", "2BM_dark_field_practice", "2BM_dark_field_plan"), + ("flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan"), + ): + 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 == frozenset({shutter_id, acquisition_camera_id}) + + 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/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" From 7b3c16881f6bb476670199aa96dd5d208abe8252 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:10:00 -0500 Subject: [PATCH 07/20] Record the camera's enclosure location, migrating slice 6's Assets (slice 7) located_in_enclosure_id is genesis-only (no update command exists), and a Run whose scoped Assets declare none is Permit-by-default per check_safety_envelope: leaving it unset on slice 6's StationShutter and Camera was a silent Enclosure-permit gate skip, not a cosmetic gap. Fixing it on already-registered Assets means decommission + re-register (the Lock A rebind path already used for controller_id), which means the two Plans binding them by id must be replaced too -- Capability, Method, and Practice reference no specific Asset id, so none of those three change. This is a one-time migration, permanent in pilot_seed.py from here on: the old StationShutter/Camera are decommissioned if present (no-op on a fresh deployment, which goes straight to the located _v2 registration), the old Plans are deprecated (hygiene; start_run already refuses a Plan bound to a Decommissioned asset regardless), and new _v2 Assets/Plans are registered with located_in_enclosure_id resolved by name via the same seed_enclosures() the real app's boot lifespan already uses -- never a hardcoded id, since Enclosure ids are minted at boot, not deterministic. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/cora/api/pilot_seed.py | 219 ++++++++++++++++-- .../integration/test_pilot_seed_postgres.py | 31 ++- 2 files changed, 220 insertions(+), 30 deletions(-) diff --git a/apps/api/src/cora/api/pilot_seed.py b/apps/api/src/cora/api/pilot_seed.py index 7ef32be1331..1a360fa4b7b 100644 --- a/apps/api/src/cora/api/pilot_seed.py +++ b/apps/api/src/cora/api/pilot_seed.py @@ -19,11 +19,28 @@ - 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), plus the Capability -> Method -> Practice -> Plan chain - for those same two recipes, bound to the two new Assets -- what - `start_run` needs. `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. + 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 @@ -36,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. @@ -49,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. """ @@ -71,6 +94,9 @@ 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 @@ -82,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 @@ -113,6 +142,7 @@ 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 @@ -125,6 +155,9 @@ 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 @@ -224,12 +257,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 @@ -237,6 +272,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) @@ -249,6 +291,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 @@ -392,8 +443,76 @@ async def attach_family( # ----- Recipe BC prerequisite Assets: what dark_field / flat_field # actually target, per docs/deployments/2-bm/recipes.md ----- - shutter_id = asset_seed_id(facility_code, beamline, shutter_name) - acquisition_camera_id = asset_seed_id(facility_code, beamline, acquisition_camera_name) + + 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, + ) + 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, @@ -402,8 +521,9 @@ async def attach_family( tier=AssetTier.DEVICE, parent_id=root_id, facility_code=None, + located_in_enclosure_id=enclosure_b_id, ), - f"asset {shutter_name} (Device)", + f"asset {shutter_name} (Device, 2-BM-B)", ) acquisition_camera = await seed_asset( acquisition_camera_id, @@ -412,8 +532,9 @@ async def attach_family( tier=AssetTier.DEVICE, parent_id=root_id, facility_code=None, + located_in_enclosure_id=enclosure_b_id, ), - f"asset {acquisition_camera_name} (Device)", + f"asset {acquisition_camera_name} (Device, 2-BM-B)", ) shutter_family_id = family_stream_id(FamilyName("Shutter")) @@ -740,11 +861,59 @@ def build_plan_events() -> list[Any]: ) _ = 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" + "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" + "flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan_v2" ) _ = root @@ -805,10 +974,10 @@ def build_parser() -> argparse.ArgumentParser: "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, 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." + "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." ), ) parser.add_argument("--facility-code", default="cora") diff --git a/apps/api/tests/integration/test_pilot_seed_postgres.py b/apps/api/tests/integration/test_pilot_seed_postgres.py index 02bfa88d338..667ce58dfea 100644 --- a/apps/api/tests/integration/test_pilot_seed_postgres.py +++ b/apps/api/tests/integration/test_pilot_seed_postgres.py @@ -14,7 +14,7 @@ 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 @@ -35,6 +35,7 @@ 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 @@ -57,6 +58,17 @@ _ACQUISITION_CAMERA = "AcquisitionCamera" +@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 async def seed_database( postgres_container: PostgresContainer, @@ -177,12 +189,21 @@ async def test_seeded_ladder_resolves_for_both_acquisition_recipes( 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, _SHUTTER) - acquisition_camera_id = asset_seed_id(_FACILITY, _BEAMLINE, _ACQUISITION_CAMERA) + shutter_id = asset_seed_id(_FACILITY, _BEAMLINE, f"{_SHUTTER}_v2") + acquisition_camera_id = asset_seed_id(_FACILITY, _BEAMLINE, f"{_ACQUISITION_CAMERA}_v2") + + 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): + asset = await PostgresAssetLookup(pool).lookup(asset_id) + assert asset is not None + assert asset.located_in_enclosure_id == enclosure_b.enclosure_id for method_name, practice_name, plan_name in ( - ("dark_field", "2BM_dark_field_practice", "2BM_dark_field_plan"), - ("flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan"), + ("dark_field", "2BM_dark_field_practice", "2BM_dark_field_plan_v2"), + ("flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan_v2"), ): method_id = recipe_seed_id(_FACILITY, _BEAMLINE, "method", method_name) practice_id = recipe_seed_id(_FACILITY, _BEAMLINE, "practice", practice_name) From 36565495ce9bba025c247b93e45104bedcf198d5 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:27:57 -0500 Subject: [PATCH 08/20] Make ConductMode a property of which decider ran, not a caller's choice (slice 5 correction) Slice 5 shipped two gaps against its own roadmap spec, both latent only because nothing yet constructs a Recorded Run. StartRun carried a caller-settable conduct_mode that the driven decider copied verbatim, letting any caller of the driven path claim Recorded while taking the enforcing path: the exact laundering hole the axis exists to close. Separately, none of the three standing runtimes (RunSupervisor's hold FSM, its liveness/truncate/signal rules, RunInitiator's in-flight cap) distinguished conduct_mode at all, so enabling any of them against a mix of driven and watched Runs would try to hold, truncate, or otherwise act on a Run an external tool actually controls. StartRun now carries no conduct_mode field; the driven decider hardcodes CONDUCTED. list_runs surfaces conduct_mode so the runtimes have something to filter on. The RunSupervisor filters watched Runs out of its running/held population once, upstream of every mechanism, before the hold pass, liveness check, or Rule Q/R signal observation ever sees them. The RunInitiator's max_in_flight count is left unchanged and documented: it deliberately counts every Run regardless of conduct_mode, because the cap serializes access to shared single-stage hardware a watched capture is actively occupying. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/cora/api/_run_initiator.py | 9 +++ apps/api/src/cora/api/_run_supervisor.py | 9 +++ .../api/src/cora/run/aggregates/run/events.py | 14 ++-- .../cora/run/features/list_runs/handler.py | 8 ++- .../cora/run/features/start_run/command.py | 22 +++--- .../cora/run/features/start_run/decider.py | 8 ++- .../test_run_conduct_mode_always_declared.py | 39 ++++++++--- .../scenarios/test_2bm_run_initiator_tick.py | 67 +++++++++++++++++++ .../api/tests/unit/api/test_run_supervisor.py | 59 ++++++++++++++++ .../tests/unit/run/test_start_run_decider.py | 13 ++-- 10 files changed, 208 insertions(+), 40 deletions(-) diff --git a/apps/api/src/cora/api/_run_initiator.py b/apps/api/src/cora/api/_run_initiator.py index 76e4fe807ff..ca938b8f9c4 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 watched (Recorded) Run because CORA + does not control it, this cap exists to serialize access to shared + single-stage hardware. A watched 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..23e9c64a214 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 + # Watched (Recorded) 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/run/aggregates/run/events.py b/apps/api/src/cora/run/aggregates/run/events.py index 915bb84f949..32af57b3b8a 100644 --- a/apps/api/src/cora/run/aggregates/run/events.py +++ b/apps/api/src/cora/run/aggregates/run/events.py @@ -200,13 +200,13 @@ class RunStarted: 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. Declared explicitly by - # `StartRun.conduct_mode`, never inferred; the decider copies it - # verbatim. Defaults to CONDUCTED: every StartRun caller today is - # Conducted, so the default declares the closed, currently-total set - # of reality rather than guessing a live signal. Forward-compat via - # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)` in - # `from_stored` for legacy streams without the key. + # .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 watched genesis + # (the separate decider) hardcodes RECORDED. 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 raid: str | None = None override_parameters: dict[str, Any] = field(default_factory=dict[str, Any]) 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..a3531a3e62a 100644 --- a/apps/api/src/cora/run/features/list_runs/handler.py +++ b/apps/api/src/cora/run/features/list_runs/handler.py @@ -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 "Recorded"), immutable from + genesis. The RunSupervisor and RunInitiator runtimes filter on this + to skip watched (Recorded) 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"]), ) 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 0989c37dd77..4e785a74de3 100644 --- a/apps/api/src/cora/run/features/start_run/command.py +++ b/apps/api/src/cora/run/features/start_run/command.py @@ -7,10 +7,6 @@ existence verified at handler-load time) - `subject_id` — the Subject being measured, or None for calibration / dark-field runs - - `conduct_mode`: who drove this act, CORA's own Conductor - (`Conducted`) or an external tool CORA only observes - (`Recorded`). Defaults to `Conducted`, true of every caller - today; see the field's own comment for the RECORDED-mode plan. - `raid` — Research Activity Identifier (ISO 23527) of the research activity this Run belongs to. Optional; opaque string carried verbatim. RAiD is project/activity scoped: one RAiD is @@ -35,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 `Recorded` Run is genesis-only through the +separate watched-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), @@ -45,7 +49,6 @@ from typing import Any from uuid import UUID -from cora.run.aggregates.run.state import ConductMode from cora.shared.identifier import Identifier @@ -70,15 +73,6 @@ class StartRun: name: str plan_id: UUID subject_id: UUID | None - # who drove this act: CORA's own Conductor, or an external tool CORA - # only observes. Defaults to CONDUCTED: the only production caller - # today (`cora.api._run_initiator`) is always Conducted, so the - # default declares the closed, currently-total set of reality rather - # than guessing a live signal. A future RECORDED-mode genesis (the - # not-yet-built shadow-to-real promotion, see `cora.api._run_watcher`) - # is new code passing this explicitly, never an old caller inheriting - # the default silently. See `ConductMode`'s own docstring. - conduct_mode: ConductMode = ConductMode.CONDUCTED raid: str | None = None override_parameters: dict[str, Any] = field(default_factory=dict[str, Any]) trigger_source: str | None = None 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 70566421f7d..3ef0489f712 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,7 +400,12 @@ def decide( name=name.value, plan_id=command.plan_id, subject_id=command.subject_id, - conduct_mode=command.conduct_mode, + # 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 Recorded Run is genesis-only through the separate + # watched-genesis decider. + conduct_mode=ConductMode.CONDUCTED, raid=command.raid, override_parameters=command.override_parameters, effective_parameters=effective_parameters, 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 index ca38e59a383..fda7b3322a4 100644 --- a/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py +++ b/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py @@ -1,20 +1,28 @@ """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. Two -properties make "declared, never inferred" a build-time guarantee rather -than a convention someone can forget: +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, RECORDED}. A third member added without a design decision (see docs/reference/modeling.md's "Conducted vs recorded" framing) would silently widen what the axis claims to mean. - - `conduct_mode` is never `Optional`/nullable on `RunStarted`, `Run`, or - `StartRun`. 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.) + - `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 + `Recorded` Run is genesis-only through the separate watched-genesis + slice. A field on `StartRun` would let any caller of the driven path + claim `Recorded` while taking the enforcing path, which is exactly + the laundering hole this axis exists to close. """ from __future__ import annotations @@ -40,7 +48,7 @@ def test_conduct_mode_has_exactly_two_members() -> None: @pytest.mark.architecture -@pytest.mark.parametrize("carrier", [RunStarted, Run, StartRun], ids=lambda c: c.__name__) +@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" @@ -49,3 +57,14 @@ def test_conduct_mode_field_is_never_optional(carrier: type) -> None: "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 Recorded while taking the enforcing path." + ) 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..31ff2e94e19 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_watched_recorded_run_toward_max_in_flight( + db_pool: asyncpg.Pool, +) -> None: + """A watched (Recorded) 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 + watched 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) + + watched_run_id = uuid4() + watched = RunStarted( + run_id=watched_run_id, + name="watched capture", + plan_id=_PLAN_ID, + subject_id=None, + occurred_at=_NOW, + conduct_mode=ConductMode.RECORDED, + ) + await deps.event_store.append( + stream_type="Run", + stream_id=watched_run_id, + expected_version=0, + events=[ + to_new_event( + event_type=run_event_type_name(watched), + payload=run_to_payload(watched), + occurred_at=watched.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/unit/api/test_run_supervisor.py b/apps/api/tests/unit/api/test_run_supervisor.py index 06e35153b06..5241864a37d 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_watched_recorded_run_when_beam_down() -> None: + """A watched (Recorded) 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="Recorded")]) + 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_watched_recorded_run() -> None: + """A watched 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="Recorded")] + ) + 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/run/test_start_run_decider.py b/apps/api/tests/unit/run/test_start_run_decider.py index 8385207c2c3..915f480f3b2 100644 --- a/apps/api/tests/unit/run/test_start_run_decider.py +++ b/apps/api/tests/unit/run/test_start_run_decider.py @@ -164,11 +164,11 @@ def test_decide_emits_run_started_for_valid_sample_run() -> None: @pytest.mark.unit -def test_decide_copies_conduct_mode_from_command_unmodified() -> None: - """The decider copies `StartRun.conduct_mode` verbatim onto the emitted - `RunStarted` — it must never compute or guess the value itself. Explicit - RECORDED (the not-yet-built shadow-promotion path's future call shape) - reaches the event exactly as declared.""" +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 `Recorded` Run is genesis-only + through the separate watched-genesis decider.""" cap = uuid4() asset_id = uuid4() plan = _plan(asset_ids=frozenset({asset_id})) @@ -187,7 +187,6 @@ def test_decide_copies_conduct_mode_from_command_unmodified() -> None: name="Run", plan_id=plan.id, subject_id=subject.id, - conduct_mode=ConductMode.RECORDED, ), context=context, needed_family_ids_snapshot=frozenset({cap}), @@ -202,7 +201,7 @@ def test_decide_copies_conduct_mode_from_command_unmodified() -> None: name="Run", plan_id=plan.id, subject_id=subject.id, - conduct_mode=ConductMode.RECORDED, + conduct_mode=ConductMode.CONDUCTED, occurred_at=_NOW, ) ] From f2785148fd05b156099e4dc403f76a671b3476f3 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:32:41 -0500 Subject: [PATCH 09/20] Fix the RunStarted integration test's stale exact-payload assertion test_start_run_persists_event_with_full_upstream_chain_against_postgres asserts the persisted RunStarted payload field by field. Slice 5 added conduct_mode to that payload but never updated this test, so it has been silently broken since, caught now by running the integration suite (not part of slice 5's own unit+architecture verification sweep). Co-Authored-By: Claude Sonnet 5 --- apps/api/tests/integration/test_start_run_handler_postgres.py | 4 ++++ 1 file changed, 4 insertions(+) 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..1c61edb84be 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,10 @@ 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", # 6g-c additive payload fields default to {} / None when no # overrides / no Plan defaults / no trigger_source are supplied. "override_parameters": {}, From ee6e3c133926bc7a2ba6e2aee36da20ad573fe6e Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:47:27 -0500 Subject: [PATCH 10/20] Split the safety envelope into shared gates, plus a witness entry point Slice 8's watched genesis needs a way to record the enclosure and beam readings instead of enforcing them, without risking the two paths ever disagreeing about what a gate actually checks. Refactor check_safety_envelope's four inline checks into standalone gate functions: check_clearance_gate and check_supply_gate still raise unconditionally (both are CORA's own data, not live facility signals, so they refuse on every path per the roadmap's rule), while enclosure_gate_refusal and beam_gate_refusal return the refusal they would raise instead of raising it. check_safety_envelope is unchanged in signature, behaviour, and raise order, now composed from the four gates; every existing caller needed zero changes, confirmed by the full pre-existing test suite passing unmodified. New witness_safety_envelope shares the exact same gate functions and converts only the enclosure/beam refusals into a SafetyEnvelopeVerdict, so the two entry points cannot silently drift: a change to a gate's logic changes what both paths see. SafetyEnvelopeVerdict carries plain bools only (the record exporter's generator drops bare str and Any) and lives beside ConductMode in state.py rather than in safety_envelope.py or events.py, so neither of those modules gains a new import edge to carry it. Co-Authored-By: Claude Sonnet 5 --- .../src/cora/run/aggregates/run/__init__.py | 16 +- .../run/aggregates/run/safety_envelope.py | 298 ++++++++++++------ apps/api/src/cora/run/aggregates/run/state.py | 37 +++ .../tests/unit/run/test_safety_envelope.py | 124 +++++++- 4 files changed, 377 insertions(+), 98 deletions(-) diff --git a/apps/api/src/cora/run/aggregates/run/__init__.py b/apps/api/src/cora/run/aggregates/run/__init__.py index f2ac3dc0951..71a9c9e2b77 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_clearance_gate, + check_safety_envelope, + check_supply_gate, + enclosure_gate_refusal, + witness_safety_envelope, +) from cora.run.aggregates.run.state import ( LOGBOOK_KIND_OBSERVATION, OBSERVATION_LOGBOOK_SCHEMA, @@ -124,6 +131,7 @@ RunSubjectNotMountableError, RunSupplyCoverageMismatchError, RunTruncateReason, + SafetyEnvelopeVerdict, SamplingProcedure, validate_input_dataset_ids, validate_pinned_calibration_ids, @@ -222,11 +230,16 @@ "RunSupplyCoverageMismatchError", "RunTruncateReason", "RunTruncated", + "SafetyEnvelopeVerdict", "SamplingProcedure", "active_hold_claims", + "beam_gate_refusal", "blocking_causes", + "check_clearance_gate", "check_safety_envelope", + "check_supply_gate", "derive_claim_id", + "enclosure_gate_refusal", "event_type_name", "evolve", "fold", @@ -240,4 +253,5 @@ "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/safety_envelope.py b/apps/api/src/cora/run/aggregates/run/safety_envelope.py index f231060dcb2..ff3dcf1a403 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 +watched-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 watched-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 watched-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 watched 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 watched +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 check_clearance_gate( *, 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 watched 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 check_supply_gate( + *, + 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 `check_clearance_gate`'s two-error pair pattern. + + Always raises, never witnesses, for the same reason as + `check_clearance_gate`: 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 watched 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. + """ + check_clearance_gate(run_id=run_id, referencing_clearances=referencing_clearances) + check_supply_gate( + 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 watched-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 the watcher 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. + """ + check_clearance_gate(run_id=run_id, referencing_clearances=referencing_clearances) + check_supply_gate( + 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 d7ba4b3a6cf..6652967299a 100644 --- a/apps/api/src/cora/run/aggregates/run/state.py +++ b/apps/api/src/cora/run/aggregates/run/state.py @@ -306,6 +306,43 @@ class ConductMode(StrEnum): RECORDED = "Recorded" +@dataclass(frozen=True) +class SafetyEnvelopeVerdict: + """A recorded reading of the two live facility signals at a watched + 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 held(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()`.""" + return self.enclosure_permitted and self.beam_available + + class InvalidRunNameError(ValueError): """The supplied name is empty, whitespace-only, or too long.""" diff --git a/apps/api/tests/unit/run/test_safety_envelope.py b/apps/api/tests/unit/run/test_safety_envelope.py index 387cf5a619e..67ced463f78 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 watched-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_held() -> None: + verdict = _witness() + assert verdict == SafetyEnvelopeVerdict(enclosure_permitted=True, beam_available=True) + assert verdict.held 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.held 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.held 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 watched 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 From 27be6cbd2e3d5136e85135e7b71941d173b97aa5 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:56:56 -0500 Subject: [PATCH 11/20] Add the watched genesis: a second, independent Run genesis for captures CORA only observes Slice 8's whole point: today only start_run can create a Run, and it always drives the act through CORA's own Conductor. A capture an external tool runs (2-BM's TomoScan) has nowhere honest to land except as a fabricated driven start, or not at all. RecordWatchedRun is that second path: same aggregate, same RunStarted event, but the decider hardcodes ConductMode.RECORDED and the command carries none of the operator-facing fields a driven start has, so a caller of the driven path can never claim to be recording, and a caller of this path can never claim to be driving. The governing rule, unchanged from the roadmap: refuse on what CORA can fix, witness what CORA cannot. Every CORA-side genesis invariant the driven decider enforces (deprecated Plan, decommissioned Asset, capability shortfall, bad wires, absent Clearance or Supply) still raises here. Only the two genuinely external live facility signals, enclosure permit and beam availability, are witnessed instead: recorded as a SafetyEnvelopeVerdict on the emitted event rather than gating the genesis. A trigger guard (RunMonitorTriggerNotPermittedError, mirroring the Enclosure BC's shipped lock) closes the one operator-reachable path in: only a literal "Monitor" trigger is accepted, and there is no REST route, no MCP tool, and no other construction site for ConductMode.RECORDED anywhere in the tree, each pinned by a dedicated architecture fitness test rather than left to convention. A naming-r3-reviewer pass on the staged diff caught five real R1/R2 issues before this landed: a property named `held` that collided with the Run BC's own RunStatus.HELD vocabulary, a field named `envelope_verdict` that collided with the "event envelope" infra term, two classes using a skeleton that didn't match their siblings (RunStartContext/RunStartEvents), and two gate-check functions whose word order didn't match their two siblings. All five renamed before commit; the rename also surfaced one more pre-existing exact-payload test gap of the same shape the previous commit fixed, caught by the same full-suite run and fixed alongside. Deferred to a follow-up commit: wiring this handler into _run_watcher.py's live promotion loop (the deployment settings, the seeded Agent principal, the edge-triggered dedup state machine, and the restart-rebuild logic), and seeding an actual fly-scan Plan for it to point at. Those are mechanical integration work once this slice exists; this commit is the safety-critical reviewable unit on its own. Co-Authored-By: Claude Sonnet 5 --- .../record_export/_dispositions.py | 4 + .../src/cora/run/aggregates/run/__init__.py | 10 +- .../api/src/cora/run/aggregates/run/events.py | 31 +- .../run/aggregates/run/safety_envelope.py | 16 +- apps/api/src/cora/run/aggregates/run/state.py | 35 +- apps/api/src/cora/run/features/__init__.py | 3 + .../features/record_watched_run/__init__.py | 31 ++ .../features/record_watched_run/command.py | 64 +++ .../features/record_watched_run/context.py | 76 +++ .../features/record_watched_run/decider.py | 215 ++++++++ .../features/record_watched_run/handler.py | 239 ++++++++ .../run/features/record_watched_run/route.py | 21 + .../run/features/record_watched_run/tool.py | 29 + apps/api/src/cora/run/routes.py | 12 + apps/api/src/cora/run/tools.py | 9 + apps/api/src/cora/run/wire.py | 13 + .../test_command_name_derives_event_name.py | 6 + .../architecture/test_slice_test_coverage.py | 9 + .../test_watched_genesis_laundering_wall.py | 123 +++++ ...est_record_watched_run_handler_postgres.py | 184 +++++++ .../test_start_run_handler_postgres.py | 4 + .../run/test_record_watched_run_decider.py | 511 ++++++++++++++++++ ...t_record_watched_run_decider_properties.py | 320 +++++++++++ apps/api/tests/unit/run/test_run_events.py | 4 + .../tests/unit/run/test_safety_envelope.py | 8 +- 25 files changed, 1958 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/cora/run/features/record_watched_run/__init__.py create mode 100644 apps/api/src/cora/run/features/record_watched_run/command.py create mode 100644 apps/api/src/cora/run/features/record_watched_run/context.py create mode 100644 apps/api/src/cora/run/features/record_watched_run/decider.py create mode 100644 apps/api/src/cora/run/features/record_watched_run/handler.py create mode 100644 apps/api/src/cora/run/features/record_watched_run/route.py create mode 100644 apps/api/src/cora/run/features/record_watched_run/tool.py create mode 100644 apps/api/tests/architecture/test_watched_genesis_laundering_wall.py create mode 100644 apps/api/tests/integration/test_record_watched_run_handler_postgres.py create mode 100644 apps/api/tests/unit/run/test_record_watched_run_decider.py create mode 100644 apps/api/tests/unit/run/test_record_watched_run_decider_properties.py diff --git a/apps/api/src/cora/infrastructure/record_export/_dispositions.py b/apps/api/src/cora/infrastructure/record_export/_dispositions.py index 9158e50e3f6..8f8f26668c1 100644 --- a/apps/api/src/cora/infrastructure/record_export/_dispositions.py +++ b/apps/api/src/cora/infrastructure/record_export/_dispositions.py @@ -1630,6 +1630,10 @@ "plan_id": "token:uuid", "raid": "drop:text", "run_id": "token:uuid", + "safety_envelope_verdict": { + "beam_available": "keep:number", + "enclosure_permitted": "keep:number", + }, "subject_id": "token:uuid", "trigger_source": "drop:text", }, diff --git a/apps/api/src/cora/run/aggregates/run/__init__.py b/apps/api/src/cora/run/aggregates/run/__init__.py index 71a9c9e2b77..b8d7426b3da 100644 --- a/apps/api/src/cora/run/aggregates/run/__init__.py +++ b/apps/api/src/cora/run/aggregates/run/__init__.py @@ -62,10 +62,10 @@ from cora.run.aggregates.run.read import load_run from cora.run.aggregates.run.safety_envelope import ( beam_gate_refusal, - check_clearance_gate, check_safety_envelope, - check_supply_gate, + clearance_gate_check, enclosure_gate_refusal, + supply_gate_check, witness_safety_envelope, ) from cora.run.aggregates.run.state import ( @@ -117,6 +117,7 @@ RunHoldClaimsRemainError, RunInputNotReachableError, RunInputNotVerifiedError, + RunMonitorTriggerNotPermittedError, RunName, RunNotFoundError, RunObservationLogbookClosedError, @@ -210,6 +211,7 @@ "RunHoldClaimsRemainError", "RunInputNotReachableError", "RunInputNotVerifiedError", + "RunMonitorTriggerNotPermittedError", "RunName", "RunNotFoundError", "RunObservationLogbookClosedError", @@ -235,9 +237,8 @@ "active_hold_claims", "beam_gate_refusal", "blocking_causes", - "check_clearance_gate", "check_safety_envelope", - "check_supply_gate", + "clearance_gate_check", "derive_claim_id", "enclosure_gate_refusal", "event_type_name", @@ -248,6 +249,7 @@ "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", diff --git a/apps/api/src/cora/run/aggregates/run/events.py b/apps/api/src/cora/run/aggregates/run/events.py index 32af57b3b8a..82368c41eff 100644 --- a/apps/api/src/cora/run/aggregates/run/events.py +++ b/apps/api/src/cora/run/aggregates/run/events.py @@ -91,7 +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 +from cora.run.aggregates.run.state import ConductMode, SafetyEnvelopeVerdict from cora.shared.identity import ActorId from cora.shared.logbook import LogbookSchema @@ -208,6 +208,17 @@ class RunStarted: # ConductMode.CONDUCTED.value)` in `from_stored` for legacy streams # without the key, not because any caller supplies it. conduct_mode: ConductMode = ConductMode.CONDUCTED + # The watched 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_watched_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]) @@ -754,6 +765,7 @@ def to_payload(event: RunEvent) -> dict[str, Any]: 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, @@ -772,6 +784,14 @@ def to_payload(event: RunEvent) -> dict[str, Any]: "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, @@ -1018,6 +1038,7 @@ 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"], @@ -1026,6 +1047,14 @@ def _build_run_started() -> RunStarted: 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/safety_envelope.py b/apps/api/src/cora/run/aggregates/run/safety_envelope.py index ff3dcf1a403..1f0102a5f6e 100644 --- a/apps/api/src/cora/run/aggregates/run/safety_envelope.py +++ b/apps/api/src/cora/run/aggregates/run/safety_envelope.py @@ -59,7 +59,7 @@ ) -def check_clearance_gate( +def clearance_gate_check( *, run_id: UUID, referencing_clearances: tuple[ClearanceLookupResult, ...], @@ -91,7 +91,7 @@ def check_clearance_gate( ) -def check_supply_gate( +def supply_gate_check( *, run_id: UUID, needed_supplies_snapshot: frozenset[str], @@ -102,10 +102,10 @@ def check_supply_gate( [[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 `check_clearance_gate`'s two-error pair pattern. + Mirrors `clearance_gate_check`'s two-error pair pattern. Always raises, never witnesses, for the same reason as - `check_clearance_gate`: Supply is CORA's own aggregate, not a live + `clearance_gate_check`: Supply is CORA's own aggregate, not a live facility reading. """ for kind in sorted(needed_supplies_snapshot): @@ -218,8 +218,8 @@ def check_safety_envelope( error's payload are unchanged from before this module gained a second entry point. """ - check_clearance_gate(run_id=run_id, referencing_clearances=referencing_clearances) - check_supply_gate( + 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, @@ -255,8 +255,8 @@ def witness_safety_envelope( raised. This is what makes "both paths provably call the same predicates" a structural fact rather than a claim to trust. """ - check_clearance_gate(run_id=run_id, referencing_clearances=referencing_clearances) - check_supply_gate( + 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, diff --git a/apps/api/src/cora/run/aggregates/run/state.py b/apps/api/src/cora/run/aggregates/run/state.py index 6652967299a..25aa7ef0eed 100644 --- a/apps/api/src/cora/run/aggregates/run/state.py +++ b/apps/api/src/cora/run/aggregates/run/state.py @@ -336,10 +336,13 @@ class SafetyEnvelopeVerdict: beam_available: bool @property - def held(self) -> bool: + 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()`.""" + 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 @@ -361,6 +364,34 @@ def __init__(self, run_id: UUID) -> None: self.run_id = run_id +class RunMonitorTriggerNotPermittedError(Exception): + """`record_watched_run` carried a non-Monitor trigger. + + Mirrors the Enclosure BC's `MonitorTriggerNotPermittedError` + (`observe_enclosure_status`, D6.L2 observation-axis-only anti-lock): + a watched 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 watched 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_watched_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.""" diff --git a/apps/api/src/cora/run/features/__init__.py b/apps/api/src/cora/run/features/__init__.py index 4a0e8759906..653acf1706a 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_watched_run: the watched genesis (a second, independent + genesis alongside start_run). Hardcodes ConductMode.RECORDED; + in-process-only, no REST route, no MCP tool. """ diff --git a/apps/api/src/cora/run/features/record_watched_run/__init__.py b/apps/api/src/cora/run/features/record_watched_run/__init__.py new file mode 100644 index 00000000000..d918d8d688f --- /dev/null +++ b/apps/api/src/cora/run/features/record_watched_run/__init__.py @@ -0,0 +1,31 @@ +"""Vertical slice for the `RecordWatchedRun` command: the watched 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_watched_run + + cmd = record_watched_run.RecordWatchedRun( + name="...", plan_id=..., capture_code=..., monitor_source_id=..., + trigger="Monitor", + ) + handler = record_watched_run.bind(deps) + run_id = await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.run.features.record_watched_run import tool +from cora.run.features.record_watched_run.command import RecordWatchedRun +from cora.run.features.record_watched_run.context import RunWatchedStartContext +from cora.run.features.record_watched_run.decider import decide +from cora.run.features.record_watched_run.handler import Handler, bind +from cora.run.features.record_watched_run.route import router + +__all__ = [ + "Handler", + "RecordWatchedRun", + "RunWatchedStartContext", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/run/features/record_watched_run/command.py b/apps/api/src/cora/run/features/record_watched_run/command.py new file mode 100644 index 00000000000..095b9844df8 --- /dev/null +++ b/apps/api/src/cora/run/features/record_watched_run/command.py @@ -0,0 +1,64 @@ +"""The `RecordWatchedRun` command -- intent dataclass for this slice. + +A watched 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 + watcher 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 + watcher 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- + Recorded backdoor (mirrors `ObserveEnclosureStatus.trigger`'s D6.L2 + anti-lock): there is no operator path to a watched genesis. + +No `conduct_mode` field: this decider hardcodes `ConductMode.RECORDED`, +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`: +a watcher 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 RecordWatchedRun: + """Record that an external tool began a capture: a watched 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_watched_run/context.py b/apps/api/src/cora/run/features/record_watched_run/context.py new file mode 100644 index 00000000000..751af9a634d --- /dev/null +++ b/apps/api/src/cora/run/features/record_watched_run/context.py @@ -0,0 +1,76 @@ +"""Cross-aggregate context the `record_watched_run` decider validates against. + +`RunWatchedStartContext` is the watched-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 a watcher 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 watched 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 watched 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 RunWatchedStartContext: + """Snapshot of upstream aggregate state at a watched 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_watched_run/decider.py b/apps/api/src/cora/run/features/record_watched_run/decider.py new file mode 100644 index 00000000000..3b290c8bbeb --- /dev/null +++ b/apps/api/src/cora/run/features/record_watched_run/decider.py @@ -0,0 +1,215 @@ +"""Pure decider for the `RecordWatchedRun` command: the watched 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.RECORDED`. Never the +other way around: `RecordWatchedRun` 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_watched_run.command import RecordWatchedRun +from cora.run.features.record_watched_run.context import RunWatchedStartContext +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 RunWatchedStartEvents: + """Events produced by a watched Run genesis: always exactly one.""" + + run_events: list[RunStarted] + + +def decide( + state: Run | None, + command: RecordWatchedRun, + *, + context: RunWatchedStartContext, + 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, +) -> RunWatchedStartEvents: + """Decide the events produced by recording a watched 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.RECORDED, + trigger_source=f"CaptureWatcher:{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 RunWatchedStartEvents(run_events=run_events) diff --git a/apps/api/src/cora/run/features/record_watched_run/handler.py b/apps/api/src/cora/run/features/record_watched_run/handler.py new file mode 100644 index 00000000000..a9fa65d29f4 --- /dev/null +++ b/apps/api/src/cora/run/features/record_watched_run/handler.py @@ -0,0 +1,239 @@ +"""Application handler for the `record_watched_run` slice: the watched 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 watcher 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_watched_run`, called only by +the in-process watcher 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_watched_run.command import RecordWatchedRun +from cora.run.features.record_watched_run.context import RunWatchedStartContext +from cora.run.features.record_watched_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 = "RecordWatchedRun" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every record_watched_run handler implements.""" + + async def __call__( + self, + command: RecordWatchedRun, + *, + 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_watched_run handler closed over the shared deps.""" + + async def handler( + command: RecordWatchedRun, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "record_watched_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_watched_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 = RunWatchedStartContext( + 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_watched_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_watched_run/route.py b/apps/api/src/cora/run/features/record_watched_run/route.py new file mode 100644 index 00000000000..83df695ad16 --- /dev/null +++ b/apps/api/src/cora/run/features/record_watched_run/route.py @@ -0,0 +1,21 @@ +"""Stub route module for `record_watched_run` (in-process-only slice). + +Per the roadmap's anti-scope: no operator path to a watched 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 watched path being used to launder around a driven +refusal. In-process adapters (the capture-watch runtime) call +`RunHandlers.record_watched_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_watched_run/tool.py b/apps/api/src/cora/run/features/record_watched_run/tool.py new file mode 100644 index 00000000000..7756bb49b7d --- /dev/null +++ b/apps/api/src/cora/run/features/record_watched_run/tool.py @@ -0,0 +1,29 @@ +"""Stub MCP tool module for `record_watched_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_watched_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_watched_run.handler import Handler + +_STUB_RESOLVER = get_mcp_surface_id + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """No-op MCP registration: record_watched_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/routes.py b/apps/api/src/cora/run/routes.py index 5ae16c1a499..016d5950de3 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 (watched-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_watched_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 watched-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_watched_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..c570fe48872 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_watched_run import tool as record_watched_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 watched-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_watched_run_tool.register( + mcp, + get_handler=lambda: get_handlers().record_watched_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..4e0e209ef00 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_watched_run` is a second, independent genesis (the watched +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_watched_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_watched_run: record_watched_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_watched_run=with_tracing( + record_watched_run.bind(deps), + command_name="RecordWatchedRun", + bc=_BC, + ), complete_run=with_tracing( complete_run.bind(deps), command_name="CompleteRun", 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..5781aed6a8b 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: + # RecordWatchedRun emits RunStarted, not WatchedRunRecorded, 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_watched_run": "watched genesis; states the RunStarted fact, not the recording verb", } _KNOWN_DRIFT: dict[str, str] = { diff --git a/apps/api/tests/architecture/test_slice_test_coverage.py b/apps/api/tests/architecture/test_slice_test_coverage.py index aee025bae01..db20ceeeb77 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 watched Run). Mirrors the + # Enclosure / Supply monitor-trigger precedent. In-process adapters + # call via RunHandlers.record_watched_run. + "cora.run.features.record_watched_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_watched_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_watched_run", } ) diff --git a/apps/api/tests/architecture/test_watched_genesis_laundering_wall.py b/apps/api/tests/architecture/test_watched_genesis_laundering_wall.py new file mode 100644 index 00000000000..8de5c008a93 --- /dev/null +++ b/apps/api/tests/architecture/test_watched_genesis_laundering_wall.py @@ -0,0 +1,123 @@ +"""The watched 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.RECORDED` is constructed in exactly one place: the + watched-genesis decider. If a second construction site appears + anywhere under `src/cora`, an operator-reachable path has found a + way to claim RECORDED, which is precisely the laundering hole the + axis exists to close. + 2. The in-process-only slices (`observe_enclosure_status`, + `record_watched_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_watched_run", +) + + +def _find_conduct_mode_recorded_sites() -> list[Path]: + """Every tracked file under src/cora that references `ConductMode.RECORDED` + specifically (other aggregates declare their own unrelated `RECORDED` + members, e.g. `AcquisitionStatus.RECORDED` in the Data BC, so the scan + must check the attribute's base name, not just the attribute name).""" + 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 == "RECORDED" + and isinstance(node.value, ast.Name) + and node.value.id == "ConductMode" + ): + sites.append(path) + break + return sites + + +@pytest.mark.architecture +def test_conduct_mode_recorded_has_exactly_one_construction_site() -> None: + sites = _find_conduct_mode_recorded_sites() + relative = sorted(str(p.relative_to(CORA_ROOT)) for p in sites) + assert relative == ["run/features/record_watched_run/decider.py"], ( + "ConductMode.RECORDED must be constructed in exactly one place " + f"(the watched-genesis decider); found it referenced in: {relative}. " + "A second site is an operator-reachable path claiming a watched " + "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/test_record_watched_run_handler_postgres.py b/apps/api/tests/integration/test_record_watched_run_handler_postgres.py new file mode 100644 index 00000000000..4abd906fc6f --- /dev/null +++ b/apps/api/tests/integration/test_record_watched_run_handler_postgres.py @@ -0,0 +1,184 @@ +"""Postgres integration test for the `record_watched_run` handler. + +Round-trips the watched 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 `Recorded`. + +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_watched_run import RecordWatchedRun, 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_watched_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_watched_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_watched_run's own event ids + ] + + +@pytest.mark.integration +async def test_record_watched_run_persists_recorded_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( + RecordWatchedRun( + name="2BM watched 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"] == "Recorded" + 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.RECORDED + + +@pytest.mark.integration +async def test_record_watched_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( + RecordWatchedRun( + name="2BM watched 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_start_run_handler_postgres.py b/apps/api/tests/integration/test_start_run_handler_postgres.py index 1c61edb84be..1a2194f23c1 100644 --- a/apps/api/tests/integration/test_start_run_handler_postgres.py +++ b/apps/api/tests/integration/test_start_run_handler_postgres.py @@ -181,6 +181,10 @@ async def test_start_run_persists_event_with_full_upstream_chain_against_postgre # by the decider (not caller-settable). Forward-compat via # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)`. "conduct_mode": "Conducted", + # Additive payload field: the watched-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/run/test_record_watched_run_decider.py b/apps/api/tests/unit/run/test_record_watched_run_decider.py new file mode 100644 index 00000000000..b7b721bbc4e --- /dev/null +++ b/apps/api/tests/unit/run/test_record_watched_run_decider.py @@ -0,0 +1,511 @@ +"""Unit tests for the `record_watched_run` slice's pure decider. + +The watched-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_watched_run +from cora.run.features.record_watched_run import RecordWatchedRun, RunWatchedStartContext +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) -> RecordWatchedRun: + 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 RecordWatchedRun(**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_recorded_for_valid_watched_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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + new_id = uuid4() + decision = record_watched_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.RECORDED + assert event.safety_envelope_verdict == SafetyEnvelopeVerdict( + enclosure_permitted=True, beam_available=True + ) + assert event.trigger_source == "CaptureWatcher: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: + """RecordWatchedRun carries no conduct_mode field for a caller to set; + the decider always stamps RECORDED.""" + cap = uuid4() + asset_id = uuid4() + plan = _plan(asset_ids=frozenset({asset_id})) + asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) + context = RunWatchedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + decision = record_watched_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.RECORDED + + +# ---------- 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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunMonitorTriggerNotPermittedError): + record_watched_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 = RunWatchedStartContext( + 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_watched_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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=(), + ) + with pytest.raises(RunRequiresActiveClearanceError): + record_watched_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 = RunWatchedStartContext( + 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_watched_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 = RunWatchedStartContext( + plan=_plan(), + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunAlreadyExistsError) as exc: + record_watched_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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunBoundPlanDeprecatedError): + record_watched_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 = RunWatchedStartContext( + plan=plan, + subject=subject, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunSubjectNotMountableError): + record_watched_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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunPlanAssetDecommissionedError): + record_watched_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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={asset_id: asset}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(RunCapabilitiesNotSatisfiedError): + record_watched_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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + ) + with pytest.raises(InvalidRunNameError): + record_watched_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 = RunWatchedStartContext( + plan=plan, + subject=None, + assets={}, + referencing_clearances=_active_clearance_stub(), + active_cautions=(caution,), + ) + decision = record_watched_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_watched_run_decider_properties.py b/apps/api/tests/unit/run/test_record_watched_run_decider_properties.py new file mode 100644 index 00000000000..cd9117f6b1f --- /dev/null +++ b/apps/api/tests/unit/run/test_record_watched_run_decider_properties.py @@ -0,0 +1,320 @@ +"""Property-based tests for `record_watched_run.decide` (Run BC). + +Complements the example-based `test_record_watched_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=RECORDED` 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_watched_run +from cora.run.features.record_watched_run import RecordWatchedRun, RunWatchedStartContext +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, +) -> RunWatchedStartContext: + return RunWatchedStartContext( + 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" +) -> RecordWatchedRun: + return RecordWatchedRun( + 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_watched_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_watched_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_watched_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_watched_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_watched_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_watched_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_watched_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 + watched 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_watched_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_watched_happy_path_always_emits_recorded_with_a_verdict( + name: str, + plan_id: UUID, + capture_code: str, + now: datetime, + new_id: UUID, +) -> None: + """The happy path always stamps RECORDED and a non-None verdict, + across the whole generated input space -- never CONDUCTED, never a + bare safety_envelope_verdict=None.""" + result = record_watched_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.RECORDED + 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_watched_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_watched_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_watched_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 66bd3639937..f23491fee88 100644 --- a/apps/api/tests/unit/run/test_run_events.py +++ b/apps/api/tests/unit/run/test_run_events.py @@ -79,6 +79,10 @@ def test_to_payload_serializes_run_started_with_subject_to_primitives() -> None: # "Conducted" when not supplied; forward-compat via # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)`. "conduct_mode": "Conducted", + # Additive payload field: the watched-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 diff --git a/apps/api/tests/unit/run/test_safety_envelope.py b/apps/api/tests/unit/run/test_safety_envelope.py index 67ced463f78..723c81c1653 100644 --- a/apps/api/tests/unit/run/test_safety_envelope.py +++ b/apps/api/tests/unit/run/test_safety_envelope.py @@ -250,24 +250,24 @@ def _witness( @pytest.mark.unit -def test_witness_full_envelope_returns_verdict_held() -> None: +def test_witness_full_envelope_returns_verdict_all_gates_passed() -> None: verdict = _witness() assert verdict == SafetyEnvelopeVerdict(enclosure_permitted=True, beam_available=True) - assert verdict.held is 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.held is False + 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.held is False + assert verdict.all_gates_passed is False @pytest.mark.unit From be426b580734f2a95a7d6728a9ce5ee0ccd2a7de Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:46:12 -0500 Subject: [PATCH 12/20] agent: seed the RunWatcher agent identity RunWatcher (cora.api._run_watcher) needs a seeded Agent + Actor at a pinned id to author RecordWatchedRun calls once it starts promoting BEGUN captures to real watched Runs, same shape as every other in-process runtime (RunSupervisor, RunInitiator, CalibrationWatcher). No behavior change to the watcher itself yet: this only gets the principal into the event store. --- apps/api/src/cora/agent/__init__.py | 6 + apps/api/src/cora/agent/seed_run_watcher.py | 108 ++++++++++++++++++ apps/api/src/cora/api/main.py | 3 + .../tests/unit/agent/test_run_watcher_seed.py | 93 +++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 apps/api/src/cora/agent/seed_run_watcher.py create mode 100644 apps/api/tests/unit/agent/test_run_watcher_seed.py diff --git a/apps/api/src/cora/agent/__init__.py b/apps/api/src/cora/agent/__init__.py index 5e9f145475a..e00bf53bfa9 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_watcher import ( + RUN_WATCHER_AGENT_ID, + seed_run_watcher_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_WATCHER_AGENT_ID", "AgentHandlers", "CautionProposalMalformedError", "CautionProposalNotActionableError", @@ -129,5 +134,6 @@ "seed_run_debriefer_agent", "seed_run_initiator_agent", "seed_run_supervisor_agent", + "seed_run_watcher_agent", "wire_agent", ] diff --git a/apps/api/src/cora/agent/seed_run_watcher.py b/apps/api/src/cora/agent/seed_run_watcher.py new file mode 100644 index 00000000000..ea19877cd96 --- /dev/null +++ b/apps/api/src/cora/agent/seed_run_watcher.py @@ -0,0 +1,108 @@ +"""Bootstrap-time seed for the RunWatcher Agent. + +The RunWatcher runtime (`cora.api._run_watcher`) needs an Agent record +(and its co-registered Actor) to exist at the pinned `RUN_WATCHER_AGENT_ID` +so it can issue `RecordWatchedRun` as an agent-kind principal when it +promotes a BEGUN capture observation to a real watched 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 `RecordWatchedRun` 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 + {RecordWatchedRun, 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 RecordWatchedRun grant, a real BEGUN observation + logs `run_watcher.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 + + +# --------------------------------------------------------------------------- +# RunWatcher 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 RunWatcher-authored Run's principal_id pointer. +# UUID continues the `1111`-opened numeric range at `2222` (next unclaimed +# block). +RUN_WATCHER_AGENT_ID = UUID("01900000-0000-7000-8000-000022220010") +RUN_WATCHER_AGENT_NAME = "RunWatcher" +RUN_WATCHER_AGENT_KIND = "RunWatcher" +RUN_WATCHER_AGENT_VERSION = "1.0.0" +RUN_WATCHER_AGENT_DESCRIPTION = ( + "Deterministic in-process runtime: promotes a real watched (Recorded) " + "Run via record_watched_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: RunWatcher 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:RunWatcher: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_watcher_agent(kernel: Kernel) -> None: + """Seed the RunWatcher Agent + co-registered Actor (idempotent).""" + identity = AgentSeedIdentity( + agent_id=RUN_WATCHER_AGENT_ID, + name=RUN_WATCHER_AGENT_NAME, + kind=RUN_WATCHER_AGENT_KIND, + version=RUN_WATCHER_AGENT_VERSION, + description=RUN_WATCHER_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="SeedRunWatcherAgent", + ) + await seed_agent(kernel, identity) + + +__all__ = [ + "RUN_WATCHER_AGENT_DESCRIPTION", + "RUN_WATCHER_AGENT_ID", + "RUN_WATCHER_AGENT_KIND", + "RUN_WATCHER_AGENT_NAME", + "RUN_WATCHER_AGENT_VERSION", + "seed_run_watcher_agent", +] diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 2bca2c79c97..36b2ba75a3a 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -70,6 +70,7 @@ seed_run_debriefer_agent, seed_run_initiator_agent, seed_run_supervisor_agent, + seed_run_watcher_agent, wire_agent, ) from cora.agent.adapters import BudgetSpendGuard, PostgresLanguageModelLookup @@ -967,6 +968,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 RunWatcher (deterministic capture-promotion agent). + await seed_run_watcher_agent(deps) # Drain Federation-owned projections so the Postgres-backed # FacilityLookup.list_active() resolves the self-Facility row diff --git a/apps/api/tests/unit/agent/test_run_watcher_seed.py b/apps/api/tests/unit/agent/test_run_watcher_seed.py new file mode 100644 index 00000000000..575529ceebe --- /dev/null +++ b/apps/api/tests/unit/agent/test_run_watcher_seed.py @@ -0,0 +1,93 @@ +"""Unit tests for the RunWatcher Agent bootstrap seed. + +RunWatcher 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_watcher import ( + RUN_WATCHER_AGENT_ID, + RUN_WATCHER_AGENT_KIND, + RUN_WATCHER_AGENT_NAME, + RUN_WATCHER_AGENT_VERSION, + seed_run_watcher_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_watcher_at_pinned_id() -> None: + kernel = _kernel() + await seed_run_watcher_agent(kernel) + + agent = await load_agent(kernel.event_store, RUN_WATCHER_AGENT_ID) + assert agent is not None + assert agent.id == RUN_WATCHER_AGENT_ID + assert agent.name.value == RUN_WATCHER_AGENT_NAME + assert agent.kind.value == RUN_WATCHER_AGENT_KIND + assert agent.version.value == RUN_WATCHER_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_watcher_agent(kernel) + + agent = await load_agent(kernel.event_store, RUN_WATCHER_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:RunWatcher: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_watcher_agent(kernel) + + actor = await load_actor(kernel.event_store, RUN_WATCHER_AGENT_ID) + assert actor is not None + assert actor.id == RUN_WATCHER_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_watcher_agent(kernel) + await seed_run_watcher_agent(kernel) + + +@pytest.mark.unit +async def test_run_watcher_id_distinct_from_other_agents() -> None: + """RunWatcher 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_WATCHER_AGENT_ID != RUN_SUPERVISOR_AGENT_ID + assert RUN_WATCHER_AGENT_ID != RUN_INITIATOR_AGENT_ID From ab09d65b8210fa49340ad427ef172cb4b9e956e4 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:01:39 -0500 Subject: [PATCH 13/20] run: add a conduct_mode filter to list_runs RunSummaryItem already carries conduct_mode (Conducted/Recorded), but the query had no way to filter on it. The RunWatcher's restart-rebuild query (a follow-up commit) needs exactly this: page through open Recorded Runs without also seeing Conducted ones. Pure filter-plumbing, mirroring the existing status/plan_id/campaign_id ScalarFilter shape; no projection or migration change. --- .../cora/run/features/list_runs/handler.py | 14 +++--- .../src/cora/run/features/list_runs/query.py | 10 +++- .../test_list_runs_handler_postgres.py | 49 +++++++++++++++++++ .../tests/unit/run/test_list_runs_handler.py | 18 ++++++- 4 files changed, 82 insertions(+), 9 deletions(-) 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 a3531a3e62a..cd9e3a7a4d9 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; @@ -124,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, } @@ -142,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..7ff5d805fac 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 / Recorded). 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 "Recorded"). + Pass `None` (omit) for "any conduct mode". Lets a caller restrict to + watched (Recorded) Runs, e.g. the RunWatcher restart-rebuild query.""" 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..173e3801363 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_watched_run import RecordWatchedRun +from cora.run.features.record_watched_run import bind as bind_record_watched_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_watched_run) against distinct Plans; `conduct_mode="Recorded"` + returns only the watched 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, + ) + + # Recorded (watched) 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_watched_run(deps_recorded)( + RecordWatchedRun( + name="watched-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="Recorded", 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 == "Recorded" 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..cf22db3347c 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="Recorded", + ), + 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="Recorded"), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) From 3dc4046df026b896dceca5c66c7a5daff82e41d0 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:17:49 -0500 Subject: [PATCH 14/20] config: add capture_watch_plan_id and the recording boot gate Two new settings for the RunWatcher's promotion path (a follow-up commit): capture_watch_plan_id (which Plan a promoted watched Run references) and run_watcher_recording_enabled (a second kill switch above run_watcher_enabled, gating whether a BEGUN observation is actually promoted rather than just logged). Boot refuses to start with recording enabled unless both prerequisites are set, mirroring the existing _enforce_production_principal_policy shape: cheaper to catch a half-configured deployment at boot than the first time a real capture begins. --- apps/api/src/cora/api/main.py | 30 +++++++ apps/api/src/cora/infrastructure/config.py | 26 +++++- .../api/test_run_watcher_recording_gate.py | 89 +++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 apps/api/tests/unit/api/test_run_watcher_recording_gate.py diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 36b2ba75a3a..1b52405a5dd 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -404,6 +404,35 @@ def _enforce_production_principal_policy(settings: Settings) -> None: raise RuntimeError(msg) +def _enforce_run_watcher_recording_gate(settings: Settings) -> None: + """Refuse to boot with run_watcher_recording_enabled=True unless both + prerequisites it depends on are also set. + + run_watcher_recording_enabled promotes a BEGUN capture observation to + a real watched Run; that promotion needs (a) the shadow watcher + itself running (run_watcher_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_watched_run has + nowhere to point. + """ + if not settings.run_watcher_recording_enabled: + return + missing: list[str] = [] + if not settings.run_watcher_enabled: + missing.append("RUN_WATCHER_ENABLED=true") + if settings.capture_watch_plan_id is None: + missing.append("CAPTURE_WATCH_PLAN_ID=") + if missing: + msg = ( + "RUN_WATCHER_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. @@ -498,6 +527,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_watcher_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 diff --git a/apps/api/src/cora/infrastructure/config.py b/apps/api/src/cora/infrastructure/config.py index 19ef6fdbe0b..baebee86979 100644 --- a/apps/api/src/cora/infrastructure/config.py +++ b/apps/api/src/cora/infrastructure/config.py @@ -778,11 +778,31 @@ class Settings(BaseSettings): # 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) regardless of any - # other setting. Default off; irrelevant when `capture_watch_pvs` - # is empty. See `cora.api._run_watcher`. + # nothing (no event, no entries row, no Run) unless + # `run_watcher_recording_enabled` is ALSO True (see below). Default + # off; irrelevant when `capture_watch_pvs` is empty. See + # `cora.api._run_watcher`. run_watcher_enabled: bool = False + # Which Plan a promoted watched Run references (record_watched_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_watcher_recording_enabled` (see + # `_enforce_run_watcher_recording_gate` in `main.py`). + capture_watch_plan_id: UUID | None = None + + # SECOND, independent kill switch above `run_watcher_enabled`: + # shadow mode (drain + log) stays default-on once `run_watcher_enabled` + # is True; this flag additionally gates whether a BEGUN observation is + # actually promoted to a real watched Run via `record_watched_run`. + # Default off, so enabling `run_watcher_enabled` alone stays + # shadow-only, unchanged from today. OPERATIONAL KILL SWITCH: boot + # refuses to start if this is True without both + # `run_watcher_enabled=True` and `capture_watch_plan_id` set (see + # `_enforce_run_watcher_recording_gate` in `main.py`). + run_watcher_recording_enabled: bool = False + @field_validator("capture_status_phases") @classmethod def _validate_capture_status_phases(cls, value: dict[str, str]) -> dict[str, str]: diff --git a/apps/api/tests/unit/api/test_run_watcher_recording_gate.py b/apps/api/tests/unit/api/test_run_watcher_recording_gate.py new file mode 100644 index 00000000000..f8658e8ee11 --- /dev/null +++ b/apps/api/tests/unit/api/test_run_watcher_recording_gate.py @@ -0,0 +1,89 @@ +# pyright: reportPrivateUsage=false +"""Unit tests for the RunWatcher recording boot guard. + +`_enforce_run_watcher_recording_gate` refuses to boot with +`run_watcher_recording_enabled=True` unless both `run_watcher_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_watcher_recording_gate +from cora.infrastructure.config import Settings + + +def _settings( + *, + run_watcher_enabled: bool = False, + capture_watch_plan_id: UUID | None = None, + run_watcher_recording_enabled: bool = False, +) -> Settings: + return Settings( # type: ignore[call-arg] + run_watcher_enabled=run_watcher_enabled, + capture_watch_plan_id=capture_watch_plan_id, + run_watcher_recording_enabled=run_watcher_recording_enabled, + ) + + +def test_recording_disabled_is_always_a_no_op() -> None: + for run_watcher_enabled in (True, False): + for capture_watch_plan_id in (None, uuid4()): + _enforce_run_watcher_recording_gate( + _settings( + run_watcher_enabled=run_watcher_enabled, + capture_watch_plan_id=capture_watch_plan_id, + run_watcher_recording_enabled=False, + ) + ) + + +def test_recording_enabled_without_watcher_enabled_refuses_boot() -> None: + with pytest.raises(RuntimeError, match="RUN_WATCHER_ENABLED=true"): + _enforce_run_watcher_recording_gate( + _settings( + run_watcher_enabled=False, + capture_watch_plan_id=uuid4(), + run_watcher_recording_enabled=True, + ) + ) + + +def test_recording_enabled_without_plan_id_refuses_boot() -> None: + with pytest.raises(RuntimeError, match="CAPTURE_WATCH_PLAN_ID"): + _enforce_run_watcher_recording_gate( + _settings( + run_watcher_enabled=True, + capture_watch_plan_id=None, + run_watcher_recording_enabled=True, + ) + ) + + +def test_recording_enabled_missing_both_refuses_boot_naming_both_vars() -> None: + with pytest.raises(RuntimeError) as exc: + _enforce_run_watcher_recording_gate( + _settings( + run_watcher_enabled=False, + capture_watch_plan_id=None, + run_watcher_recording_enabled=True, + ) + ) + message = str(exc.value) + assert "RUN_WATCHER_ENABLED=true" in message + assert "CAPTURE_WATCH_PLAN_ID" in message + + +def test_recording_enabled_with_both_prerequisites_passes() -> None: + _enforce_run_watcher_recording_gate( + _settings( + run_watcher_enabled=True, + capture_watch_plan_id=uuid4(), + run_watcher_recording_enabled=True, + ) + ) From 9b1d69cc9f4ad4f1930f75b15fa60c8362a0dbfb Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:17:56 -0500 Subject: [PATCH 15/20] api: promote BEGUN captures to watched Runs in RunWatcher Wires the shadow-only capture-observation runtime to actually create a real watched (Recorded) Run via record_watched_run when it observes a BEGUN capture, behind the run_watcher_recording_enabled kill switch added in a prior commit. A per-capture-code dedup state machine (RunWatcherRecorder) ensures a single in-progress capture is promoted at most once; ENDED/ABORTED clears the local entry so a later capture on the same code can promote again. A boot-time restart-rebuild query (rebuild_open_captures) seeds that dedup state from every currently- Running Recorded Run's external_refs, so a capture still open across a process restart is never re-promoted. Shadow mode (recording disabled, the default) is byte-for-byte unchanged: observe_capture's body is untouched, and every existing lifespan/loop test that constructs no recorder keeps passing unmodified. Reviewed by naming-r3-reviewer before landing: renamed rebuild_open_capture_map -> rebuild_open_captures, capture_closed -> open_capture_cleared (it isn't a CapturePhase, so it doesn't belong to that log-event family), _CLOSING_PHASES -> _TERMINAL_PHASES (matching the codebase's terminal/closing vocabulary split), and the recorder's observe method -> observe_capture (so both dispatch branches in run_run_watcher read identically). --- apps/api/src/cora/api/_run_watcher.py | 262 +++++++++++- apps/api/src/cora/api/main.py | 31 +- .../test_run_watcher_rebuild_postgres.py | 147 +++++++ apps/api/tests/unit/api/test_run_watcher.py | 396 +++++++++++++++++- 4 files changed, 811 insertions(+), 25 deletions(-) create mode 100644 apps/api/tests/integration/test_run_watcher_rebuild_postgres.py diff --git a/apps/api/src/cora/api/_run_watcher.py b/apps/api/src/cora/api/_run_watcher.py index faeb1365859..7238b761c5b 100644 --- a/apps/api/src/cora/api/_run_watcher.py +++ b/apps/api/src/cora/api/_run_watcher.py @@ -1,17 +1,19 @@ -"""RunWatcher runtime: shadow-observe an external tool's captures. +"""RunWatcher runtime: shadow-observe an external tool's captures, and +(behind a second, independent kill switch) promote a BEGUN capture to a +real watched Run. Background loop draining a `CaptureObserver` and logging each -observation's classified phase. SHADOW ONLY: this module writes -nothing anywhere, ever, by construction, regardless of any Settings -flag. There is no event append, no entries-table write, and no Run -command issued from this file. Promoting from shadow to a real -recorded Run genesis is deliberately a separate, not-yet-built module: -shadow mode exists so a deployment can watch a real capture end to end, -over a real substrate, before any write path exists to get wrong. +observation's classified phase. Shadow mode (the default, and the only +behavior until `Settings.run_watcher_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_watched_run`, +with per-capture-code dedup so a single in-progress capture is never +promoted twice (see `RunWatcherRecorder`). Hosted at the composition root (`cora.api`), like `_run_initiator.py` -and `_enclosure_permit_observer.py`: it will later compose a Run BC -command with an Agent principal, and only `cora.api` may depend on both. +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 @@ -30,7 +32,29 @@ 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). +time for an absent one). These log lines are unconditional: they fire +identically whether or not recording is enabled. + +## Promotion (when run_watcher_recording_enabled is True) + +Per capture_code, a small dedup state machine: + + - `BEGUN` while no Run is open for this code: call `record_watched_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. + +`RunWatcherRecorder`'s dedup state is seeded once at boot (see +`rebuild_open_captures`) from every currently-Running Recorded Run's +`external_refs`, so a still-open capture at process restart is never +re-promoted. ## Retry + resilience @@ -43,8 +67,9 @@ `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. Shadow mode makes no claim anything -depends on, so there is no boot race to close and no wait is needed. +`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 @@ -52,19 +77,38 @@ import asyncio import contextlib from typing import TYPE_CHECKING +from uuid import UUID +from cora.agent.seed_run_watcher import RUN_WATCHER_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_watched_run.command import RecordWatchedRun 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_watched_run.handler import Handler as RecordWatchedRunHandler 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 +# RunWatcher per deployment, so no derivation function is needed. +RUN_WATCHER_MONITOR_SOURCE_ID = MonitorSourceId(UUID("01900000-0000-7000-8000-000072756e01")) + _PHASE_LOG_EVENT: dict[CapturePhase, str] = { CapturePhase.BEGUN: "run_watcher.capture_begun", CapturePhase.PROGRESSING: "run_watcher.capture_progressing", @@ -73,6 +117,8 @@ CapturePhase.UNRECOGNIZED: "run_watcher.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.""" @@ -90,13 +136,160 @@ def observe_capture(observation: CaptureObservation) -> None: ) +class RunWatcherRecorder: + """Promotes a BEGUN observation to a watched 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_watched_run: RecordWatchedRunHandler, + settings: Settings, + open_captures: dict[str, UUID] | None = None, + ) -> None: + self._deps = deps + self._record_watched_run = record_watched_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_watcher_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_watcher.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_watcher_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_watcher.recording_enabled_without_plan_id", + capture_code=observation.capture_code, + ) + return + + command = RecordWatchedRun( + name=f"Watched capture {observation.capture_code}", + plan_id=plan_id, + capture_code=observation.capture_code, + monitor_source_id=RUN_WATCHER_MONITOR_SOURCE_ID, + trigger="Monitor", + ) + try: + run_id = await self._record_watched_run( + command, + principal_id=RUN_WATCHER_AGENT_ID, + correlation_id=self._deps.id_generator.new_id(), + ) + except asyncio.CancelledError: + raise + except UnauthorizedError: + # Configuration fault: the RunWatcher principal is not + # granted RecordWatchedRun. Log loudly; stay unopened so the + # next BEGUN retries once the grant is fixed (same posture + # as RunInitiator's StartRun grant). + _log.warning( + "run_watcher.promotion_unauthorized", + capture_code=observation.capture_code, + ) + return + except Exception: + _log.exception( + "run_watcher.promotion_failed", + capture_code=observation.capture_code, + ) + return + self._open_captures[observation.capture_code] = run_id + _log.info( + "run_watcher.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_watched_run`'s decider always stamps exactly one, + so `None` should not happen for a Recorded 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, Recorded Run and return + capture_code -> run_id for each one's `external_refs`. + + Seeds `RunWatcherRecorder`'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="Recorded", + cursor=cursor, + limit=_PAGE_LIMIT, + ), + principal_id=RUN_WATCHER_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_run_watcher( *, observer: CaptureObserver, capture_codes: frozenset[str], + recorder: RunWatcherRecorder | None = None, reconnect_delay_seconds: float = _RECONNECT_DELAY_SECONDS, ) -> None: - """Drain the observer, logging each observation; re-subscribe on stream end.""" + """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) @@ -104,7 +297,10 @@ async def run_run_watcher( try: async for observation in observer.observe(scope): try: - observe_capture(observation) + if recorder is not None: + await recorder.observe_capture(observation) + else: + observe_capture(observation) except asyncio.CancelledError: raise except Exception: @@ -124,18 +320,41 @@ async def run_watcher_lifespan( *, observer: CaptureObserver, capture_codes: frozenset[str], + deps: Kernel | None = None, + record_watched_run: RecordWatchedRunHandler | None = None, + open_captures: dict[str, UUID] | None = None, ) -> AsyncGenerator[None]: - """Run the shadow watcher as a background task for the app's lifetime. + """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_watched_run` is also supplied. """ if not capture_codes: yield return + if record_watched_run is not None and deps is None: + msg = "run_watcher_lifespan: record_watched_run requires deps" + raise ValueError(msg) + + recorder: RunWatcherRecorder | None = None + if record_watched_run is not None: + assert deps is not None # narrowed by the check above + recorder = RunWatcherRecorder( + deps=deps, + record_watched_run=record_watched_run, + settings=deps.settings, + open_captures=open_captures, + ) + task = asyncio.create_task( - run_run_watcher(observer=observer, capture_codes=capture_codes), + run_run_watcher(observer=observer, capture_codes=capture_codes, recorder=recorder), name="run-watcher", ) try: @@ -146,4 +365,11 @@ async def run_watcher_lifespan( await task -__all__ = ["observe_capture", "run_run_watcher", "run_watcher_lifespan"] +__all__ = [ + "RUN_WATCHER_MONITOR_SOURCE_ID", + "RunWatcherRecorder", + "observe_capture", + "rebuild_open_captures", + "run_run_watcher", + "run_watcher_lifespan", +] diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 1b52405a5dd..1a6465f4957 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 @@ -93,7 +94,7 @@ ) from cora.api._run_initiator import run_initiator_lifespan from cora.api._run_supervisor import run_supervisor_lifespan -from cora.api._run_watcher import run_watcher_lifespan +from cora.api._run_watcher import rebuild_open_captures, run_watcher_lifespan from cora.api.middleware import BodySizeLimitMiddleware from cora.api.protected_resource_metadata import register_protected_resource_metadata_route from cora.budget import ( @@ -262,6 +263,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. @@ -1048,11 +1052,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: ) # RunWatcher: shadow-observe an external tool's captures (2-BM - # commissioning ladder rung 1). SHADOW ONLY: writes nothing, - # regardless of settings. run_watcher_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. + # commissioning ladder rung 1), and (behind the SECOND, + # independent run_watcher_recording_enabled gate) promote a + # BEGUN capture to a real watched Run. run_watcher_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_watcher_recording_enabled is also True. capture_watch_observer = ControlPortCaptureObserver( control_port=app.state.operation.control_port, capture_pvs=settings.capture_watch_pvs, @@ -1064,6 +1071,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: if settings.run_watcher_enabled else frozenset() ) + # Boot-time restart-rebuild: seed the dedup map from every + # currently-Running Recorded 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. + open_captures: dict[str, UUID] = ( + await rebuild_open_captures(deps, list_runs=app.state.run.list_runs) + if capture_watch_codes + else {} + ) try: async with ( @@ -1118,6 +1134,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: run_watcher_lifespan( observer=capture_watch_observer, capture_codes=capture_watch_codes, + deps=deps, + record_watched_run=app.state.run.record_watched_run, + open_captures=open_captures, ), ): yield diff --git a/apps/api/tests/integration/test_run_watcher_rebuild_postgres.py b/apps/api/tests/integration/test_run_watcher_rebuild_postgres.py new file mode 100644 index 00000000000..76003dd2029 --- /dev/null +++ b/apps/api/tests/integration/test_run_watcher_rebuild_postgres.py @@ -0,0 +1,147 @@ +"""Postgres integration test for `rebuild_open_captures` +(cora.api._run_watcher). + +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_watched_run_handler_postgres.py` exercises. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.api._run_watcher 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_watched_run import RecordWatchedRun, 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 (RunWatcher 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_watcher_rebuild", + site_id=_2BM_UNIT_ID, + plan_id=_PLAN_ID, + plan_name="2BM_watched_tomography_plan_watcher_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_watched_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( + RecordWatchedRun( + name="2BM watched 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/unit/api/test_run_watcher.py b/apps/api/tests/unit/api/test_run_watcher.py index e4f0aab3d51..744fb5fc037 100644 --- a/apps/api/tests/unit/api/test_run_watcher.py +++ b/apps/api/tests/unit/api/test_run_watcher.py @@ -19,16 +19,34 @@ 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_watcher import observe_capture, run_run_watcher, run_watcher_lifespan +from cora.api._run_watcher import ( + RUN_WATCHER_MONITOR_SOURCE_ID, + RunWatcherRecorder, + observe_capture, + rebuild_open_captures, + run_run_watcher, + run_watcher_lifespan, +) +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_watched_run.command import RecordWatchedRun 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( @@ -193,3 +211,379 @@ async def test_lifespan_spawns_and_cleanly_cancels_the_background_task() -> None await asyncio.sleep(0.02) events = [entry["event"] for entry in logs] assert "run_watcher.capture_ended" in events + + +class _FakeRecordWatchedRun: + """Fake `record_watched_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[RecordWatchedRun] = [] + + async def __call__( + self, + command: RecordWatchedRun, + *, + 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_watched_run: _FakeRecordWatchedRun, + run_watcher_recording_enabled: bool = True, + capture_watch_plan_id: UUID | None = _PLAN_ID, + open_captures: dict[str, UUID] | None = None, +) -> RunWatcherRecorder: + settings = Settings( # type: ignore[call-arg] + run_watcher_recording_enabled=run_watcher_recording_enabled, + capture_watch_plan_id=capture_watch_plan_id, + ) + return RunWatcherRecorder( + deps=build_deps(ids=[uuid4() for _ in range(10)]), + record_watched_run=record_watched_run, + settings=settings, + open_captures=open_captures, + ) + + +@pytest.mark.unit +async def test_run_watcher_recorder_promotes_a_begun_capture_while_idle() -> None: + fake = _FakeRecordWatchedRun() + recorder = _recorder(record_watched_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_WATCHER_MONITOR_SOURCE_ID + + +@pytest.mark.unit +async def test_run_watcher_recorder_does_not_repromote_a_begun_capture_while_open() -> None: + fake = _FakeRecordWatchedRun() + recorder = _recorder(record_watched_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_watcher_recorder_stays_idle_after_a_promotion_failure() -> None: + fake = _FakeRecordWatchedRun(raises=RuntimeError("clearance refused")) + recorder = _recorder(record_watched_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_watcher_recorder_logs_a_distinct_event_on_unauthorized() -> None: + fake = _FakeRecordWatchedRun(raises=UnauthorizedError("not granted")) + recorder = _recorder(record_watched_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_watcher.promotion_unauthorized" in events + + +@pytest.mark.unit +async def test_run_watcher_recorder_clears_on_ended_while_open() -> None: + run_id = uuid4() + fake = _FakeRecordWatchedRun(run_id=run_id) + recorder = _recorder(record_watched_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_watcher_recorder_clears_on_aborted_while_open() -> None: + run_id = uuid4() + fake = _FakeRecordWatchedRun(run_id=run_id) + recorder = _recorder(record_watched_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_watcher_recorder_noop_on_ended_while_idle() -> None: + fake = _FakeRecordWatchedRun() + recorder = _recorder(record_watched_run=fake) + + await recorder.observe_capture(_obs(reported_status="Scan complete", phase=CapturePhase.ENDED)) + + assert fake.calls == [] + + +@pytest.mark.unit +async def test_run_watcher_recorder_noop_on_aborted_while_idle() -> None: + fake = _FakeRecordWatchedRun() + recorder = _recorder(record_watched_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_watcher_recorder_noop_on_progressing_regardless_of_state( + preopened: bool, +) -> None: + fake = _FakeRecordWatchedRun() + open_captures = {_CODE: uuid4()} if preopened else None + recorder = _recorder(record_watched_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_watcher_recorder_noop_on_unrecognized_regardless_of_state( + preopened: bool, +) -> None: + fake = _FakeRecordWatchedRun() + open_captures = {_CODE: uuid4()} if preopened else None + recorder = _recorder(record_watched_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_watcher_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 = _FakeRecordWatchedRun() + open_captures = {_CODE: uuid4()} if preopened else None + recorder = _recorder(record_watched_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_watcher_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 = _FakeRecordWatchedRun() + recorder = _recorder(record_watched_run=fake, run_watcher_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_watcher_lifespan_seeds_open_captures_from_the_supplied_map() -> None: + run_id = uuid4() + fake = _FakeRecordWatchedRun() + observer = _FakeObserver([_obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN)]) + deps = build_deps() + + async with run_watcher_lifespan( + observer=observer, + capture_codes=frozenset({_CODE}), + deps=deps, + record_watched_run=fake, + open_captures={_CODE: run_id}, + ): + await asyncio.sleep(0.02) + + assert fake.calls == [] + + +@pytest.mark.unit +async def test_run_watcher_lifespan_rejects_a_handler_without_deps() -> None: + with pytest.raises(ValueError, match="requires deps"): + async with run_watcher_lifespan( + observer=_FakeObserver([]), + capture_codes=frozenset({_CODE}), + record_watched_run=_FakeRecordWatchedRun(), + ): + 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 = "Recorded") -> 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_watched_run_started(deps: Any, *, run_id: UUID, capture_code: str) -> None: + """Directly append a RunStarted(conduct_mode=RECORDED) event carrying + the given capture_code as an external_ref, mirroring the shipped + `test_initiator_tick_counts_a_watched_recorded_run_toward_max_in_flight` + seeding pattern (record_watched_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.RECORDED, + 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_watched_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_watched_run_started(deps, run_id=run_id_a, capture_code="code-a") + await _append_watched_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.RECORDED, + ) + 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 == {} From a94151559e277c259b4ce8693d9a0be7eb25803a Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:32:41 -0500 Subject: [PATCH 16/20] pilot_seed: register the fly_scan acquisition recipe Seeds the actual 2-BM TomoScan workflow (fly_scan) as a third rung on the acquisition recipe ladder, alongside the existing dark_field / flat_field baseline captures. Watch-only: no operator REST/UI surface ever selects this Plan for start_run, matching record_watched_run's own stub route/tool, so capture_watch_plan_id has something real to point at once a deployment turns recording on. --- apps/api/src/cora/api/pilot_seed.py | 8 ++++++++ .../tests/integration/test_pilot_seed_postgres.py | 12 +++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/api/src/cora/api/pilot_seed.py b/apps/api/src/cora/api/pilot_seed.py index 1a360fa4b7b..f86dd7a0cd2 100644 --- a/apps/api/src/cora/api/pilot_seed.py +++ b/apps/api/src/cora/api/pilot_seed.py @@ -915,6 +915,14 @@ async def deprecate_plan_if_present(old_plan_name: str) -> None: await seed_acquisition_recipe( "flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan_v2" ) + # The actual 2-BM TomoScan workflow the RunWatcher's promotion path + # (cora.api._run_watcher) 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_watched_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. + await seed_acquisition_recipe("fly_scan", "2BM_fly_scan_practice", "2BM_fly_scan_plan_v1") _ = root if not dry_run: diff --git a/apps/api/tests/integration/test_pilot_seed_postgres.py b/apps/api/tests/integration/test_pilot_seed_postgres.py index 667ce58dfea..3bbfeb458e4 100644 --- a/apps/api/tests/integration/test_pilot_seed_postgres.py +++ b/apps/api/tests/integration/test_pilot_seed_postgres.py @@ -151,10 +151,13 @@ async def test_dry_run_writes_nothing_beyond_bootstrap(seed_database: SeedDataba 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[])", @@ -174,15 +177,17 @@ async def test_seeded_camera_carries_capturing_through_the_real_lookup( assert "Capturing" in asset.family_affordances -async def test_seeded_ladder_resolves_for_both_acquisition_recipes( +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 both Plans without + superset + affordance-cover checks) accepted every Plan without raising, and each Plan binds exactly the StationShutter + the acquisition camera -- the two Assets `docs/deployments/2-bm/ - recipes.md`'s dark_field / flat_field recipes actually target.""" + recipes.md`'s dark_field / flat_field recipes actually target, and + the same pair the fly_scan recipe (the real TomoScan workflow the + RunWatcher's promotion path watches) reuses.""" pool, url = seed_database assert await _run_ceremony(url) == 2 @@ -204,6 +209,7 @@ async def test_seeded_ladder_resolves_for_both_acquisition_recipes( 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) From 6270a5376fefadc8a121bf454f968717efae5fa0 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:08:00 -0500 Subject: [PATCH 17/20] api: guard the RunWatcher restart-rebuild against a missing authz grant Gate-review finding (security/authz lens): rebuild_open_captures ran unconditionally at boot with no exception handling, so a deployment that enabled run_watcher_enabled under enforced TrustAuthorize but forgot to grant the seeded RunWatcher agent ListRuns would crash the entire API's boot, not just this feature. Every sibling watcher probes its read grant via probe_read_grant and stays resilient in its own per-tick loop; this one-time boot read had neither. Adds the same probe_read_grant call (refuses boot with a clear message in strict mode) plus a fallback to an empty dedup map on UnauthorizedError in non-strict mode, so a missing grant degrades to "cold start, one possible re-promotion" rather than taking down every other bounded context's routes. --- apps/api/src/cora/api/main.py | 43 +++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 1a6465f4957..4d91e32b3c9 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -50,6 +50,7 @@ ) from cora.access.adapters import EventStorePrincipalLivenessLookup from cora.agent import ( + RUN_WATCHER_AGENT_ID, AgentHandlers, build_llm, refresh_language_model_pricing, @@ -84,6 +85,7 @@ 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 ( @@ -226,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, @@ -1075,11 +1080,39 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # currently-Running Recorded 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. - open_captures: dict[str, UUID] = ( - await rebuild_open_captures(deps, list_runs=app.state.run.list_runs) - if capture_watch_codes - else {} - ) + # 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_WATCHER_AGENT_ID, + read_command="ListRuns", + log_prefix="run_watcher", + 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_watcher.rebuild_unauthorized", + reason=( + "ListRuns grant missing for RUN_WATCHER_AGENT_ID; " + "starting with an empty dedup map" + ), + ) try: async with ( From c3508acfc5171ce68968a45a4cf3e6157ac2acc3 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:36:56 -0500 Subject: [PATCH 18/20] record_export: drop SafetyEnvelopeVerdict's two bools from export Gate-review finding (record/publishing lens): enclosure_permitted and beam_available exported unredacted (keep:number) purely because they are bool-typed, while the equivalent live PSS/interlock fact on EnclosurePermitObserved (from_status/to_status) is drop:text. Nobody made a deliberate export-sensitivity call for the new fields; the generator's type-driven default decided it. Adds _OVERRIDE_DISPOSITIONS to gen_record_dispositions.py (sibling to the existing _OVERRIDE_WIRE_KEYS per-field override table) so the two fields classify as drop:text like their EnclosurePermitObserved counterpart, while staying genuinely typed bool at the source (no str coercion just to game the classifier). Regenerated _dispositions.py; the diff is exactly the two flipped entries. --- .../record_export/_dispositions.py | 4 +- .../record_export/test_redact_tier1.py | 39 +++++++++++++++++++ apps/api/tools/gen_record_dispositions.py | 33 +++++++++++++++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/apps/api/src/cora/infrastructure/record_export/_dispositions.py b/apps/api/src/cora/infrastructure/record_export/_dispositions.py index 8f8f26668c1..a786e364763 100644 --- a/apps/api/src/cora/infrastructure/record_export/_dispositions.py +++ b/apps/api/src/cora/infrastructure/record_export/_dispositions.py @@ -1631,8 +1631,8 @@ "raid": "drop:text", "run_id": "token:uuid", "safety_envelope_verdict": { - "beam_available": "keep:number", - "enclosure_permitted": "keep:number", + "beam_available": "drop:text", + "enclosure_permitted": "drop:text", }, "subject_id": "token:uuid", "trigger_source": "drop:text", 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..e9058614117 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": "Recorded", + "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"] == "Recorded" + + 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/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 From 980754ced3bfdd4021543884ac03b4ea00c5296a Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:39:39 -0500 Subject: [PATCH 19/20] pilot_seed: bind the fly_scan recipe to a Rotary stage Asset Gate-review finding (beamline domain lens): the seeded fly_scan recipe required only a Shutter + Camera, the same pair as the two static dark_field/flat_field baseline captures. A real fly-scan's defining feature is continuous sample rotation; omitting the Rotary stage meant an exported watched Run record would understate the equipment a real 2-BM TomoScan fly-scan actually uses. Seeds a RotaryStage-family Device Asset (RotaryStage is already a globally-bootstrapped family) and threads it through seed_acquisition_recipe via a new include_rotary flag, used only by the fly_scan call; dark_field/flat_field are unchanged. --- apps/api/src/cora/api/pilot_seed.py | 89 +++++++++++++++---- .../integration/test_pilot_seed_postgres.py | 25 ++++-- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/apps/api/src/cora/api/pilot_seed.py b/apps/api/src/cora/api/pilot_seed.py index f86dd7a0cd2..e93e6715af2 100644 --- a/apps/api/src/cora/api/pilot_seed.py +++ b/apps/api/src/cora/api/pilot_seed.py @@ -231,6 +231,7 @@ async def seed_pilot_beamline( 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 @@ -537,6 +538,23 @@ async def decommission_if_present(old_id: UUID, label: str) -> None: 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: @@ -544,16 +562,30 @@ async def decommission_if_present(old_id: UUID, label: str) -> None: 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( @@ -754,16 +786,26 @@ async def seed_genesis( reload=lambda: load_capability(kernel.event_store, capability_id), ) - # Both recipes need exactly the two Assets seeded above; no - # Scintillator or other microscope-family requirement, unlike + # 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). + # 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 + 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", @@ -774,7 +816,7 @@ async def seed_acquisition_recipe( name=method_name, capability_id=capability_id, execution_pattern=ExecutionPattern.BATCH, - needed_family_ids=recipe_family_ids, + needed_family_ids=needed_family_ids, ), capability=capability, now=clock.now(), @@ -824,25 +866,31 @@ def build_plan_events() -> list[Any]: 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={ - shutter_id: shutter, - acquisition_camera_id: acquisition_camera, - }, + assets=assets, capability=capability, - family_affordances={ - shutter_family_id: shutter_family.affordances, - family_id: family.affordances, - }, + family_affordances=family_affordances, ) return decide_plan( state=None, command=DefinePlan( name=plan_name, practice_id=practice_id, - asset_ids=frozenset({shutter_id, acquisition_camera_id}), + asset_ids=frozenset(assets), ), context=context, now=clock.now(), @@ -921,8 +969,15 @@ async def deprecate_plan_if_present(old_plan_name: str) -> None: # conducted: no operator REST/UI surface ever selects this Plan for # start_run, matching record_watched_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. - await seed_acquisition_recipe("fly_scan", "2BM_fly_scan_practice", "2BM_fly_scan_plan_v1") + # 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: @@ -1001,6 +1056,7 @@ def build_parser() -> argparse.ArgumentParser: # 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 @@ -1017,6 +1073,7 @@ def main(argv: list[str] | None = None) -> int: 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/tests/integration/test_pilot_seed_postgres.py b/apps/api/tests/integration/test_pilot_seed_postgres.py index 3bbfeb458e4..7a149acc551 100644 --- a/apps/api/tests/integration/test_pilot_seed_postgres.py +++ b/apps/api/tests/integration/test_pilot_seed_postgres.py @@ -56,6 +56,7 @@ _CAMERA = "Camera" _SHUTTER = "StationShutter" _ACQUISITION_CAMERA = "AcquisitionCamera" +_ROTARY_STAGE = "RotaryStage" @pytest.fixture(autouse=True) @@ -148,6 +149,7 @@ async def test_dry_run_writes_nothing_beyond_bootstrap(seed_database: SeedDataba 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"), @@ -183,11 +185,12 @@ async def test_seeded_ladder_resolves_for_all_acquisition_recipes( """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, and each Plan binds exactly the StationShutter + the - acquisition camera -- the two Assets `docs/deployments/2-bm/ - recipes.md`'s dark_field / flat_field recipes actually target, and - the same pair the fly_scan recipe (the real TomoScan workflow the - RunWatcher's promotion path watches) reuses.""" + 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 RunWatcher'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 @@ -196,16 +199,24 @@ async def test_seeded_ladder_resolves_for_all_acquisition_recipes( 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): + 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"), @@ -226,7 +237,7 @@ async def test_seeded_ladder_resolves_for_all_acquisition_recipes( plan = await load_plan(event_store, plan_id) assert plan is not None assert plan.practice_id == practice_id - assert plan.asset_ids == frozenset({shutter_id, acquisition_camera_id}) + assert plan.asset_ids == expected_asset_ids[method_name] capability = await load_capability(event_store, capability_id) assert capability is not None From bdbe2d403ea51f6381bd056aca718c5e8da0aac1 Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:09:10 -0500 Subject: [PATCH 20/20] run: rename ConductMode.RECORDED to WITNESSED, and the watched-genesis cluster to witnessed (RunWatcher -> RunWitness) Executes a naming decision locked in a separate session (project_conduct_mode_witnessed_rename.md): RECORDED was not actually a contrast pair with CONDUCTED, since every Conducted Run is also recorded (in the event store, in proj_run_summary, in the export bundle). WITNESSED is the only candidate mutually exclusive with CONDUCTED, and is already the vocabulary the decider's own governing rule uses ("refuse on what CORA can fix, witness what CORA cannot"). That memo's tripwire fires when a deployment is about to enable run_watcher_enabled, which is exactly the next step after this follow-up's five commits landed live promotion. Executing the sweep now (worktree is clean, no in-flight edits to reconcile by hand) rather than deferring further. Cascades from the enum rename: RunWatcher -> RunWitness (runtime class, module, seeded agent identity, settings, log namespace), record_watched_run -> record_witnessed_run (feature slice, command, context/events classes), and prose throughout ("watched genesis" -> "witnessed genesis"). Also fixes a separate pre-existing bug the memo called out: trigger_source referenced a class (CaptureWatcher) that never existed in the codebase; it now names the real runtime (RunWitness). Naming-reviewed before landing (naming-r3-reviewer, two passes): first pass found the RunWatcherRecorder.observe/RunWatcherRecorder naming (now RunWitnessRecorder.observe_capture), rebuild_open_capture_map -> rebuild_open_captures, and _CLOSING_PHASES -> _TERMINAL_PHASES from the original commit. Second pass (this rename) found several persisted strings and half-renamed prose (mixed CONDUCTED/witnessed vocabulary in the same sentence), all fixed, plus a genuine architecture-fitness gap: RunWitness doesn't end in any R5 doer-suffix (-er/-or/-ist/-ant/-tor) the existing test/test_agent_kind_doer_form.py checked, so a new _ZERO_CHANGE_DOER_WORDS allowlist was added for whole-word doer nouns like Monitor/Coordinator's siblings. --- apps/api/src/cora/agent/__init__.py | 10 +- ...eed_run_watcher.py => seed_run_witness.py} | 68 +++---- apps/api/src/cora/api/_run_initiator.py | 12 +- apps/api/src/cora/api/_run_supervisor.py | 2 +- .../api/{_run_watcher.py => _run_witness.py} | 138 ++++++------- apps/api/src/cora/api/main.py | 56 +++--- apps/api/src/cora/api/pilot_seed.py | 6 +- apps/api/src/cora/infrastructure/config.py | 26 +-- .../api/src/cora/run/aggregates/run/events.py | 8 +- .../run/aggregates/run/safety_envelope.py | 18 +- apps/api/src/cora/run/aggregates/run/state.py | 35 ++-- apps/api/src/cora/run/features/__init__.py | 4 +- .../cora/run/features/list_runs/handler.py | 4 +- .../src/cora/run/features/list_runs/query.py | 6 +- .../features/record_watched_run/__init__.py | 31 --- .../features/record_witnessed_run/__init__.py | 31 +++ .../command.py | 20 +- .../context.py | 14 +- .../decider.py | 28 +-- .../handler.py | 32 +-- .../route.py | 8 +- .../tool.py | 8 +- .../cora/run/features/start_run/command.py | 4 +- .../cora/run/features/start_run/decider.py | 4 +- apps/api/src/cora/run/ports/__init__.py | 2 +- apps/api/src/cora/run/routes.py | 8 +- apps/api/src/cora/run/tools.py | 8 +- apps/api/src/cora/run/wire.py | 12 +- .../architecture/test_agent_kind_doer_form.py | 27 ++- .../test_command_name_derives_event_name.py | 4 +- .../test_run_conduct_mode_always_declared.py | 16 +- .../architecture/test_slice_test_coverage.py | 10 +- ...test_witnessed_genesis_laundering_wall.py} | 39 ++-- .../scenarios/test_2bm_run_initiator_tick.py | 24 +-- .../test_list_runs_handler_postgres.py | 20 +- .../integration/test_pilot_seed_postgres.py | 2 +- ..._record_witnessed_run_handler_postgres.py} | 30 +-- ...y => test_run_witness_rebuild_postgres.py} | 20 +- .../test_start_run_handler_postgres.py | 2 +- ...tcher_seed.py => test_run_witness_seed.py} | 54 ++--- .../api/tests/unit/api/test_run_supervisor.py | 12 +- ...est_run_watcher.py => test_run_witness.py} | 184 +++++++++--------- ....py => test_run_witness_recording_gate.py} | 54 ++--- .../record_export/test_redact_tier1.py | 4 +- .../tests/unit/run/test_list_runs_handler.py | 4 +- ...y => test_record_witnessed_run_decider.py} | 76 ++++---- ...ecord_witnessed_run_decider_properties.py} | 50 ++--- apps/api/tests/unit/run/test_run_events.py | 2 +- apps/api/tests/unit/run/test_run_evolver.py | 16 +- .../unit/run/test_run_summary_projection.py | 14 +- .../tests/unit/run/test_safety_envelope.py | 4 +- .../tests/unit/run/test_start_run_decider.py | 4 +- apps/api/tests/unit/test_settings.py | 4 +- docs/reference/modeling.md | 2 +- ...1035_add_proj_run_summary_conduct_mode.sql | 11 +- infra/atlas/migrations/atlas.sum | 4 +- 56 files changed, 662 insertions(+), 634 deletions(-) rename apps/api/src/cora/agent/{seed_run_watcher.py => seed_run_witness.py} (62%) rename apps/api/src/cora/api/{_run_watcher.py => _run_witness.py} (74%) delete mode 100644 apps/api/src/cora/run/features/record_watched_run/__init__.py create mode 100644 apps/api/src/cora/run/features/record_witnessed_run/__init__.py rename apps/api/src/cora/run/features/{record_watched_run => record_witnessed_run}/command.py (77%) rename apps/api/src/cora/run/features/{record_watched_run => record_witnessed_run}/context.py (87%) rename apps/api/src/cora/run/features/{record_watched_run => record_witnessed_run}/decider.py (90%) rename apps/api/src/cora/run/features/{record_watched_run => record_witnessed_run}/handler.py (89%) rename apps/api/src/cora/run/features/{record_watched_run => record_witnessed_run}/route.py (66%) rename apps/api/src/cora/run/features/{record_watched_run => record_witnessed_run}/tool.py (73%) rename apps/api/tests/architecture/{test_watched_genesis_laundering_wall.py => test_witnessed_genesis_laundering_wall.py} (75%) rename apps/api/tests/integration/{test_record_watched_run_handler_postgres.py => test_record_witnessed_run_handler_postgres.py} (86%) rename apps/api/tests/integration/{test_run_watcher_rebuild_postgres.py => test_run_witness_rebuild_postgres.py} (90%) rename apps/api/tests/unit/agent/{test_run_watcher_seed.py => test_run_witness_seed.py} (59%) rename apps/api/tests/unit/api/{test_run_watcher.py => test_run_witness.py} (75%) rename apps/api/tests/unit/api/{test_run_watcher_recording_gate.py => test_run_witness_recording_gate.py} (54%) rename apps/api/tests/unit/run/{test_record_watched_run_decider.py => test_record_witnessed_run_decider.py} (88%) rename apps/api/tests/unit/run/{test_record_watched_run_decider_properties.py => test_record_witnessed_run_decider_properties.py} (85%) diff --git a/apps/api/src/cora/agent/__init__.py b/apps/api/src/cora/agent/__init__.py index e00bf53bfa9..e6cebed4ad4 100644 --- a/apps/api/src/cora/agent/__init__.py +++ b/apps/api/src/cora/agent/__init__.py @@ -87,9 +87,9 @@ RUN_SUPERVISOR_AGENT_ID, seed_run_supervisor_agent, ) -from cora.agent.seed_run_watcher import ( - RUN_WATCHER_AGENT_ID, - seed_run_watcher_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 @@ -106,7 +106,7 @@ "RATIFICATION_ENFORCER_AGENT_ID", "RUN_INITIATOR_AGENT_ID", "RUN_SUPERVISOR_AGENT_ID", - "RUN_WATCHER_AGENT_ID", + "RUN_WITNESS_AGENT_ID", "AgentHandlers", "CautionProposalMalformedError", "CautionProposalNotActionableError", @@ -134,6 +134,6 @@ "seed_run_debriefer_agent", "seed_run_initiator_agent", "seed_run_supervisor_agent", - "seed_run_watcher_agent", + "seed_run_witness_agent", "wire_agent", ] diff --git a/apps/api/src/cora/agent/seed_run_watcher.py b/apps/api/src/cora/agent/seed_run_witness.py similarity index 62% rename from apps/api/src/cora/agent/seed_run_watcher.py rename to apps/api/src/cora/agent/seed_run_witness.py index ea19877cd96..f215ce5ece6 100644 --- a/apps/api/src/cora/agent/seed_run_watcher.py +++ b/apps/api/src/cora/agent/seed_run_witness.py @@ -1,9 +1,9 @@ -"""Bootstrap-time seed for the RunWatcher Agent. +"""Bootstrap-time seed for the RunWitness Agent. -The RunWatcher runtime (`cora.api._run_watcher`) needs an Agent record -(and its co-registered Actor) to exist at the pinned `RUN_WATCHER_AGENT_ID` -so it can issue `RecordWatchedRun` as an agent-kind principal when it -promotes a BEGUN capture observation to a real watched Run. Mirrors +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`. @@ -15,14 +15,14 @@ (`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 `RecordWatchedRun` through the + - 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 + {RecordWatchedRun, ListRuns}. + 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 RecordWatchedRun grant, a real BEGUN observation - logs `run_watcher.promotion_unauthorized` and stays IDLE (retried on + 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). """ @@ -39,21 +39,21 @@ # --------------------------------------------------------------------------- -# RunWatcher agent identity (deployment-stable constants) +# 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 RunWatcher-authored Run's principal_id pointer. +# orphans every prior RunWitness-authored Run's principal_id pointer. # UUID continues the `1111`-opened numeric range at `2222` (next unclaimed # block). -RUN_WATCHER_AGENT_ID = UUID("01900000-0000-7000-8000-000022220010") -RUN_WATCHER_AGENT_NAME = "RunWatcher" -RUN_WATCHER_AGENT_KIND = "RunWatcher" -RUN_WATCHER_AGENT_VERSION = "1.0.0" -RUN_WATCHER_AGENT_DESCRIPTION = ( - "Deterministic in-process runtime: promotes a real watched (Recorded) " - "Run via record_watched_run when it observes an external tool " +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 " @@ -61,12 +61,12 @@ ) -# Sentinel model ref: RunWatcher is rule-based, not an LLM agent. The +# 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:RunWatcher:v1", + model="agent:RunWitness:v1", snapshot_pin=None, ) @@ -80,29 +80,29 @@ _BOOTSTRAP_CORRELATION_ID = UUID("01900000-0000-7000-8000-000022220014") -async def seed_run_watcher_agent(kernel: Kernel) -> None: - """Seed the RunWatcher Agent + co-registered Actor (idempotent).""" +async def seed_run_witness_agent(kernel: Kernel) -> None: + """Seed the RunWitness Agent + co-registered Actor (idempotent).""" identity = AgentSeedIdentity( - agent_id=RUN_WATCHER_AGENT_ID, - name=RUN_WATCHER_AGENT_NAME, - kind=RUN_WATCHER_AGENT_KIND, - version=RUN_WATCHER_AGENT_VERSION, - description=RUN_WATCHER_AGENT_DESCRIPTION, + 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="SeedRunWatcherAgent", + command_name="SeedRunWitnessAgent", ) await seed_agent(kernel, identity) __all__ = [ - "RUN_WATCHER_AGENT_DESCRIPTION", - "RUN_WATCHER_AGENT_ID", - "RUN_WATCHER_AGENT_KIND", - "RUN_WATCHER_AGENT_NAME", - "RUN_WATCHER_AGENT_VERSION", - "seed_run_watcher_agent", + "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/_run_initiator.py b/apps/api/src/cora/api/_run_initiator.py index ca938b8f9c4..5838aafb0b7 100644 --- a/apps/api/src/cora/api/_run_initiator.py +++ b/apps/api/src/cora/api/_run_initiator.py @@ -276,12 +276,12 @@ async def initiate_tick( `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 watched (Recorded) Run because CORA - does not control it, this cap exists to serialize access to shared - single-stage hardware. A watched 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. + 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 diff --git a/apps/api/src/cora/api/_run_supervisor.py b/apps/api/src/cora/api/_run_supervisor.py index 23e9c64a214..76c98170f13 100644 --- a/apps/api/src/cora/api/_run_supervisor.py +++ b/apps/api/src/cora/api/_run_supervisor.py @@ -1177,7 +1177,7 @@ async def _supervise_tick( principal_id=RUN_SUPERVISOR_AGENT_ID, reason=str(err), ) from err - # Watched (Recorded) Runs are not the supervisor's to hold, resume, + # 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 diff --git a/apps/api/src/cora/api/_run_watcher.py b/apps/api/src/cora/api/_run_witness.py similarity index 74% rename from apps/api/src/cora/api/_run_watcher.py rename to apps/api/src/cora/api/_run_witness.py index 7238b761c5b..8ebcba84e67 100644 --- a/apps/api/src/cora/api/_run_watcher.py +++ b/apps/api/src/cora/api/_run_witness.py @@ -1,15 +1,15 @@ -"""RunWatcher runtime: shadow-observe an external tool's captures, and +"""RunWitness runtime: shadow-observe an external tool's captures, and (behind a second, independent kill switch) promote a BEGUN capture to a -real watched Run. +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_watcher_recording_enabled` is turned on) +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_watched_run`, +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 `RunWatcherRecorder`). +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 @@ -17,15 +17,15 @@ ## Log lines, one per observation -- `run_watcher.capture_begun` -- `run_watcher.capture_progressing` -- `run_watcher.capture_ended` -- `run_watcher.capture_aborted` -- `run_watcher.capture_unrecognized`: `phase` is `UNRECOGNIZED`, meaning +- `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_watcher.capture_unreached`: `phase` is `None`, meaning this +- `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). @@ -35,11 +35,11 @@ time for an absent one). These log lines are unconditional: they fire identically whether or not recording is enabled. -## Promotion (when run_watcher_recording_enabled is True) +## 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_watched_run` + - `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. @@ -51,8 +51,8 @@ - `ENDED` / `ABORTED` while nothing is open, or `PROGRESSING` / `UNRECOGNIZED` / a `None` phase in any state: no-op. -`RunWatcherRecorder`'s dedup state is seeded once at boot (see -`rebuild_open_captures`) from every currently-Running Recorded Run's +`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. @@ -79,11 +79,11 @@ from typing import TYPE_CHECKING from uuid import UUID -from cora.agent.seed_run_watcher import RUN_WATCHER_AGENT_ID +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_watched_run.command import RecordWatchedRun +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 @@ -94,7 +94,7 @@ 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_watched_run.handler import Handler as RecordWatchedRunHandler + 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 @@ -106,15 +106,15 @@ # Single hardcoded literal, mirroring `ENCLOSURE_PERMIT_MONITOR_SOURCE_ID` # (`cora.enclosure._monitor`) exactly: there is exactly one in-process -# RunWatcher per deployment, so no derivation function is needed. -RUN_WATCHER_MONITOR_SOURCE_ID = MonitorSourceId(UUID("01900000-0000-7000-8000-000072756e01")) +# 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_watcher.capture_begun", - CapturePhase.PROGRESSING: "run_watcher.capture_progressing", - CapturePhase.ENDED: "run_watcher.capture_ended", - CapturePhase.ABORTED: "run_watcher.capture_aborted", - CapturePhase.UNRECOGNIZED: "run_watcher.capture_unrecognized", + 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) @@ -123,7 +123,7 @@ def observe_capture(observation: CaptureObservation) -> None: """Log one observation. The entire body of shadow mode: no writes.""" if observation.phase is None: - event = "run_watcher.capture_unreached" + event = "run_witness.capture_unreached" else: event = _PHASE_LOG_EVENT[observation.phase] _log.info( @@ -136,8 +136,8 @@ def observe_capture(observation: CaptureObservation) -> None: ) -class RunWatcherRecorder: - """Promotes a BEGUN observation to a watched Run when recording is +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 @@ -151,18 +151,18 @@ def __init__( self, *, deps: Kernel, - record_watched_run: RecordWatchedRunHandler, + record_witnessed_run: RecordWitnessedRunHandler, settings: Settings, open_captures: dict[str, UUID] | None = None, ) -> None: self._deps = deps - self._record_watched_run = record_watched_run + 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_watcher_recording_enabled: + if not self._settings.run_witness_recording_enabled: return code = observation.capture_code @@ -175,58 +175,58 @@ async def observe_capture(self, observation: CaptureObservation) -> None: elif phase in _TERMINAL_PHASES: run_id = self._open_captures.pop(code, None) if run_id is not None: - _log.info("run_watcher.open_capture_cleared", capture_code=code, run_id=str(run_id)) + _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_watcher_recording_gate` + # 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_watcher.recording_enabled_without_plan_id", + "run_witness.recording_enabled_without_plan_id", capture_code=observation.capture_code, ) return - command = RecordWatchedRun( - name=f"Watched capture {observation.capture_code}", + command = RecordWitnessedRun( + name=f"Witnessed capture {observation.capture_code}", plan_id=plan_id, capture_code=observation.capture_code, - monitor_source_id=RUN_WATCHER_MONITOR_SOURCE_ID, + monitor_source_id=RUN_WITNESS_MONITOR_SOURCE_ID, trigger="Monitor", ) try: - run_id = await self._record_watched_run( + run_id = await self._record_witnessed_run( command, - principal_id=RUN_WATCHER_AGENT_ID, + principal_id=RUN_WITNESS_AGENT_ID, correlation_id=self._deps.id_generator.new_id(), ) except asyncio.CancelledError: raise except UnauthorizedError: - # Configuration fault: the RunWatcher principal is not - # granted RecordWatchedRun. Log loudly; stay unopened so the + # 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_watcher.promotion_unauthorized", + "run_witness.promotion_unauthorized", capture_code=observation.capture_code, ) return except Exception: _log.exception( - "run_watcher.promotion_failed", + "run_witness.promotion_failed", capture_code=observation.capture_code, ) return self._open_captures[observation.capture_code] = run_id _log.info( - "run_watcher.promoted", + "run_witness.promoted", capture_code=observation.capture_code, run_id=str(run_id), ) @@ -235,8 +235,8 @@ async def _promote(self, observation: CaptureObservation) -> None: def _extract_capture_code(external_refs: frozenset[Identifier]) -> str | None: """Find the `Identifier(scheme="capture-code", ...)` entry's value. - Defensive: `record_watched_run`'s decider always stamps exactly one, - so `None` should not happen for a Recorded Run, but a missing ref + 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: @@ -246,10 +246,10 @@ def _extract_capture_code(external_refs: frozenset[Identifier]) -> str | None: async def rebuild_open_captures(deps: Kernel, *, list_runs: ListRunsHandler) -> dict[str, UUID]: - """Page through every Running, Recorded Run and return + """Page through every Running, Witnessed Run and return capture_code -> run_id for each one's `external_refs`. - Seeds `RunWatcherRecorder`'s dedup state once at boot so a capture + 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. @@ -262,11 +262,11 @@ async def rebuild_open_captures(deps: Kernel, *, list_runs: ListRunsHandler) -> page = await list_runs( ListRuns( status="Running", - conduct_mode="Recorded", + conduct_mode="Witnessed", cursor=cursor, limit=_PAGE_LIMIT, ), - principal_id=RUN_WATCHER_AGENT_ID, + principal_id=RUN_WITNESS_AGENT_ID, correlation_id=deps.id_generator.new_id(), ) for item in page.items: @@ -281,11 +281,11 @@ async def rebuild_open_captures(deps: Kernel, *, list_runs: ListRunsHandler) -> cursor = page.next_cursor -async def run_run_watcher( +async def run_witness_loop( *, observer: CaptureObserver, capture_codes: frozenset[str], - recorder: RunWatcherRecorder | None = None, + recorder: RunWitnessRecorder | None = None, reconnect_delay_seconds: float = _RECONNECT_DELAY_SECONDS, ) -> None: """Drain the observer, logging (and, with a recorder, promoting) @@ -305,23 +305,23 @@ async def run_run_watcher( raise except Exception: _log.exception( - "run_watcher.record_failed", + "run_witness.record_failed", capture_code=observation.capture_code, ) except asyncio.CancelledError: raise except Exception: - _log.exception("run_watcher.iteration_failed") + _log.exception("run_witness.iteration_failed") await asyncio.sleep(reconnect_delay_seconds) @contextlib.asynccontextmanager -async def run_watcher_lifespan( +async def run_witness_lifespan( *, observer: CaptureObserver, capture_codes: frozenset[str], deps: Kernel | None = None, - record_watched_run: RecordWatchedRunHandler | 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. @@ -334,28 +334,28 @@ async def run_watcher_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_watched_run` is also supplied. + `record_witnessed_run` is also supplied. """ if not capture_codes: yield return - if record_watched_run is not None and deps is None: - msg = "run_watcher_lifespan: record_watched_run requires deps" + if record_witnessed_run is not None and deps is None: + msg = "run_witness_lifespan: record_witnessed_run requires deps" raise ValueError(msg) - recorder: RunWatcherRecorder | None = None - if record_watched_run is not None: + recorder: RunWitnessRecorder | None = None + if record_witnessed_run is not None: assert deps is not None # narrowed by the check above - recorder = RunWatcherRecorder( + recorder = RunWitnessRecorder( deps=deps, - record_watched_run=record_watched_run, + record_witnessed_run=record_witnessed_run, settings=deps.settings, open_captures=open_captures, ) task = asyncio.create_task( - run_run_watcher(observer=observer, capture_codes=capture_codes, recorder=recorder), - name="run-watcher", + run_witness_loop(observer=observer, capture_codes=capture_codes, recorder=recorder), + name="run-witness", ) try: yield @@ -366,10 +366,10 @@ async def run_watcher_lifespan( __all__ = [ - "RUN_WATCHER_MONITOR_SOURCE_ID", - "RunWatcherRecorder", + "RUN_WITNESS_MONITOR_SOURCE_ID", + "RunWitnessRecorder", "observe_capture", "rebuild_open_captures", - "run_run_watcher", - "run_watcher_lifespan", + "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 4d91e32b3c9..f67fa08b04a 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -50,7 +50,7 @@ ) from cora.access.adapters import EventStorePrincipalLivenessLookup from cora.agent import ( - RUN_WATCHER_AGENT_ID, + RUN_WITNESS_AGENT_ID, AgentHandlers, build_llm, refresh_language_model_pricing, @@ -72,7 +72,7 @@ seed_run_debriefer_agent, seed_run_initiator_agent, seed_run_supervisor_agent, - seed_run_watcher_agent, + seed_run_witness_agent, wire_agent, ) from cora.agent.adapters import BudgetSpendGuard, PostgresLanguageModelLookup @@ -96,7 +96,7 @@ ) from cora.api._run_initiator import run_initiator_lifespan from cora.api._run_supervisor import run_supervisor_lifespan -from cora.api._run_watcher import rebuild_open_captures, run_watcher_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 ( @@ -413,28 +413,28 @@ def _enforce_production_principal_policy(settings: Settings) -> None: raise RuntimeError(msg) -def _enforce_run_watcher_recording_gate(settings: Settings) -> None: - """Refuse to boot with run_watcher_recording_enabled=True unless both +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_watcher_recording_enabled promotes a BEGUN capture observation to - a real watched Run; that promotion needs (a) the shadow watcher - itself running (run_watcher_enabled) to ever see an observation, and + 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_watched_run has + it the first time a real capture begins and record_witnessed_run has nowhere to point. """ - if not settings.run_watcher_recording_enabled: + if not settings.run_witness_recording_enabled: return missing: list[str] = [] - if not settings.run_watcher_enabled: - missing.append("RUN_WATCHER_ENABLED=true") + 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_WATCHER_RECORDING_ENABLED=true requires " + "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." @@ -536,7 +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_watcher_recording_gate(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 @@ -1007,8 +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 RunWatcher (deterministic capture-promotion agent). - await seed_run_watcher_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 @@ -1056,15 +1056,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: tick_seconds=settings.enclosure_permit_probe_tick_seconds, ) - # RunWatcher: shadow-observe an external tool's captures (2-BM + # RunWitness: shadow-observe an external tool's captures (2-BM # commissioning ladder rung 1), and (behind the SECOND, - # independent run_watcher_recording_enabled gate) promote a - # BEGUN capture to a real watched Run. run_watcher_enabled is + # 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_watcher_recording_enabled is also True. + # 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, @@ -1073,11 +1073,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: ) capture_watch_codes: frozenset[str] = ( frozenset(settings.capture_watch_pvs) - if settings.run_watcher_enabled + if settings.run_witness_enabled else frozenset() ) # Boot-time restart-rebuild: seed the dedup map from every - # currently-Running Recorded Run's external_refs, so a + # 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 @@ -1096,9 +1096,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: if capture_watch_codes: await probe_read_grant( deps, - agent_id=RUN_WATCHER_AGENT_ID, + agent_id=RUN_WITNESS_AGENT_ID, read_command="ListRuns", - log_prefix="run_watcher", + log_prefix="run_witness", strict=settings.watcher_authz_strict, ) try: @@ -1107,9 +1107,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: ) except RunUnauthorizedError: _log.warning( - "run_watcher.rebuild_unauthorized", + "run_witness.rebuild_unauthorized", reason=( - "ListRuns grant missing for RUN_WATCHER_AGENT_ID; " + "ListRuns grant missing for RUN_WITNESS_AGENT_ID; " "starting with an empty dedup map" ), ) @@ -1164,11 +1164,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: deps, list_campaigns=app.state.campaign.list_campaigns, ), - run_watcher_lifespan( + run_witness_lifespan( observer=capture_watch_observer, capture_codes=capture_watch_codes, deps=deps, - record_watched_run=app.state.run.record_watched_run, + record_witnessed_run=app.state.run.record_witnessed_run, open_captures=open_captures, ), ): diff --git a/apps/api/src/cora/api/pilot_seed.py b/apps/api/src/cora/api/pilot_seed.py index e93e6715af2..f30f38a3f2b 100644 --- a/apps/api/src/cora/api/pilot_seed.py +++ b/apps/api/src/cora/api/pilot_seed.py @@ -963,11 +963,11 @@ async def deprecate_plan_if_present(old_plan_name: str) -> None: await seed_acquisition_recipe( "flat_field", "2BM_flat_field_practice", "2BM_flat_field_plan_v2" ) - # The actual 2-BM TomoScan workflow the RunWatcher's promotion path - # (cora.api._run_watcher) watches: a fly-scan capture, distinct from + # 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_watched_run's own stub route/tool. + # 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, diff --git a/apps/api/src/cora/infrastructure/config.py b/apps/api/src/cora/infrastructure/config.py index baebee86979..00ce79e7d6f 100644 --- a/apps/api/src/cora/infrastructure/config.py +++ b/apps/api/src/cora/infrastructure/config.py @@ -779,29 +779,29 @@ class Settings(BaseSettings): # 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_watcher_recording_enabled` is ALSO True (see below). Default + # `run_witness_recording_enabled` is ALSO True (see below). Default # off; irrelevant when `capture_watch_pvs` is empty. See - # `cora.api._run_watcher`. - run_watcher_enabled: bool = False + # `cora.api._run_witness`. + run_witness_enabled: bool = False - # Which Plan a promoted watched Run references (record_watched_run's + # 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_watcher_recording_enabled` (see - # `_enforce_run_watcher_recording_gate` in `main.py`). + # `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_watcher_enabled`: - # shadow mode (drain + log) stays default-on once `run_watcher_enabled` + # 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 watched Run via `record_watched_run`. - # Default off, so enabling `run_watcher_enabled` alone stays + # 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_watcher_enabled=True` and `capture_watch_plan_id` set (see - # `_enforce_run_watcher_recording_gate` in `main.py`). - run_watcher_recording_enabled: bool = False + # `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 diff --git a/apps/api/src/cora/run/aggregates/run/events.py b/apps/api/src/cora/run/aggregates/run/events.py index 82368c41eff..0e268617d05 100644 --- a/apps/api/src/cora/run/aggregates/run/events.py +++ b/apps/api/src/cora/run/aggregates/run/events.py @@ -202,19 +202,19 @@ class RunStarted: # 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 watched genesis - # (the separate decider) hardcodes RECORDED. Defaults to CONDUCTED for + # 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 watched genesis's recorded reading of the two live facility + # 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_watched_run`'s decider + # 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. 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 1f0102a5f6e..d8700bb43cd 100644 --- a/apps/api/src/cora/run/aggregates/run/safety_envelope.py +++ b/apps/api/src/cora/run/aggregates/run/safety_envelope.py @@ -5,22 +5,22 @@ still hold for a held Run to be safely resumed. Living in the aggregate kernel (mirroring `plan.wires_validation.validate_wire_endpoints`) lets the `start_run` decider, the RunSupervisor's pre-resume re-check, AND the -watched-genesis decider import one definition, so a change to any gate +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, the supervisor runtime -for resume, or the watched-genesis handler) loads the cross-aggregate +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 watched-genesis +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 watched path exactly as they do on the driven +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. @@ -34,7 +34,7 @@ status, Asset-decommissioned, capability re-validation, wire endpoints, 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 watched +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). """ @@ -77,7 +77,7 @@ def clearance_gate_check( 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 watched Run (see `test_compute_shaped_scope_ + vacuous, and including a witnessed Run (see `test_compute_shaped_scope_ still_requires_active_clearance`; any future split must not exempt either). """ @@ -213,7 +213,7 @@ def check_safety_envelope( """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 watched genesis). Composed from + 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. @@ -244,11 +244,11 @@ def witness_safety_envelope( beam_availability: BeamAvailabilityLookupResult | None, ) -> SafetyEnvelopeVerdict: """Record a verdict on the two live facility signals instead of - enforcing them; used only by the watched-genesis decider. + 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 the watcher can observe from + 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 diff --git a/apps/api/src/cora/run/aggregates/run/state.py b/apps/api/src/cora/run/aggregates/run/state.py index 25aa7ef0eed..324bd153d59 100644 --- a/apps/api/src/cora/run/aggregates/run/state.py +++ b/apps/api/src/cora/run/aggregates/run/state.py @@ -288,12 +288,23 @@ class ConductMode(StrEnum): `CONDUCTED` is CORA's own Conductor driving the act (every Run started today, via `_run_initiator.py` or a phase-conduct bridge, - is Conducted). `RECORDED` is an externally-driven act CORA only - observes after the fact, for example a 2-BM tomoscan scan watched - by `RunWatcher` (see `cora.api._run_watcher`). Nothing - yet constructs a `RECORDED` Run: promoting a shadow observation - into one is deliberately a separate, not-yet-built module (see - `_run_watcher.py`'s own docstring). + 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, @@ -303,12 +314,12 @@ class ConductMode(StrEnum): """ CONDUCTED = "Conducted" - RECORDED = "Recorded" + WITNESSED = "Witnessed" @dataclass(frozen=True) class SafetyEnvelopeVerdict: - """A recorded reading of the two live facility signals at a watched + """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 @@ -365,18 +376,18 @@ def __init__(self, run_id: UUID) -> None: class RunMonitorTriggerNotPermittedError(Exception): - """`record_watched_run` carried a non-Monitor trigger. + """`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 watched genesis is reachable only via Monitor-driven inbound + 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 watched path. + spine through the witnessed path. HTTP 400 (semantically a request the caller cannot issue, not a state-transition conflict). @@ -385,7 +396,7 @@ class RunMonitorTriggerNotPermittedError(Exception): def __init__(self, run_id: UUID, trigger: str) -> None: super().__init__( f"Run {run_id}: trigger {trigger!r} is not permitted on " - f"record_watched_run; only 'Monitor' is accepted per the " + f"record_witnessed_run; only 'Monitor' is accepted per the " f"observation-axis-only anti-lock." ) self.run_id = run_id diff --git a/apps/api/src/cora/run/features/__init__.py b/apps/api/src/cora/run/features/__init__.py index 653acf1706a..1d45c979537 100644 --- a/apps/api/src/cora/run/features/__init__.py +++ b/apps/api/src/cora/run/features/__init__.py @@ -10,7 +10,7 @@ open-on-first-write) - 6j: adjust_run (mid-flight parameter steering; idempotency-wrapped; closes the autonomous-CT closed-loop steering gap) - - record_watched_run: the watched genesis (a second, independent - genesis alongside start_run). Hardcodes ConductMode.RECORDED; + - 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 cd9e3a7a4d9..e0d94d48840 100644 --- a/apps/api/src/cora/run/features/list_runs/handler.py +++ b/apps/api/src/cora/run/features/list_runs/handler.py @@ -67,9 +67,9 @@ class RunSummaryItem: """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 "Recorded"), immutable from + """`ConductMode` value ("Conducted" or "Witnessed"), immutable from genesis. The RunSupervisor and RunInitiator runtimes filter on this - to skip watched (Recorded) Runs: those are not theirs to hold, resume, + to skip Witnessed Runs: those are not theirs to hold, resume, truncate, or count toward in-flight limits.""" 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 7ff5d805fac..ca242b6bef6 100644 --- a/apps/api/src/cora/run/features/list_runs/query.py +++ b/apps/api/src/cora/run/features/list_runs/query.py @@ -3,7 +3,7 @@ Optional filters: status (Running / Held / Completed / Aborted / Stopped / Truncated), plan_id (which Plan was bound), campaign_id -(Campaign membership), and conduct_mode (Conducted / Recorded). Cursor +(Campaign membership), and conduct_mode (Conducted / Witnessed). Cursor encodes (created_at, run_id). """ @@ -44,6 +44,6 @@ class ListRuns: (omit) for "any Campaign or none".""" conduct_mode: str | None = None - """Optional `ConductMode` value filter ("Conducted" or "Recorded"). + """Optional `ConductMode` value filter ("Conducted" or "Witnessed"). Pass `None` (omit) for "any conduct mode". Lets a caller restrict to - watched (Recorded) Runs, e.g. the RunWatcher restart-rebuild query.""" + Witnessed Runs, e.g. the RunWitness restart-rebuild query.""" diff --git a/apps/api/src/cora/run/features/record_watched_run/__init__.py b/apps/api/src/cora/run/features/record_watched_run/__init__.py deleted file mode 100644 index d918d8d688f..00000000000 --- a/apps/api/src/cora/run/features/record_watched_run/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Vertical slice for the `RecordWatchedRun` command: the watched 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_watched_run - - cmd = record_watched_run.RecordWatchedRun( - name="...", plan_id=..., capture_code=..., monitor_source_id=..., - trigger="Monitor", - ) - handler = record_watched_run.bind(deps) - run_id = await handler(cmd, principal_id=..., correlation_id=...) -""" - -from cora.run.features.record_watched_run import tool -from cora.run.features.record_watched_run.command import RecordWatchedRun -from cora.run.features.record_watched_run.context import RunWatchedStartContext -from cora.run.features.record_watched_run.decider import decide -from cora.run.features.record_watched_run.handler import Handler, bind -from cora.run.features.record_watched_run.route import router - -__all__ = [ - "Handler", - "RecordWatchedRun", - "RunWatchedStartContext", - "bind", - "decide", - "router", - "tool", -] 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_watched_run/command.py b/apps/api/src/cora/run/features/record_witnessed_run/command.py similarity index 77% rename from apps/api/src/cora/run/features/record_watched_run/command.py rename to apps/api/src/cora/run/features/record_witnessed_run/command.py index 095b9844df8..79e64265eec 100644 --- a/apps/api/src/cora/run/features/record_watched_run/command.py +++ b/apps/api/src/cora/run/features/record_witnessed_run/command.py @@ -1,12 +1,12 @@ -"""The `RecordWatchedRun` command -- intent dataclass for this slice. +"""The `RecordWitnessedRun` command -- intent dataclass for this slice. -A watched genesis: CORA records that an external tool began a capture, +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 - watcher runtime resolves it from settings, not from the substrate); + 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 @@ -18,22 +18,22 @@ 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 - watcher runtime that produced this genesis, mirroring + 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- - Recorded backdoor (mirrors `ObserveEnclosureStatus.trigger`'s D6.L2 - anti-lock): there is no operator path to a watched genesis. + 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.RECORDED`, +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`: -a watcher has no operator inputs to pass, and every field this command +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. @@ -53,8 +53,8 @@ @dataclass(frozen=True) -class RecordWatchedRun: - """Record that an external tool began a capture: a watched Run genesis.""" +class RecordWitnessedRun: + """Record that an external tool began a capture: a witnessed Run genesis.""" name: str plan_id: UUID diff --git a/apps/api/src/cora/run/features/record_watched_run/context.py b/apps/api/src/cora/run/features/record_witnessed_run/context.py similarity index 87% rename from apps/api/src/cora/run/features/record_watched_run/context.py rename to apps/api/src/cora/run/features/record_witnessed_run/context.py index 751af9a634d..a035bcca2aa 100644 --- a/apps/api/src/cora/run/features/record_watched_run/context.py +++ b/apps/api/src/cora/run/features/record_witnessed_run/context.py @@ -1,12 +1,12 @@ -"""Cross-aggregate context the `record_watched_run` decider validates against. +"""Cross-aggregate context the `record_witnessed_run` decider validates against. -`RunWatchedStartContext` is the watched-genesis counterpart to `RunStartContext` +`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 a watcher has no operator inputs in those axes). +since RunWitness has no operator inputs in those axes). ## Field semantics @@ -28,13 +28,13 @@ 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 watched genesis without an Active Clearance still + 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 watched genesis without an Available Supply of a required + 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 @@ -61,8 +61,8 @@ @dataclass(frozen=True) -class RunWatchedStartContext: - """Snapshot of upstream aggregate state at a watched Run's genesis.""" +class RunWitnessedStartContext: + """Snapshot of upstream aggregate state at a witnessed Run's genesis.""" plan: Plan subject: Subject | None diff --git a/apps/api/src/cora/run/features/record_watched_run/decider.py b/apps/api/src/cora/run/features/record_witnessed_run/decider.py similarity index 90% rename from apps/api/src/cora/run/features/record_watched_run/decider.py rename to apps/api/src/cora/run/features/record_witnessed_run/decider.py index 3b290c8bbeb..fd802889e31 100644 --- a/apps/api/src/cora/run/features/record_watched_run/decider.py +++ b/apps/api/src/cora/run/features/record_witnessed_run/decider.py @@ -1,10 +1,10 @@ -"""Pure decider for the `RecordWatchedRun` command: the watched genesis. +"""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.RECORDED`. Never the -other way around: `RecordWatchedRun` carries no `conduct_mode` field for +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 @@ -83,8 +83,8 @@ validate_effective_parameters_against_method_schema, witness_safety_envelope, ) -from cora.run.features.record_watched_run.command import RecordWatchedRun -from cora.run.features.record_watched_run.context import RunWatchedStartContext +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 @@ -99,25 +99,25 @@ @dataclass(frozen=True) -class RunWatchedStartEvents: - """Events produced by a watched Run genesis: always exactly one.""" +class RunWitnessedStartEvents: + """Events produced by a witnessed Run genesis: always exactly one.""" run_events: list[RunStarted] def decide( state: Run | None, - command: RecordWatchedRun, + command: RecordWitnessedRun, *, - context: RunWatchedStartContext, + 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, -) -> RunWatchedStartEvents: - """Decide the events produced by recording a watched Run genesis.""" +) -> RunWitnessedStartEvents: + """Decide the events produced by recording a witnessed Run genesis.""" if state is not None: raise RunAlreadyExistsError(state.id) @@ -203,8 +203,8 @@ def decide( name=name.value, plan_id=command.plan_id, subject_id=command.subject_id, - conduct_mode=ConductMode.RECORDED, - trigger_source=f"CaptureWatcher:{command.capture_code}", + conduct_mode=ConductMode.WITNESSED, + trigger_source=f"RunWitness:{command.capture_code}", effective_parameters=effective_parameters, external_refs=external_refs, acknowledged_cautions=acknowledged_cautions, @@ -212,4 +212,4 @@ def decide( occurred_at=now, ) ] - return RunWatchedStartEvents(run_events=run_events) + return RunWitnessedStartEvents(run_events=run_events) diff --git a/apps/api/src/cora/run/features/record_watched_run/handler.py b/apps/api/src/cora/run/features/record_witnessed_run/handler.py similarity index 89% rename from apps/api/src/cora/run/features/record_watched_run/handler.py rename to apps/api/src/cora/run/features/record_witnessed_run/handler.py index a9fa65d29f4..1194a08b9f7 100644 --- a/apps/api/src/cora/run/features/record_watched_run/handler.py +++ b/apps/api/src/cora/run/features/record_witnessed_run/handler.py @@ -1,4 +1,4 @@ -"""Application handler for the `record_watched_run` slice: the watched genesis. +"""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, @@ -11,13 +11,13 @@ 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 watcher runtime's own edge-triggered state, not this +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_watched_run`, called only by -the in-process watcher runtime as a seeded Agent principal. +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 @@ -34,24 +34,24 @@ 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_watched_run.command import RecordWatchedRun -from cora.run.features.record_watched_run.context import RunWatchedStartContext -from cora.run.features.record_watched_run.decider import decide +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 = "RecordWatchedRun" +_COMMAND_NAME = "RecordWitnessedRun" _log = get_logger(__name__) class Handler(Protocol): - """Callable interface every record_watched_run handler implements.""" + """Callable interface every record_witnessed_run handler implements.""" async def __call__( self, - command: RecordWatchedRun, + command: RecordWitnessedRun, *, principal_id: UUID, correlation_id: UUID, @@ -61,10 +61,10 @@ async def __call__( def bind(deps: Kernel) -> Handler: - """Build a record_watched_run handler closed over the shared deps.""" + """Build a record_witnessed_run handler closed over the shared deps.""" async def handler( - command: RecordWatchedRun, + command: RecordWitnessedRun, *, principal_id: UUID, correlation_id: UUID, @@ -72,7 +72,7 @@ async def handler( surface_id: UUID = NIL_SENTINEL_ID, ) -> UUID: _log.info( - "record_watched_run.start", + "record_witnessed_run.start", command_name=_COMMAND_NAME, plan_id=str(command.plan_id), capture_code=command.capture_code, @@ -89,7 +89,7 @@ async def handler( ) if isinstance(decision, Deny): _log.info( - "record_watched_run.denied", + "record_witnessed_run.denied", command_name=_COMMAND_NAME, plan_id=str(command.plan_id), principal_id=str(principal_id), @@ -172,7 +172,7 @@ async def handler( # reading on the emitted RunStarted rather than gating on it. beam_availability = await deps.beam_availability_lookup.read() - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=subject, assets=assets, @@ -224,7 +224,7 @@ async def handler( ) _log.info( - "record_watched_run.success", + "record_witnessed_run.success", command_name=_COMMAND_NAME, run_id=str(new_id), plan_id=str(command.plan_id), diff --git a/apps/api/src/cora/run/features/record_watched_run/route.py b/apps/api/src/cora/run/features/record_witnessed_run/route.py similarity index 66% rename from apps/api/src/cora/run/features/record_watched_run/route.py rename to apps/api/src/cora/run/features/record_witnessed_run/route.py index 83df695ad16..cb69f6e2def 100644 --- a/apps/api/src/cora/run/features/record_watched_run/route.py +++ b/apps/api/src/cora/run/features/record_witnessed_run/route.py @@ -1,11 +1,11 @@ -"""Stub route module for `record_watched_run` (in-process-only slice). +"""Stub route module for `record_witnessed_run` (in-process-only slice). -Per the roadmap's anti-scope: no operator path to a watched genesis. No +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 watched path being used to launder around a driven +that stops the witnessed path being used to launder around a driven refusal. In-process adapters (the capture-watch runtime) call -`RunHandlers.record_watched_run(...)` directly. +`RunHandlers.record_witnessed_run(...)` directly. The empty `router` exists only to satisfy the slice-file-shape + routes-completeness architecture fitness functions; no routes are diff --git a/apps/api/src/cora/run/features/record_watched_run/tool.py b/apps/api/src/cora/run/features/record_witnessed_run/tool.py similarity index 73% rename from apps/api/src/cora/run/features/record_watched_run/tool.py rename to apps/api/src/cora/run/features/record_witnessed_run/tool.py index 7756bb49b7d..cdc54a8259d 100644 --- a/apps/api/src/cora/run/features/record_watched_run/tool.py +++ b/apps/api/src/cora/run/features/record_witnessed_run/tool.py @@ -1,7 +1,7 @@ -"""Stub MCP tool module for `record_watched_run` (in-process-only slice). +"""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_watched_run(...)` directly. +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 @@ -15,13 +15,13 @@ from mcp.server.fastmcp import FastMCP from cora.infrastructure.routing import get_mcp_surface_id -from cora.run.features.record_watched_run.handler import Handler +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_watched_run is in-process-only.""" + """No-op MCP registration: record_witnessed_run is in-process-only.""" _ = mcp _ = get_handler _ = _STUB_RESOLVER 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 4e785a74de3..dd1e58942a7 100644 --- a/apps/api/src/cora/run/features/start_run/command.py +++ b/apps/api/src/cora/run/features/start_run/command.py @@ -35,8 +35,8 @@ 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 `Recorded` Run is genesis-only through the -separate watched-genesis slice, never through this one. See +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) + 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 3ef0489f712..7c0b03634b1 100644 --- a/apps/api/src/cora/run/features/start_run/decider.py +++ b/apps/api/src/cora/run/features/start_run/decider.py @@ -403,8 +403,8 @@ def decide( # 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 Recorded Run is genesis-only through the separate - # watched-genesis decider. + # 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, diff --git a/apps/api/src/cora/run/ports/__init__.py b/apps/api/src/cora/run/ports/__init__.py index bec33333fb6..edfeb54cd05 100644 --- a/apps/api/src/cora/run/ports/__init__.py +++ b/apps/api/src/cora/run/ports/__init__.py @@ -2,7 +2,7 @@ Cross-BC ports live in `infrastructure/ports/`; these are owned by Run because their sole consumer is a Run-watching composition-root runtime -(the RunSupervisor, and now the capture-observing RunWatcher). See +(the RunSupervisor, and now the capture-observing RunWitness). See [[project_observation_signal_port_design]]. """ diff --git a/apps/api/src/cora/run/routes.py b/apps/api/src/cora/run/routes.py index 016d5950de3..ce5aeecbf14 100644 --- a/apps/api/src/cora/run/routes.py +++ b/apps/api/src/cora/run/routes.py @@ -50,7 +50,7 @@ - 409 (Run adjust transition guard, 6j): RunCannotAdjustError - 400 (validation, 12b-5 adds): InvalidPinnedCalibrationsError - 400 (validation): InvalidInputDatasetsError - - 400 (watched-genesis trigger guard): RunMonitorTriggerNotPermittedError + - 400 (witnessed-genesis trigger guard): RunMonitorTriggerNotPermittedError """ from fastapi import FastAPI, Request, status @@ -112,7 +112,7 @@ get_run, hold_run, list_runs, - record_watched_run, + record_witnessed_run, resume_run, start_run, stop_run, @@ -201,11 +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 watched-genesis slice. + # 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_watched_run.router) + 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) diff --git a/apps/api/src/cora/run/tools.py b/apps/api/src/cora/run/tools.py index c570fe48872..f1e527c1f0d 100644 --- a/apps/api/src/cora/run/tools.py +++ b/apps/api/src/cora/run/tools.py @@ -17,7 +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_watched_run import tool as record_watched_run_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 @@ -35,13 +35,13 @@ def register_run_tools( mcp, get_handler=lambda: get_handlers().start_run, ) - # Stub registration for the in-process-only watched-genesis slice. + # 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_watched_run_tool.register( + record_witnessed_run_tool.register( mcp, - get_handler=lambda: get_handlers().record_watched_run, + get_handler=lambda: get_handlers().record_witnessed_run, ) complete_run_tool.register( mcp, diff --git a/apps/api/src/cora/run/wire.py b/apps/api/src/cora/run/wire.py index 4e0e209ef00..51230a58341 100644 --- a/apps/api/src/cora/run/wire.py +++ b/apps/api/src/cora/run/wire.py @@ -24,7 +24,7 @@ strict-not-idempotent (the guard rejects double-application and ConcurrencyError catches the persistence-layer double-submit case). -`record_watched_run` is a second, independent genesis (the watched +`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 @@ -75,7 +75,7 @@ get_run, hold_run, list_runs, - record_watched_run, + record_witnessed_run, resume_run, start_run, stop_run, @@ -90,7 +90,7 @@ class RunHandlers: """The Run BC's handler bundle, each closed over Kernel.""" start_run: start_run.IdempotentHandler - record_watched_run: record_watched_run.Handler + record_witnessed_run: record_witnessed_run.Handler complete_run: complete_run.Handler abort_run: abort_run.Handler hold_run: hold_run.Handler @@ -123,9 +123,9 @@ def wire_run(deps: Kernel) -> RunHandlers: command_name="StartRun", bc=_BC, ), - record_watched_run=with_tracing( - record_watched_run.bind(deps), - command_name="RecordWatchedRun", + record_witnessed_run=with_tracing( + record_witnessed_run.bind(deps), + command_name="RecordWitnessedRun", bc=_BC, ), complete_run=with_tracing( 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 5781aed6a8b..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 @@ -150,11 +150,11 @@ # `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: - # RecordWatchedRun emits RunStarted, not WatchedRunRecorded, because + # 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_watched_run": "watched genesis; states the RunStarted fact, not the recording verb", + "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 index fda7b3322a4..b9afc00228e 100644 --- a/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py +++ b/apps/api/tests/architecture/test_run_conduct_mode_always_declared.py @@ -6,9 +6,9 @@ choice" a build-time guarantee rather than a convention someone can forget: - - The enum stays closed to exactly {CONDUCTED, RECORDED}. A third member + - The enum stays closed to exactly {CONDUCTED, WITNESSED}. A third member added without a design decision (see docs/reference/modeling.md's - "Conducted vs recorded" framing) would silently widen what the axis + "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 @@ -19,9 +19,9 @@ - `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 - `Recorded` Run is genesis-only through the separate watched-genesis + `Witnessed` Run is genesis-only through the separate witnessed-genesis slice. A field on `StartRun` would let any caller of the driven path - claim `Recorded` while taking the enforcing path, which is exactly + claim `Witnessed` while taking the enforcing path, which is exactly the laundering hole this axis exists to close. """ @@ -39,11 +39,11 @@ @pytest.mark.architecture def test_conduct_mode_has_exactly_two_members() -> None: names = {member.name for member in ConductMode} - assert names == {"CONDUCTED", "RECORDED"}, ( - f"ConductMode grew a member beyond {{CONDUCTED, RECORDED}}: {names}. " + 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 recorded' framing before widening this enum." + "'Conducted vs witnessed' framing before widening this enum." ) @@ -66,5 +66,5 @@ def test_start_run_has_no_conduct_mode_field() -> None: "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 Recorded while taking the enforcing path." + "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 db20ceeeb77..30bb5faa0f3 100644 --- a/apps/api/tests/architecture/test_slice_test_coverage.py +++ b/apps/api/tests/architecture/test_slice_test_coverage.py @@ -109,10 +109,10 @@ # 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 watched Run). Mirrors the + # anti-scope: no operator path to a witnessed Run). Mirrors the # Enclosure / Supply monitor-trigger precedent. In-process adapters - # call via RunHandlers.record_watched_run. - "cora.run.features.record_watched_run", + # 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 + @@ -198,7 +198,7 @@ "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_watched_run", + "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 @@ -536,7 +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_watched_run", + "cora.run.features.record_witnessed_run", } ) diff --git a/apps/api/tests/architecture/test_watched_genesis_laundering_wall.py b/apps/api/tests/architecture/test_witnessed_genesis_laundering_wall.py similarity index 75% rename from apps/api/tests/architecture/test_watched_genesis_laundering_wall.py rename to apps/api/tests/architecture/test_witnessed_genesis_laundering_wall.py index 8de5c008a93..51e5faf61f8 100644 --- a/apps/api/tests/architecture/test_watched_genesis_laundering_wall.py +++ b/apps/api/tests/architecture/test_witnessed_genesis_laundering_wall.py @@ -1,15 +1,15 @@ -"""The watched genesis has no operator path in, by construction. +"""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.RECORDED` is constructed in exactly one place: the - watched-genesis decider. If a second construction site appears + 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 RECORDED, which is precisely the laundering hole the + 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_watched_run`) expose zero REST routes and zero MCP tools. + `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. @@ -24,15 +24,18 @@ _IN_PROCESS_ONLY_SLICES: tuple[str, ...] = ( "enclosure/features/observe_enclosure_status", - "run/features/record_watched_run", + "run/features/record_witnessed_run", ) -def _find_conduct_mode_recorded_sites() -> list[Path]: - """Every tracked file under src/cora that references `ConductMode.RECORDED` - specifically (other aggregates declare their own unrelated `RECORDED` - members, e.g. `AcquisitionStatus.RECORDED` in the Data BC, so the scan - must check the attribute's base name, not just the attribute name).""" +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: @@ -42,7 +45,7 @@ def _find_conduct_mode_recorded_sites() -> list[Path]: for node in ast.walk(tree): if ( isinstance(node, ast.Attribute) - and node.attr == "RECORDED" + and node.attr == "WITNESSED" and isinstance(node.value, ast.Name) and node.value.id == "ConductMode" ): @@ -52,13 +55,13 @@ def _find_conduct_mode_recorded_sites() -> list[Path]: @pytest.mark.architecture -def test_conduct_mode_recorded_has_exactly_one_construction_site() -> None: - sites = _find_conduct_mode_recorded_sites() +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_watched_run/decider.py"], ( - "ConductMode.RECORDED must be constructed in exactly one place " - f"(the watched-genesis decider); found it referenced in: {relative}. " - "A second site is an operator-reachable path claiming a watched " + 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." ) 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 31ff2e94e19..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 @@ -315,38 +315,38 @@ async def test_initiator_tick_respects_max_in_flight(db_pool: asyncpg.Pool) -> N @pytest.mark.integration -async def test_initiator_tick_counts_a_watched_recorded_run_toward_max_in_flight( +async def test_initiator_tick_counts_a_witnessed_run_toward_max_in_flight( db_pool: asyncpg.Pool, ) -> None: - """A watched (Recorded) Run occupies the same one-stage hardware a driven + """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 - watched Run because CORA does not control it) would let the initiator + 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) - watched_run_id = uuid4() - watched = RunStarted( - run_id=watched_run_id, - name="watched capture", + 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.RECORDED, + conduct_mode=ConductMode.WITNESSED, ) await deps.event_store.append( stream_type="Run", - stream_id=watched_run_id, + stream_id=witnessed_run_id, expected_version=0, events=[ to_new_event( - event_type=run_event_type_name(watched), - payload=run_to_payload(watched), - occurred_at=watched.occurred_at, + 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, 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 173e3801363..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,8 +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_watched_run import RecordWatchedRun -from cora.run.features.record_watched_run import bind as bind_record_watched_run +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 @@ -452,8 +452,8 @@ async def test_campaign_id_filter_narrows_results(db_pool: asyncpg.Pool) -> 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_watched_run) against distinct Plans; `conduct_mode="Recorded"` - returns only the watched one. Proves the query-side filter (this + 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. @@ -466,13 +466,13 @@ async def test_conduct_mode_filter_narrows_to_recorded_runs_only(db_pool: asyncp correlation_id=_CORRELATION_ID, ) - # Recorded (watched) Run. + # 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_watched_run(deps_recorded)( - RecordWatchedRun( - name="watched-run", + 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()), @@ -486,10 +486,10 @@ async def test_conduct_mode_filter_narrows_to_recorded_runs_only(db_pool: asyncp handler = bind_list(deps_conducted) page = await handler( - ListRuns(conduct_mode="Recorded", limit=10), + 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 == "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 7a149acc551..8ab2cb384d9 100644 --- a/apps/api/tests/integration/test_pilot_seed_postgres.py +++ b/apps/api/tests/integration/test_pilot_seed_postgres.py @@ -188,7 +188,7 @@ async def test_seeded_ladder_resolves_for_all_acquisition_recipes( 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 RunWatcher's promotion path watches) additionally binds + 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 diff --git a/apps/api/tests/integration/test_record_watched_run_handler_postgres.py b/apps/api/tests/integration/test_record_witnessed_run_handler_postgres.py similarity index 86% rename from apps/api/tests/integration/test_record_watched_run_handler_postgres.py rename to apps/api/tests/integration/test_record_witnessed_run_handler_postgres.py index 4abd906fc6f..11af705758e 100644 --- a/apps/api/tests/integration/test_record_watched_run_handler_postgres.py +++ b/apps/api/tests/integration/test_record_witnessed_run_handler_postgres.py @@ -1,9 +1,9 @@ -"""Postgres integration test for the `record_watched_run` handler. +"""Postgres integration test for the `record_witnessed_run` handler. -Round-trips the watched genesis through a real event store: authorize, +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 `Recorded`. +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 @@ -24,7 +24,7 @@ fold, from_stored, ) -from cora.run.features.record_watched_run import RecordWatchedRun, bind +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 @@ -41,7 +41,7 @@ _PRINCIPAL_ID = operator_for(__file__) _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000004c9601") -# Scenario tag: 4c96 (record_watched_run handler round-trip). +# 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")) @@ -84,7 +84,7 @@ practice_name="2BM_tomography_practice", site_id=_2BM_UNIT_ID, plan_id=_PLAN_ID, - plan_name="2BM_watched_tomography_plan", + plan_name="2BM_witnessed_tomography_plan", plan_asset_ids=frozenset( {_ASSET_ROTARY_ID, _ASSET_LINEAR_X_ID, _ASSET_CAMERA_ID, _ASSET_SCINTILLATOR_ID} ), @@ -96,12 +96,12 @@ def _id_queue() -> list[UUID]: return [ *tomography_install_id_prefix(asset_ids=_TOMO_ASSETS), *recipe_ladder_id_prefix(spec=_RECIPE), - *[e() for _ in range(20)], # headroom: record_watched_run's own event ids + *[e() for _ in range(20)], # headroom: record_witnessed_run's own event ids ] @pytest.mark.integration -async def test_record_watched_run_persists_recorded_run_with_safety_envelope_verdict( +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()) @@ -121,8 +121,8 @@ async def test_record_watched_run_persists_recorded_run_with_safety_envelope_ver handler = bind(deps) run_id = await handler( - RecordWatchedRun( - name="2BM watched capture", + RecordWitnessedRun( + name="2BM witnessed capture", plan_id=_PLAN_ID, capture_code="2bmb-tomoscan", monitor_source_id=MonitorSourceId(UUID("01900000-0000-7000-8000-000063617001")), @@ -137,7 +137,7 @@ async def test_record_watched_run_persists_recorded_run_with_safety_envelope_ver assert len(events) == 1 stored = events[0] assert stored.event_type == "RunStarted" - assert stored.payload["conduct_mode"] == "Recorded" + assert stored.payload["conduct_mode"] == "Witnessed" assert stored.payload["subject_id"] is None assert stored.payload["safety_envelope_verdict"] == { "enclosure_permitted": True, @@ -149,11 +149,11 @@ async def test_record_watched_run_persists_recorded_run_with_safety_envelope_ver # 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.RECORDED + assert state.conduct_mode is ConductMode.WITNESSED @pytest.mark.integration -async def test_record_watched_run_rejects_non_monitor_trigger(db_pool: asyncpg.Pool) -> None: +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, @@ -172,8 +172,8 @@ async def test_record_watched_run_rejects_non_monitor_trigger(db_pool: asyncpg.P handler = bind(deps) with pytest.raises(RunMonitorTriggerNotPermittedError): await handler( - RecordWatchedRun( - name="2BM watched capture", + RecordWitnessedRun( + name="2BM witnessed capture", plan_id=_PLAN_ID, capture_code="2bmb-tomoscan", monitor_source_id=MonitorSourceId(UUID("01900000-0000-7000-8000-000063617001")), diff --git a/apps/api/tests/integration/test_run_watcher_rebuild_postgres.py b/apps/api/tests/integration/test_run_witness_rebuild_postgres.py similarity index 90% rename from apps/api/tests/integration/test_run_watcher_rebuild_postgres.py rename to apps/api/tests/integration/test_run_witness_rebuild_postgres.py index 76003dd2029..068a860ca71 100644 --- a/apps/api/tests/integration/test_run_watcher_rebuild_postgres.py +++ b/apps/api/tests/integration/test_run_witness_rebuild_postgres.py @@ -1,5 +1,5 @@ """Postgres integration test for `rebuild_open_captures` -(cora.api._run_watcher). +(cora.api._run_witness). Proves the restart-rebuild query composes correctly end-to-end against real Postgres: the `conduct_mode` list_runs filter, the @@ -8,7 +8,7 @@ fake handlers) can prove this chain. Reuses the shared 2-BM tomography fixture, the same one -`test_record_watched_run_handler_postgres.py` exercises. +`test_record_witnessed_run_handler_postgres.py` exercises. """ from datetime import UTC, datetime @@ -17,14 +17,14 @@ import asyncpg import pytest -from cora.api._run_watcher import rebuild_open_captures +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_watched_run import RecordWatchedRun, bind +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 @@ -42,7 +42,7 @@ _PRINCIPAL_ID = operator_for(__file__) _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000004caa01") -# Scenario tag: 4caa (RunWatcher restart-rebuild round-trip). +# 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")) @@ -82,10 +82,10 @@ {_CAP_ROTARY_STAGE_ID, _CAP_LINEAR_STAGE_ID, _CAP_CAMERA_ID, _CAP_SCINTILLATOR_ID} ), practice_id=_PRACTICE_ID, - practice_name="2BM_tomography_practice_watcher_rebuild", + practice_name="2BM_tomography_practice_witness_rebuild", site_id=_2BM_UNIT_ID, plan_id=_PLAN_ID, - plan_name="2BM_watched_tomography_plan_watcher_rebuild", + 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} ), @@ -110,7 +110,7 @@ async def _drain(db_pool: asyncpg.Pool) -> None: @pytest.mark.integration -async def test_rebuild_open_captures_seeds_dedup_map_from_a_real_open_watched_run( +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()) @@ -130,8 +130,8 @@ async def test_rebuild_open_captures_seeds_dedup_map_from_a_real_open_watched_ru handler = bind(deps) run_id = await handler( - RecordWatchedRun( - name="2BM watched capture", + RecordWitnessedRun( + name="2BM witnessed capture", plan_id=_PLAN_ID, capture_code="2bmb-tomoscan", monitor_source_id=MonitorSourceId(UUID("01900000-0000-7000-8000-000063617001")), 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 1a2194f23c1..6dfa6ef645f 100644 --- a/apps/api/tests/integration/test_start_run_handler_postgres.py +++ b/apps/api/tests/integration/test_start_run_handler_postgres.py @@ -181,7 +181,7 @@ async def test_start_run_persists_event_with_full_upstream_chain_against_postgre # by the decider (not caller-settable). Forward-compat via # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)`. "conduct_mode": "Conducted", - # Additive payload field: the watched-genesis envelope reading. + # 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, diff --git a/apps/api/tests/unit/agent/test_run_watcher_seed.py b/apps/api/tests/unit/agent/test_run_witness_seed.py similarity index 59% rename from apps/api/tests/unit/agent/test_run_watcher_seed.py rename to apps/api/tests/unit/agent/test_run_witness_seed.py index 575529ceebe..1d83c3df5d9 100644 --- a/apps/api/tests/unit/agent/test_run_watcher_seed.py +++ b/apps/api/tests/unit/agent/test_run_witness_seed.py @@ -1,6 +1,6 @@ -"""Unit tests for the RunWatcher Agent bootstrap seed. +"""Unit tests for the RunWitness Agent bootstrap seed. -RunWatcher is another DETERMINISTIC agent: no prompt template and a +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`. @@ -11,12 +11,12 @@ import pytest from cora.agent.aggregates.agent import load_agent -from cora.agent.seed_run_watcher import ( - RUN_WATCHER_AGENT_ID, - RUN_WATCHER_AGENT_KIND, - RUN_WATCHER_AGENT_NAME, - RUN_WATCHER_AGENT_VERSION, - seed_run_watcher_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 @@ -35,29 +35,29 @@ def _kernel() -> Kernel: @pytest.mark.unit -async def test_seed_creates_run_watcher_at_pinned_id() -> None: +async def test_seed_creates_run_witness_at_pinned_id() -> None: kernel = _kernel() - await seed_run_watcher_agent(kernel) + await seed_run_witness_agent(kernel) - agent = await load_agent(kernel.event_store, RUN_WATCHER_AGENT_ID) + agent = await load_agent(kernel.event_store, RUN_WITNESS_AGENT_ID) assert agent is not None - assert agent.id == RUN_WATCHER_AGENT_ID - assert agent.name.value == RUN_WATCHER_AGENT_NAME - assert agent.kind.value == RUN_WATCHER_AGENT_KIND - assert agent.version.value == RUN_WATCHER_AGENT_VERSION + 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_watcher_agent(kernel) + await seed_run_witness_agent(kernel) - agent = await load_agent(kernel.event_store, RUN_WATCHER_AGENT_ID) + 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:RunWatcher:v1" + assert agent.model_ref.model == "agent:RunWitness:v1" @pytest.mark.unit @@ -66,11 +66,11 @@ async def test_seed_creates_co_registered_actor() -> None: from cora.access.aggregates.actor import load_actor kernel = _kernel() - await seed_run_watcher_agent(kernel) + await seed_run_witness_agent(kernel) - actor = await load_actor(kernel.event_store, RUN_WATCHER_AGENT_ID) + actor = await load_actor(kernel.event_store, RUN_WITNESS_AGENT_ID) assert actor is not None - assert actor.id == RUN_WATCHER_AGENT_ID + assert actor.id == RUN_WITNESS_AGENT_ID assert actor.kind.value == "agent" @@ -78,16 +78,16 @@ async def test_seed_creates_co_registered_actor() -> None: async def test_seed_is_idempotent() -> None: """Re-running the seed is a no-op (ConcurrencyError-as-success pattern).""" kernel = _kernel() - await seed_run_watcher_agent(kernel) - await seed_run_watcher_agent(kernel) + await seed_run_witness_agent(kernel) + await seed_run_witness_agent(kernel) @pytest.mark.unit -async def test_run_watcher_id_distinct_from_other_agents() -> None: - """RunWatcher shares the UUID-range scheme with its sibling runtimes +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_WATCHER_AGENT_ID != RUN_SUPERVISOR_AGENT_ID - assert RUN_WATCHER_AGENT_ID != RUN_INITIATOR_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_run_supervisor.py b/apps/api/tests/unit/api/test_run_supervisor.py index 5241864a37d..4a1be9c0758 100644 --- a/apps/api/tests/unit/api/test_run_supervisor.py +++ b/apps/api/tests/unit/api/test_run_supervisor.py @@ -484,15 +484,15 @@ async def test_tick_holds_running_run_when_beam_down_and_records_decision() -> N @pytest.mark.unit -async def test_tick_never_holds_a_watched_recorded_run_when_beam_down() -> None: - """A watched (Recorded) Run is driven by an external tool CORA only +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="Recorded")]) + list_runs = _make_list_runs([_running_item(run_id, conduct_mode="Witnessed")]) hold_run, hold_calls = _make_recording_hold() memory: dict[UUID, str] = {} @@ -1192,15 +1192,15 @@ async def test_shadow_liveness_flags_stale_run_observe_only() -> None: @pytest.mark.unit -async def test_shadow_liveness_never_flags_a_watched_recorded_run() -> None: - """A watched Run's duration is set by the external tool driving it, not +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="Recorded")] + [_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() diff --git a/apps/api/tests/unit/api/test_run_watcher.py b/apps/api/tests/unit/api/test_run_witness.py similarity index 75% rename from apps/api/tests/unit/api/test_run_watcher.py rename to apps/api/tests/unit/api/test_run_witness.py index 744fb5fc037..2d1fbac112d 100644 --- a/apps/api/tests/unit/api/test_run_watcher.py +++ b/apps/api/tests/unit/api/test_run_witness.py @@ -1,4 +1,4 @@ -"""Tests for the RunWatcher shadow runtime (cora.api._run_watcher). +"""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 @@ -25,13 +25,13 @@ import pytest import structlog.testing -from cora.api._run_watcher import ( - RUN_WATCHER_MONITOR_SOURCE_ID, - RunWatcherRecorder, +from cora.api._run_witness import ( + RUN_WITNESS_MONITOR_SOURCE_ID, + RunWitnessRecorder, observe_capture, rebuild_open_captures, - run_run_watcher, - run_watcher_lifespan, + run_witness_lifespan, + run_witness_loop, ) from cora.infrastructure.config import Settings from cora.infrastructure.event_envelope import to_new_event @@ -39,7 +39,7 @@ 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_watched_run.command import RecordWatchedRun +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 @@ -97,11 +97,11 @@ async def _drain(self) -> AsyncGenerator[CaptureObservation]: @pytest.mark.parametrize( ("phase", "expected_event"), [ - (CapturePhase.BEGUN, "run_watcher.capture_begun"), - (CapturePhase.PROGRESSING, "run_watcher.capture_progressing"), - (CapturePhase.ENDED, "run_watcher.capture_ended"), - (CapturePhase.ABORTED, "run_watcher.capture_aborted"), - (CapturePhase.UNRECOGNIZED, "run_watcher.capture_unrecognized"), + (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( @@ -118,7 +118,7 @@ def test_observe_capture_logs_unreached_for_a_probe_only_observation() -> None: 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_watcher.capture_unreached"] + assert [entry["event"] for entry in logs] == ["run_witness.capture_unreached"] @pytest.mark.unit @@ -145,15 +145,15 @@ def test_observe_capture_reports_no_substrate_time_as_none_not_a_string() -> Non @pytest.mark.unit -async def test_run_run_watcher_is_a_no_op_for_empty_capture_codes() -> None: +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_run_watcher(observer=observer, capture_codes=frozenset()) + 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_run_watcher_logs_every_observation_in_sequence() -> None: +async def test_run_witness_loop_logs_every_observation_in_sequence() -> None: observer = _FakeObserver( [ _obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN), @@ -161,7 +161,9 @@ async def test_run_run_watcher_logs_every_observation_in_sequence() -> None: _obs(reported_status="Scan complete", phase=CapturePhase.ENDED), ] ) - task = asyncio.create_task(run_run_watcher(observer=observer, capture_codes=frozenset({_CODE}))) + 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() @@ -169,18 +171,18 @@ async def test_run_run_watcher_logs_every_observation_in_sequence() -> None: await task events = [entry["event"] for entry in logs] assert events == [ - "run_watcher.capture_begun", - "run_watcher.capture_progressing", - "run_watcher.capture_ended", + "run_witness.capture_begun", + "run_witness.capture_progressing", + "run_witness.capture_ended", ] @pytest.mark.unit -async def test_run_run_watcher_survives_an_observer_that_raises() -> None: +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_run_watcher( + run_witness_loop( observer=_BoomObserver(), capture_codes=frozenset({_CODE}), reconnect_delay_seconds=0.01, @@ -192,13 +194,13 @@ async def test_run_run_watcher_survives_an_observer_that_raises() -> None: with contextlib.suppress(asyncio.CancelledError): await task events = [entry["event"] for entry in logs] - assert "run_watcher.iteration_failed" in events + 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_watcher_lifespan(observer=_FakeObserver([]), capture_codes=frozenset()): + async with run_witness_lifespan(observer=_FakeObserver([]), capture_codes=frozenset()): entered = True assert entered @@ -207,24 +209,24 @@ async def test_lifespan_is_a_no_op_when_no_capture_codes_configured() -> None: 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_watcher_lifespan(observer=observer, capture_codes=frozenset({_CODE})): + 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_watcher.capture_ended" in events + assert "run_witness.capture_ended" in events -class _FakeRecordWatchedRun: - """Fake `record_watched_run` handler: records every call, returns a +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[RecordWatchedRun] = [] + self.calls: list[RecordWitnessedRun] = [] async def __call__( self, - command: RecordWatchedRun, + command: RecordWitnessedRun, *, principal_id: UUID, correlation_id: UUID, @@ -239,27 +241,27 @@ async def __call__( def _recorder( *, - record_watched_run: _FakeRecordWatchedRun, - run_watcher_recording_enabled: bool = True, + 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, -) -> RunWatcherRecorder: +) -> RunWitnessRecorder: settings = Settings( # type: ignore[call-arg] - run_watcher_recording_enabled=run_watcher_recording_enabled, + run_witness_recording_enabled=run_witness_recording_enabled, capture_watch_plan_id=capture_watch_plan_id, ) - return RunWatcherRecorder( + return RunWitnessRecorder( deps=build_deps(ids=[uuid4() for _ in range(10)]), - record_watched_run=record_watched_run, + record_witnessed_run=record_witnessed_run, settings=settings, open_captures=open_captures, ) @pytest.mark.unit -async def test_run_watcher_recorder_promotes_a_begun_capture_while_idle() -> None: - fake = _FakeRecordWatchedRun() - recorder = _recorder(record_watched_run=fake) +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)) @@ -268,13 +270,13 @@ async def test_run_watcher_recorder_promotes_a_begun_capture_while_idle() -> Non assert command.capture_code == _CODE assert command.plan_id == _PLAN_ID assert command.trigger == "Monitor" - assert command.monitor_source_id == RUN_WATCHER_MONITOR_SOURCE_ID + assert command.monitor_source_id == RUN_WITNESS_MONITOR_SOURCE_ID @pytest.mark.unit -async def test_run_watcher_recorder_does_not_repromote_a_begun_capture_while_open() -> None: - fake = _FakeRecordWatchedRun() - recorder = _recorder(record_watched_run=fake) +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) @@ -284,9 +286,9 @@ async def test_run_watcher_recorder_does_not_repromote_a_begun_capture_while_ope @pytest.mark.unit -async def test_run_watcher_recorder_stays_idle_after_a_promotion_failure() -> None: - fake = _FakeRecordWatchedRun(raises=RuntimeError("clearance refused")) - recorder = _recorder(record_watched_run=fake) +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) @@ -298,23 +300,23 @@ async def test_run_watcher_recorder_stays_idle_after_a_promotion_failure() -> No @pytest.mark.unit -async def test_run_watcher_recorder_logs_a_distinct_event_on_unauthorized() -> None: - fake = _FakeRecordWatchedRun(raises=UnauthorizedError("not granted")) - recorder = _recorder(record_watched_run=fake) +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_watcher.promotion_unauthorized" in events + assert "run_witness.promotion_unauthorized" in events @pytest.mark.unit -async def test_run_watcher_recorder_clears_on_ended_while_open() -> None: +async def test_run_witness_recorder_clears_on_ended_while_open() -> None: run_id = uuid4() - fake = _FakeRecordWatchedRun(run_id=run_id) - recorder = _recorder(record_watched_run=fake, open_captures={_CODE: run_id}) + 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)) @@ -325,10 +327,10 @@ async def test_run_watcher_recorder_clears_on_ended_while_open() -> None: @pytest.mark.unit -async def test_run_watcher_recorder_clears_on_aborted_while_open() -> None: +async def test_run_witness_recorder_clears_on_aborted_while_open() -> None: run_id = uuid4() - fake = _FakeRecordWatchedRun(run_id=run_id) - recorder = _recorder(record_watched_run=fake, open_captures={_CODE: run_id}) + 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)) @@ -337,9 +339,9 @@ async def test_run_watcher_recorder_clears_on_aborted_while_open() -> None: @pytest.mark.unit -async def test_run_watcher_recorder_noop_on_ended_while_idle() -> None: - fake = _FakeRecordWatchedRun() - recorder = _recorder(record_watched_run=fake) +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)) @@ -347,9 +349,9 @@ async def test_run_watcher_recorder_noop_on_ended_while_idle() -> None: @pytest.mark.unit -async def test_run_watcher_recorder_noop_on_aborted_while_idle() -> None: - fake = _FakeRecordWatchedRun() - recorder = _recorder(record_watched_run=fake) +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)) @@ -358,12 +360,12 @@ async def test_run_watcher_recorder_noop_on_aborted_while_idle() -> None: @pytest.mark.unit @pytest.mark.parametrize("preopened", [False, True]) -async def test_run_watcher_recorder_noop_on_progressing_regardless_of_state( +async def test_run_witness_recorder_noop_on_progressing_regardless_of_state( preopened: bool, ) -> None: - fake = _FakeRecordWatchedRun() + fake = _FakeRecordWitnessedRun() open_captures = {_CODE: uuid4()} if preopened else None - recorder = _recorder(record_watched_run=fake, open_captures=open_captures) + recorder = _recorder(record_witnessed_run=fake, open_captures=open_captures) await recorder.observe_capture( _obs(reported_status="Collecting projections", phase=CapturePhase.PROGRESSING) @@ -374,12 +376,12 @@ async def test_run_watcher_recorder_noop_on_progressing_regardless_of_state( @pytest.mark.unit @pytest.mark.parametrize("preopened", [False, True]) -async def test_run_watcher_recorder_noop_on_unrecognized_regardless_of_state( +async def test_run_witness_recorder_noop_on_unrecognized_regardless_of_state( preopened: bool, ) -> None: - fake = _FakeRecordWatchedRun() + fake = _FakeRecordWitnessedRun() open_captures = {_CODE: uuid4()} if preopened else None - recorder = _recorder(record_watched_run=fake, open_captures=open_captures) + recorder = _recorder(record_witnessed_run=fake, open_captures=open_captures) await recorder.observe_capture(_obs(reported_status="???", phase=CapturePhase.UNRECOGNIZED)) @@ -388,14 +390,14 @@ async def test_run_watcher_recorder_noop_on_unrecognized_regardless_of_state( @pytest.mark.unit @pytest.mark.parametrize("preopened", [False, True]) -async def test_run_watcher_recorder_noop_on_none_phase_regardless_of_state( +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 = _FakeRecordWatchedRun() + fake = _FakeRecordWitnessedRun() open_captures = {_CODE: uuid4()} if preopened else None - recorder = _recorder(record_watched_run=fake, open_captures=open_captures) + recorder = _recorder(record_witnessed_run=fake, open_captures=open_captures) await recorder.observe_capture(_obs(reported_status=None, phase=None)) @@ -403,12 +405,12 @@ async def test_run_watcher_recorder_noop_on_none_phase_regardless_of_state( @pytest.mark.unit -async def test_run_watcher_recorder_is_a_pass_through_when_recording_disabled() -> None: +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 = _FakeRecordWatchedRun() - recorder = _recorder(record_watched_run=fake, run_watcher_recording_enabled=False) + 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: @@ -421,17 +423,17 @@ async def test_run_watcher_recorder_is_a_pass_through_when_recording_disabled() @pytest.mark.unit -async def test_run_watcher_lifespan_seeds_open_captures_from_the_supplied_map() -> None: +async def test_run_witness_lifespan_seeds_open_captures_from_the_supplied_map() -> None: run_id = uuid4() - fake = _FakeRecordWatchedRun() + fake = _FakeRecordWitnessedRun() observer = _FakeObserver([_obs(reported_status="Beginning scan", phase=CapturePhase.BEGUN)]) deps = build_deps() - async with run_watcher_lifespan( + async with run_witness_lifespan( observer=observer, capture_codes=frozenset({_CODE}), deps=deps, - record_watched_run=fake, + record_witnessed_run=fake, open_captures={_CODE: run_id}, ): await asyncio.sleep(0.02) @@ -440,12 +442,12 @@ async def test_run_watcher_lifespan_seeds_open_captures_from_the_supplied_map() @pytest.mark.unit -async def test_run_watcher_lifespan_rejects_a_handler_without_deps() -> None: +async def test_run_witness_lifespan_rejects_a_handler_without_deps() -> None: with pytest.raises(ValueError, match="requires deps"): - async with run_watcher_lifespan( + async with run_witness_lifespan( observer=_FakeObserver([]), capture_codes=frozenset({_CODE}), - record_watched_run=_FakeRecordWatchedRun(), + record_witnessed_run=_FakeRecordWitnessedRun(), ): pass @@ -469,7 +471,7 @@ async def __call__( return self._pages[len(self.queries) - 1] -def _summary_item(*, run_id: UUID, conduct_mode: str = "Recorded") -> RunSummaryItem: +def _summary_item(*, run_id: UUID, conduct_mode: str = "Witnessed") -> RunSummaryItem: return RunSummaryItem( run_id=run_id, name="watched capture", @@ -487,11 +489,11 @@ def _summary_item(*, run_id: UUID, conduct_mode: str = "Recorded") -> RunSummary ) -async def _append_watched_run_started(deps: Any, *, run_id: UUID, capture_code: str) -> None: - """Directly append a RunStarted(conduct_mode=RECORDED) event carrying +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_watched_recorded_run_toward_max_in_flight` - seeding pattern (record_watched_run's own decider is exercised + `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, @@ -499,7 +501,7 @@ async def _append_watched_run_started(deps: Any, *, run_id: UUID, capture_code: plan_id=_PLAN_ID, subject_id=None, occurred_at=_NOW, - conduct_mode=ConductMode.RECORDED, + conduct_mode=ConductMode.WITNESSED, external_refs=({"scheme": "capture-code", "value": capture_code},), ) await deps.event_store.append( @@ -525,7 +527,7 @@ async def _append_watched_run_started(deps: Any, *, run_id: UUID, capture_code: 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_watched_run_started(deps, run_id=run_id, capture_code=_CODE) + 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) @@ -538,8 +540,8 @@ 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_watched_run_started(deps, run_id=run_id_a, capture_code="code-a") - await _append_watched_run_started(deps, run_id=run_id_b, capture_code="code-b") + 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"), @@ -563,7 +565,7 @@ async def test_rebuild_open_captures_skips_a_run_with_no_capture_code_ref() -> N plan_id=_PLAN_ID, subject_id=None, occurred_at=_NOW, - conduct_mode=ConductMode.RECORDED, + conduct_mode=ConductMode.WITNESSED, ) await deps.event_store.append( stream_type="Run", diff --git a/apps/api/tests/unit/api/test_run_watcher_recording_gate.py b/apps/api/tests/unit/api/test_run_witness_recording_gate.py similarity index 54% rename from apps/api/tests/unit/api/test_run_watcher_recording_gate.py rename to apps/api/tests/unit/api/test_run_witness_recording_gate.py index f8658e8ee11..fd73638e8d3 100644 --- a/apps/api/tests/unit/api/test_run_watcher_recording_gate.py +++ b/apps/api/tests/unit/api/test_run_witness_recording_gate.py @@ -1,8 +1,8 @@ # pyright: reportPrivateUsage=false -"""Unit tests for the RunWatcher recording boot guard. +"""Unit tests for the RunWitness recording boot guard. -`_enforce_run_watcher_recording_gate` refuses to boot with -`run_watcher_recording_enabled=True` unless both `run_watcher_enabled` +`_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 @@ -14,76 +14,76 @@ import pytest -from cora.api.main import _enforce_run_watcher_recording_gate +from cora.api.main import _enforce_run_witness_recording_gate from cora.infrastructure.config import Settings def _settings( *, - run_watcher_enabled: bool = False, + run_witness_enabled: bool = False, capture_watch_plan_id: UUID | None = None, - run_watcher_recording_enabled: bool = False, + run_witness_recording_enabled: bool = False, ) -> Settings: return Settings( # type: ignore[call-arg] - run_watcher_enabled=run_watcher_enabled, + run_witness_enabled=run_witness_enabled, capture_watch_plan_id=capture_watch_plan_id, - run_watcher_recording_enabled=run_watcher_recording_enabled, + run_witness_recording_enabled=run_witness_recording_enabled, ) def test_recording_disabled_is_always_a_no_op() -> None: - for run_watcher_enabled in (True, False): + for run_witness_enabled in (True, False): for capture_watch_plan_id in (None, uuid4()): - _enforce_run_watcher_recording_gate( + _enforce_run_witness_recording_gate( _settings( - run_watcher_enabled=run_watcher_enabled, + run_witness_enabled=run_witness_enabled, capture_watch_plan_id=capture_watch_plan_id, - run_watcher_recording_enabled=False, + run_witness_recording_enabled=False, ) ) -def test_recording_enabled_without_watcher_enabled_refuses_boot() -> None: - with pytest.raises(RuntimeError, match="RUN_WATCHER_ENABLED=true"): - _enforce_run_watcher_recording_gate( +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_watcher_enabled=False, + run_witness_enabled=False, capture_watch_plan_id=uuid4(), - run_watcher_recording_enabled=True, + 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_watcher_recording_gate( + _enforce_run_witness_recording_gate( _settings( - run_watcher_enabled=True, + run_witness_enabled=True, capture_watch_plan_id=None, - run_watcher_recording_enabled=True, + 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_watcher_recording_gate( + _enforce_run_witness_recording_gate( _settings( - run_watcher_enabled=False, + run_witness_enabled=False, capture_watch_plan_id=None, - run_watcher_recording_enabled=True, + run_witness_recording_enabled=True, ) ) message = str(exc.value) - assert "RUN_WATCHER_ENABLED=true" in message + 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_watcher_recording_gate( + _enforce_run_witness_recording_gate( _settings( - run_watcher_enabled=True, + run_witness_enabled=True, capture_watch_plan_id=uuid4(), - run_watcher_recording_enabled=True, + 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 e9058614117..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 @@ -83,7 +83,7 @@ def test_safety_envelope_verdict_bools_are_redacted_not_kept_whole() -> None: "plan_id": "01900000-0000-7000-8000-0000000000d2", "subject_id": None, "raid": None, - "conduct_mode": "Recorded", + "conduct_mode": "Witnessed", "safety_envelope_verdict": { "enclosure_permitted": True, "beam_available": False, @@ -103,7 +103,7 @@ def test_safety_envelope_verdict_bools_are_redacted_not_kept_whole() -> None: 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"] == "Recorded" + assert redacted["conduct_mode"] == "Witnessed" def test_a_payload_key_absent_from_a_known_events_field_list_drops() -> None: 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 cf22db3347c..4cd1aba8671 100644 --- a/apps/api/tests/unit/run/test_list_runs_handler.py +++ b/apps/api/tests/unit/run/test_list_runs_handler.py @@ -72,7 +72,7 @@ async def test_handler_accepts_combined_filters() -> None: status="Running", plan_id=uuid4(), campaign_id=uuid4(), - conduct_mode="Recorded", + conduct_mode="Witnessed", ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, @@ -84,7 +84,7 @@ async def test_handler_accepts_combined_filters() -> None: async def test_handler_accepts_conduct_mode_filter() -> None: handler = bind(build_deps()) page = await handler( - ListRuns(conduct_mode="Recorded"), + ListRuns(conduct_mode="Witnessed"), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) diff --git a/apps/api/tests/unit/run/test_record_watched_run_decider.py b/apps/api/tests/unit/run/test_record_witnessed_run_decider.py similarity index 88% rename from apps/api/tests/unit/run/test_record_watched_run_decider.py rename to apps/api/tests/unit/run/test_record_witnessed_run_decider.py index b7b721bbc4e..9501468c017 100644 --- a/apps/api/tests/unit/run/test_record_watched_run_decider.py +++ b/apps/api/tests/unit/run/test_record_witnessed_run_decider.py @@ -1,6 +1,6 @@ -"""Unit tests for the `record_watched_run` slice's pure decider. +"""Unit tests for the `record_witnessed_run` slice's pure decider. -The watched-genesis counterpart to `test_start_run_decider.py`. Pins the +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 @@ -38,8 +38,8 @@ RunSubjectNotMountableError, SafetyEnvelopeVerdict, ) -from cora.run.features import record_watched_run -from cora.run.features.record_watched_run import RecordWatchedRun, RunWatchedStartContext +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) @@ -102,7 +102,7 @@ def _subject( ) -def _command(**overrides: object) -> RecordWatchedRun: +def _command(**overrides: object) -> RecordWitnessedRun: defaults: dict[str, object] = { "name": "watched capture", "plan_id": uuid4(), @@ -111,7 +111,7 @@ def _command(**overrides: object) -> RecordWatchedRun: "trigger": _TRIGGER, } defaults.update(overrides) - return RecordWatchedRun(**defaults) # type: ignore[arg-type] + return RecordWitnessedRun(**defaults) # type: ignore[arg-type] def _beam( @@ -142,19 +142,19 @@ def _enclosure(permit_status: str, lifecycle: str) -> EnclosureLookupResult: @pytest.mark.unit -def test_decide_emits_run_started_recorded_for_valid_watched_capture() -> None: +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 = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, referencing_clearances=_active_clearance_stub(), ) new_id = uuid4() - decision = record_watched_run.decide( + decision = record_witnessed_run.decide( state=None, command=_command(name="watched capture", plan_id=plan.id), context=context, @@ -166,29 +166,29 @@ def test_decide_emits_run_started_recorded_for_valid_watched_capture() -> None: ) assert len(decision.run_events) == 1 event = decision.run_events[0] - assert event.conduct_mode is ConductMode.RECORDED + assert event.conduct_mode is ConductMode.WITNESSED assert event.safety_envelope_verdict == SafetyEnvelopeVerdict( enclosure_permitted=True, beam_available=True ) - assert event.trigger_source == "CaptureWatcher:2bmb-tomoscan" + 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: - """RecordWatchedRun carries no conduct_mode field for a caller to set; - the decider always stamps RECORDED.""" + """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 = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, referencing_clearances=_active_clearance_stub(), ) - decision = record_watched_run.decide( + decision = record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, @@ -198,7 +198,7 @@ def test_decide_hardcodes_recorded_regardless_of_input() -> None: now=_NOW, new_id=uuid4(), ) - assert decision.run_events[0].conduct_mode is ConductMode.RECORDED + assert decision.run_events[0].conduct_mode is ConductMode.WITNESSED # ---------- The trigger guard ---------- @@ -211,14 +211,14 @@ def test_decide_rejects_any_non_monitor_trigger(bad_trigger: str) -> None: asset_id = uuid4() plan = _plan(asset_ids=frozenset({asset_id})) asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, referencing_clearances=_active_clearance_stub(), ) with pytest.raises(RunMonitorTriggerNotPermittedError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id, trigger=bad_trigger), context=context, @@ -241,7 +241,7 @@ def test_decide_records_failing_enclosure_and_beam_instead_of_raising() -> None: asset_id = uuid4() plan = _plan(asset_ids=frozenset({asset_id})) asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, @@ -249,7 +249,7 @@ def test_decide_records_failing_enclosure_and_beam_instead_of_raising() -> None: referencing_enclosures=(_enclosure("NotPermitted", "Active"),), beam_availability=_beam(quality_ok=False), ) - decision = record_watched_run.decide( + decision = record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, @@ -274,14 +274,14 @@ def test_decide_no_clearance_still_raises() -> None: asset_id = uuid4() plan = _plan(asset_ids=frozenset({asset_id})) asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, referencing_clearances=(), ) with pytest.raises(RunRequiresActiveClearanceError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, @@ -299,7 +299,7 @@ def test_decide_clearance_present_but_inactive_still_raises() -> None: asset_id = uuid4() plan = _plan(asset_ids=frozenset({asset_id})) asset = _asset(asset_id=asset_id, family_ids=frozenset({cap})) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, @@ -314,7 +314,7 @@ def test_decide_clearance_present_but_inactive_still_raises() -> None: ), ) with pytest.raises(RunClearanceCoverageMismatchError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, @@ -338,14 +338,14 @@ def test_decide_on_existing_state_raises_already_exists() -> None: subject_id=None, status=RunStatus.RUNNING, ) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=_plan(), subject=None, assets={}, referencing_clearances=_active_clearance_stub(), ) with pytest.raises(RunAlreadyExistsError) as exc: - record_watched_run.decide( + record_witnessed_run.decide( state=existing, command=_command(), context=context, @@ -361,14 +361,14 @@ def test_decide_on_existing_state_raises_already_exists() -> None: @pytest.mark.unit def test_decide_deprecated_plan_raises() -> None: plan = _plan(status=PlanStatus.DEPRECATED) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={}, referencing_clearances=_active_clearance_stub(), ) with pytest.raises(RunBoundPlanDeprecatedError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, @@ -384,14 +384,14 @@ def test_decide_deprecated_plan_raises() -> None: def test_decide_subject_not_mountable_raises() -> None: plan = _plan() subject = _subject(status=SubjectStatus.RECEIVED) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=subject, assets={}, referencing_clearances=_active_clearance_stub(), ) with pytest.raises(RunSubjectNotMountableError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id, subject_id=subject.id), context=context, @@ -408,14 +408,14 @@ 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 = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, referencing_clearances=_active_clearance_stub(), ) with pytest.raises(RunPlanAssetDecommissionedError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, @@ -432,14 +432,14 @@ 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 = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={asset_id: asset}, referencing_clearances=_active_clearance_stub(), ) with pytest.raises(RunCapabilitiesNotSatisfiedError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, @@ -454,14 +454,14 @@ def test_decide_capability_shortfall_raises() -> None: @pytest.mark.unit def test_decide_invalid_name_raises() -> None: plan = _plan() - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={}, referencing_clearances=_active_clearance_stub(), ) with pytest.raises(InvalidRunNameError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id, name=" "), context=context, @@ -490,14 +490,14 @@ def test_decide_embeds_active_cautions_snapshot() -> None: text_excerpt="Fragile mount", workaround_excerpt="Handle gently", ) - context = RunWatchedStartContext( + context = RunWitnessedStartContext( plan=plan, subject=None, assets={}, referencing_clearances=_active_clearance_stub(), active_cautions=(caution,), ) - decision = record_watched_run.decide( + decision = record_witnessed_run.decide( state=None, command=_command(plan_id=plan.id), context=context, diff --git a/apps/api/tests/unit/run/test_record_watched_run_decider_properties.py b/apps/api/tests/unit/run/test_record_witnessed_run_decider_properties.py similarity index 85% rename from apps/api/tests/unit/run/test_record_watched_run_decider_properties.py rename to apps/api/tests/unit/run/test_record_witnessed_run_decider_properties.py index cd9117f6b1f..afe9cfddad1 100644 --- a/apps/api/tests/unit/run/test_record_watched_run_decider_properties.py +++ b/apps/api/tests/unit/run/test_record_witnessed_run_decider_properties.py @@ -1,6 +1,6 @@ -"""Property-based tests for `record_watched_run.decide` (Run BC). +"""Property-based tests for `record_witnessed_run.decide` (Run BC). -Complements the example-based `test_record_watched_run_decider.py`. +Complements the example-based `test_record_witnessed_run_decider.py`. Universal claims across generated inputs: - Any non-None state always raises `RunAlreadyExistsError`. @@ -11,7 +11,7 @@ `RunRequiresActiveClearanceError` (still a refusal, per the roadmap's rule). - On the happy path, the single `RunStarted` always carries - `conduct_mode=RECORDED` and a non-None `safety_envelope_verdict`. + `conduct_mode=WITNESSED` and a non-None `safety_envelope_verdict`. - Pure: same inputs return equal results. """ @@ -37,8 +37,8 @@ RunStarted, RunStatus, ) -from cora.run.features import record_watched_run -from cora.run.features.record_watched_run import RecordWatchedRun, RunWatchedStartContext +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 @@ -76,8 +76,8 @@ def _context( *, plan_status: PlanStatus = PlanStatus.DEFINED, clearances: tuple[ClearanceLookupResult, ...] | None = None, -) -> RunWatchedStartContext: - return RunWatchedStartContext( +) -> RunWitnessedStartContext: + return RunWitnessedStartContext( plan=_plan(status=plan_status), subject=None, assets={}, @@ -87,8 +87,8 @@ def _context( def _command( *, name: str, plan_id: UUID, capture_code: str, trigger: str = "Monitor" -) -> RecordWatchedRun: - return RecordWatchedRun( +) -> RecordWitnessedRun: + return RecordWitnessedRun( name=name, plan_id=plan_id, capture_code=capture_code, @@ -107,7 +107,7 @@ def _command( now=aware_datetimes(), new_id=st.uuids(), ) -def test_watched_on_existing_state_always_raises_already_exists( +def test_witnessed_on_existing_state_always_raises_already_exists( existing_id: UUID, existing_status: RunStatus, name: str, @@ -124,7 +124,7 @@ def test_watched_on_existing_state_always_raises_already_exists( status=existing_status, ) with pytest.raises(RunAlreadyExistsError) as exc: - record_watched_run.decide( + record_witnessed_run.decide( state=existing, command=_command(name=name, plan_id=plan_id, capture_code=capture_code), context=_context(), @@ -146,7 +146,7 @@ def test_watched_on_existing_state_always_raises_already_exists( now=aware_datetimes(), new_id=st.uuids(), ) -def test_watched_any_non_monitor_trigger_always_raises( +def test_witnessed_any_non_monitor_trigger_always_raises( name: str, plan_id: UUID, capture_code: str, @@ -157,7 +157,7 @@ def test_watched_any_non_monitor_trigger_always_raises( """No input shape can make a non-Monitor trigger pass: the laundering wall holds regardless of every other field.""" with pytest.raises(RunMonitorTriggerNotPermittedError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command( name=name, plan_id=plan_id, capture_code=capture_code, trigger=trigger @@ -179,7 +179,7 @@ def test_watched_any_non_monitor_trigger_always_raises( now=aware_datetimes(), new_id=st.uuids(), ) -def test_watched_without_referencing_clearance_always_raises_requires_clearance( +def test_witnessed_without_referencing_clearance_always_raises_requires_clearance( name: str, plan_id: UUID, capture_code: str, @@ -187,7 +187,7 @@ def test_watched_without_referencing_clearance_always_raises_requires_clearance( new_id: UUID, ) -> None: with pytest.raises(RunRequiresActiveClearanceError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(name=name, plan_id=plan_id, capture_code=capture_code), context=_context(clearances=()), @@ -208,7 +208,7 @@ def test_watched_without_referencing_clearance_always_raises_requires_clearance( now=aware_datetimes(), new_id=st.uuids(), ) -def test_watched_clearance_present_but_never_active_always_raises_coverage_mismatch( +def test_witnessed_clearance_present_but_never_active_always_raises_coverage_mismatch( name: str, plan_id: UUID, capture_code: str, @@ -217,7 +217,7 @@ def test_watched_clearance_present_but_never_active_always_raises_coverage_misma new_id: UUID, ) -> None: """Whatever non-Active status a referencing clearance carries, the - watched path still refuses -- it never treats a present-but-inactive + witnessed path still refuses -- it never treats a present-but-inactive clearance as witnessed.""" clearances = ( ClearanceLookupResult( @@ -229,7 +229,7 @@ def test_watched_clearance_present_but_never_active_always_raises_coverage_misma ), ) with pytest.raises(RunClearanceCoverageMismatchError): - record_watched_run.decide( + record_witnessed_run.decide( state=None, command=_command(name=name, plan_id=plan_id, capture_code=capture_code), context=_context(clearances=clearances), @@ -249,17 +249,17 @@ def test_watched_clearance_present_but_never_active_always_raises_coverage_misma now=aware_datetimes(), new_id=st.uuids(), ) -def test_watched_happy_path_always_emits_recorded_with_a_verdict( +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 RECORDED and a non-None verdict, + """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_watched_run.decide( + result = record_witnessed_run.decide( state=None, command=_command(name=name, plan_id=plan_id, capture_code=capture_code), context=_context(), @@ -272,7 +272,7 @@ def test_watched_happy_path_always_emits_recorded_with_a_verdict( assert len(result.run_events) == 1 event = result.run_events[0] assert isinstance(event, RunStarted) - assert event.conduct_mode is ConductMode.RECORDED + 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 @@ -288,7 +288,7 @@ def test_watched_happy_path_always_emits_recorded_with_a_verdict( now=aware_datetimes(), new_id=st.uuids(), ) -def test_watched_is_pure_same_input_same_output( +def test_witnessed_is_pure_same_input_same_output( name: str, plan_id: UUID, capture_code: str, @@ -297,7 +297,7 @@ def test_watched_is_pure_same_input_same_output( ) -> None: command = _command(name=name, plan_id=plan_id, capture_code=capture_code) context = _context() - first = record_watched_run.decide( + first = record_witnessed_run.decide( state=None, command=command, context=context, @@ -307,7 +307,7 @@ def test_watched_is_pure_same_input_same_output( now=now, new_id=new_id, ) - second = record_watched_run.decide( + second = record_witnessed_run.decide( state=None, command=command, context=context, diff --git a/apps/api/tests/unit/run/test_run_events.py b/apps/api/tests/unit/run/test_run_events.py index f23491fee88..8b1ea612ed4 100644 --- a/apps/api/tests/unit/run/test_run_events.py +++ b/apps/api/tests/unit/run/test_run_events.py @@ -79,7 +79,7 @@ def test_to_payload_serializes_run_started_with_subject_to_primitives() -> None: # "Conducted" when not supplied; forward-compat via # `payload.get("conduct_mode", ConductMode.CONDUCTED.value)`. "conduct_mode": "Conducted", - # Additive payload field: the watched-genesis envelope reading. + # 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, diff --git a/apps/api/tests/unit/run/test_run_evolver.py b/apps/api/tests/unit/run/test_run_evolver.py index 4d00540e01b..d0b560ed644 100644 --- a/apps/api/tests/unit/run/test_run_evolver.py +++ b/apps/api/tests/unit/run/test_run_evolver.py @@ -91,25 +91,25 @@ def test_evolve_run_started_defaults_conduct_mode_to_conducted() -> None: @pytest.mark.unit -def test_evolve_run_started_honors_explicit_recorded_conduct_mode() -> None: - """A RECORDED genesis (the not-yet-built shadow-promotion path) must - declare its mode explicitly; the evolver copies it verbatim, never - computing or guessing it.""" - state = evolve(None, _run_started(conduct_mode=ConductMode.RECORDED)) - assert state.conduct_mode is ConductMode.RECORDED +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.RECORDED) + 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.RECORDED + assert state.conduct_mode is ConductMode.WITNESSED @pytest.mark.unit 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 d43655428b9..a33af1bb8f7 100644 --- a/apps/api/tests/unit/run/test_run_summary_projection.py +++ b/apps/api/tests/unit/run/test_run_summary_projection.py @@ -504,20 +504,20 @@ async def test_run_started_with_pinned_calibration_ids_inserts_uuid_array() -> N @pytest.mark.unit -async def test_run_started_with_recorded_conduct_mode_inserts_recorded() -> None: - """A RECORDED-mode RunStarted payload (the not-yet-built shadow- - promotion path's future emit shape) lands its declared value on the - row verbatim, not the historical Conducted default.""" +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": "watched-scan", + "name": "witnessed-scan", "plan_id": str(_PLAN_ID), "subject_id": None, - "conduct_mode": "Recorded", + "conduct_mode": "Witnessed", "occurred_at": _NOW.isoformat(), }, ) @@ -526,4 +526,4 @@ async def test_run_started_with_recorded_conduct_mode_inserts_recorded() -> None args = conn.execute.await_args assert args is not None - assert args.args[12] == "Recorded" + 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 723c81c1653..761a0f15fc5 100644 --- a/apps/api/tests/unit/run/test_safety_envelope.py +++ b/apps/api/tests/unit/run/test_safety_envelope.py @@ -3,7 +3,7 @@ `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 watched-genesis decider. These pin the pass path and +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. @@ -282,7 +282,7 @@ def test_witness_every_gate_failing_records_both_false() -> None: @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 watched path refuses on it exactly as the driven + facility reading: the witnessed path refuses on it exactly as the driven path does.""" with pytest.raises(RunRequiresActiveClearanceError): _witness(referencing_clearances=()) 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 915f480f3b2..3ae55f13c23 100644 --- a/apps/api/tests/unit/run/test_start_run_decider.py +++ b/apps/api/tests/unit/run/test_start_run_decider.py @@ -167,8 +167,8 @@ def test_decide_emits_run_started_for_valid_sample_run() -> None: 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 `Recorded` Run is genesis-only - through the separate watched-genesis decider.""" + 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})) diff --git a/apps/api/tests/unit/test_settings.py b/apps/api/tests/unit/test_settings.py index b7eea94bc2e..8b8ddcaa0f1 100644 --- a/apps/api/tests/unit/test_settings.py +++ b/apps/api/tests/unit/test_settings.py @@ -301,14 +301,14 @@ def test_settings_capture_watch_defaults_are_empty_and_off( """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_WATCHER_ENABLED", 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_watcher_enabled is False + assert settings.run_witness_enabled is False @pytest.mark.unit 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 index 493e2bc6e05..72700f02ca3 100644 --- a/infra/atlas/migrations/20260814041035_add_proj_run_summary_conduct_mode.sql +++ b/infra/atlas/migrations/20260814041035_add_proj_run_summary_conduct_mode.sql @@ -3,11 +3,12 @@ -- -- 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 to date really was --- Conducted (nothing in the codebase constructs a Recorded-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. +-- 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 d4aa4f58e3e..1586c68a9c7 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:VAd2LX0bZJy/3clCYhf/ux2PAKI9cn4BriMCeDij7Uc= +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,4 +163,4 @@ h1:VAd2LX0bZJy/3clCYhf/ux2PAKI9cn4BriMCeDij7Uc= 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:i34cwORg/LmkqEcT0O5xhE0z6geK91cJr0GtCiNcrLg= +20260814041035_add_proj_run_summary_conduct_mode.sql h1:WVbP5BfF78sLkryrrmmIMUOpetNnUfGIgsFQpZsKCOw=