diff --git a/apps/api/src/cora/api/beamline_staff_seed.py b/apps/api/src/cora/api/beamline_staff_seed.py new file mode 100644 index 00000000000..f0a942ea6d0 --- /dev/null +++ b/apps/api/src/cora/api/beamline_staff_seed.py @@ -0,0 +1,343 @@ +"""The beamline staff seed ceremony: give 2-BM its two real human principals. + +`python -m cora.api.beamline_staff_seed` registers, idempotently, the +two real 2-BM beamline staff as CORA `human` Actors under pinned, +deployment-stable ids, so they exist as usable principals for hands-on +testing (register_actor's decider refuses `kind="agent"`; `human` is +the default path this ceremony takes). + +## Why a CLI ceremony, not a boot-lifespan hook + +`_agent_seed.py` / `_enclosure_seed.py` / `_clearance_template_seed.py` +run automatically on every app boot because an empty or absent config +value makes them a safe no-op everywhere that value is not set. This +seed cannot take that shape: the two people it registers are real, and +their display names are personal data that must never live in this +repository (not in a Settings default, not in a fixture, not in an +env var name baked into the schema of every deployment). The name for +each pinned id has to come from the deploy host at the moment someone +chooses to run this, and a missing name must fail loudly rather than +seed a blank or placeholder, per the PII vault design. Wiring that +requirement into the automatic boot path would mean every OTHER +deployment (dev, CI, test, every other facility) fails to boot unless +it also configures two 2-BM-specific names it has no reason to know. +So this follows `pilot_seed.py`'s shape instead: an explicit, idempotent, +operator-run ceremony, CLI-argument-driven, that reads no descriptor +and touches nothing at import time. + +## Where the names come from + +Each pinned slot resolves its display name from a CLI flag, defaulting +to a same-named environment variable (`BEAMLINE_STAFF_OPERATOR_A_NAME` / +`BEAMLINE_STAFF_OPERATOR_B_NAME`) that the deploy host sets outside this +repository. Neither name is read into `Settings`: promoting them to the +shared configuration schema would put a 2-BM-specific PII concern in +front of every other deployment's config surface. `_require_all_names_ +configured` runs before any database connection is opened, so a missing +name fails immediately and names the unconfigured slot, never the +missing value itself. + +The name is written to the `actor_profile` PII vault via +`kernel.profile_store.upsert` (same call `register_actor`'s handler and +`_agent_seed.seed_agent` make) and is NEVER placed in the `ActorRegistered` +event payload, matching the PII vault pattern documented on +`cora.access.aggregates.actor.events.ActorRegistered`. + +## Identity + +Two pinned ids, one per operator slot, under a namespace distinct from +the seeded-agent range (`01900000-0000-7000-8000-...`): agent ids and +staff-actor ids must never collide, and using a visibly different top +segment plus a different fourth-group nibble (`9000` here vs `8000` +for agents) means a reader can tell which kind of seed minted a given +id without a lookup. Each slot also carries a pinned `event_id` / +`correlation_id` (mirrors `AgentSeedIdentity` in `_agent_seed.py`) so a +re-run derives byte-identical envelopes rather than relying on +`ConcurrencyError` alone to detect the already-seeded case. + +## Idempotency + +Mirrors `_enclosure_seed.py`'s genesis-append shape: pre-check via +`load_actor`, and on a lost race treat `ConcurrencyError` as +already-seeded. No promotion step exists for Actors (unlike Agents), +so there is nothing to strand: a seeded human Actor is immediately a +usable principal. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final +from uuid import UUID + +from cora.access.aggregates.actor import ActorKind, ActorRegistered +from cora.access.aggregates.actor import event_type_name as actor_event_type_name +from cora.access.aggregates.actor import to_payload as actor_to_payload +from cora.access.aggregates.actor.read import load_actor +from cora.infrastructure.config import Settings +from cora.infrastructure.deps import make_postgres_kernel +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.ports import AllowAllAuthorize, SystemClock, UUIDv7Generator +from cora.infrastructure.ports.event_store import ConcurrencyError +from cora.infrastructure.postgres.pool import create_pool +from cora.infrastructure.routing import SYSTEM_PRINCIPAL_ID +from cora.infrastructure.schema_version import verify_schema_version + +if TYPE_CHECKING: + import asyncpg + + from cora.infrastructure.kernel import Kernel + +_STREAM_TYPE = "Actor" +_COMMAND_NAME = "SeedBeamlineStaff" + +_EXIT_CLEAN = 0 +_EXIT_ERROR = 1 +_EXIT_SEEDED = 2 + + +class _BeamlineStaffNameMissingError(RuntimeError): + """Raised when one or more pinned slots have no configured display name. + + The whole point of this error: refuse to seed a blank or + placeholder name into the PII vault. Its message names the + unconfigured slot and env var, never a name (there is none to + name), so the error is safe to print, log, or paste into an issue. + """ + + +@dataclass(frozen=True) +class BeamlineStaffSlot: + """One pinned human-actor slot this ceremony seeds. + + `slot` is an anonymous role label (never a real name) used in log + lines, report output, and as the config key the CLI/env-var lookup + is keyed on. `env_var` is the environment variable this slot's + display name defaults from when no CLI flag is given. + """ + + slot: str + actor_id: UUID + event_id: UUID + correlation_id: UUID + env_var: str + + +#: Distinct from the seeded-agent range (`01900000-0000-7000-8000-...`) +#: by both the top segment and the fourth-group nibble (`9000` vs +#: `8000`), so a human-staff id is visibly not an agent id on sight. +#: Verified against every literal UUID checked into the repo before +#: being picked (see the module docstring's Identity section). +OPERATOR_A_ACTOR_ID: Final[UUID] = UUID("02900000-0000-7000-9000-0000000a0010") +OPERATOR_B_ACTOR_ID: Final[UUID] = UUID("02900000-0000-7000-9000-0000000b0010") + +BEAMLINE_STAFF_SLOTS: Final[tuple[BeamlineStaffSlot, ...]] = ( + BeamlineStaffSlot( + slot="2-bm-operator-a", + actor_id=OPERATOR_A_ACTOR_ID, + event_id=UUID("02900000-0000-7000-9000-0000000a0012"), + correlation_id=UUID("02900000-0000-7000-9000-0000000a0014"), + env_var="BEAMLINE_STAFF_OPERATOR_A_NAME", + ), + BeamlineStaffSlot( + slot="2-bm-operator-b", + actor_id=OPERATOR_B_ACTOR_ID, + event_id=UUID("02900000-0000-7000-9000-0000000b0012"), + correlation_id=UUID("02900000-0000-7000-9000-0000000b0014"), + env_var="BEAMLINE_STAFF_OPERATOR_B_NAME", + ), +) + + +@dataclass +class _Report: + lines: list[str] + seeded: bool = False + failed: bool = False + + def note(self, outcome: str, subject: str, detail: str = "") -> None: + suffix = f" ({detail})" if detail else "" + self.lines.append(f"{outcome:<8} {subject}{suffix}") + if outcome == "seeded": + self.seeded = True + if outcome == "error": + self.failed = True + + +def _require_all_names_configured(names_by_slot: dict[str, str | None]) -> None: + """Fail loudly, before any I/O, if any pinned slot has no real name. + + Checked once for the whole slot set (rather than per-slot at write + time) so a config mistake surfaces immediately, without needing a + database connection, and names every unconfigured slot in one + message instead of stopping at the first. + """ + missing = [ + slot for slot in BEAMLINE_STAFF_SLOTS if not (names_by_slot.get(slot.slot) or "").strip() + ] + if not missing: + return + remedy = ", ".join(f"{slot.env_var} (slot '{slot.slot}')" for slot in missing) + raise _BeamlineStaffNameMissingError( + "refusing to seed a blank or placeholder display name; set the following " + f"on the deploy host before running this ceremony: {remedy}" + ) + + +async def _seed_one_beamline_staff_actor( + kernel: Kernel, + slot: BeamlineStaffSlot, + name: str, + *, + dry_run: bool, + report: _Report, +) -> None: + existing = await load_actor(kernel.event_store, slot.actor_id) + if existing is not None: + report.note("exists", f"actor {slot.slot}") + return + if dry_run: + report.note("seeded", f"actor {slot.slot}", "dry-run, not written") + return + + now = kernel.clock.now() + event = ActorRegistered(actor_id=slot.actor_id, occurred_at=now, kind=ActorKind.HUMAN) + + # Profile vault upsert FIRST, matching `register_actor`'s own handler + # and `_agent_seed.seed_agent`: a crash between this and the append + # below still leaves the name in place for the retry, and no reader + # can observe the actor_id before its display name exists. + await kernel.profile_store.upsert(actor_id=slot.actor_id, name=name, created_at=now) + + new_event = to_new_event( + event_type=actor_event_type_name(event), + payload=actor_to_payload(event), + occurred_at=now, + event_id=slot.event_id, + command_name=_COMMAND_NAME, + correlation_id=slot.correlation_id, + causation_id=None, + principal_id=SYSTEM_PRINCIPAL_ID, + ) + try: + await kernel.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=slot.actor_id, + expected_version=0, + events=[new_event], + ) + except ConcurrencyError: + report.note("exists", f"actor {slot.slot}", "raced another writer; already present") + return + report.note("seeded", f"actor {slot.slot}") + + +async def seed_beamline_staff( + *, + names_by_slot: dict[str, str | None], + dry_run: bool, + database_url: str | None = None, +) -> int: + """Run the ceremony. `database_url` overrides the Settings value so + the integration tier can point a run at its per-test database; the + CLI always uses the deployment's own configuration. + + A missing or blank name fails the same way any other ceremony error + does: caught below, reported as a named line, exit code 1. It is + still checked before the pool is opened, so a misconfigured run + never touches the database at all. + """ + report = _Report(lines=[]) + pool: asyncpg.Pool | None = None + try: + _require_all_names_configured(names_by_slot) + + settings = Settings() + pool = await create_pool( + database_url if database_url is not None else settings.database_url, + min_size=1, + max_size=4, + ) + await verify_schema_version(pool) + kernel = make_postgres_kernel( + pool, + settings=settings, + clock=SystemClock(), + id_generator=UUIDv7Generator(), + authz=AllowAllAuthorize(), + ) + for slot in BEAMLINE_STAFF_SLOTS: + name = names_by_slot[slot.slot] + assert name is not None and name.strip(), "checked by _require_all_names_configured" + await _seed_one_beamline_staff_actor( + kernel, slot, name.strip(), dry_run=dry_run, report=report + ) + return _finish(report, dry_run) + except Exception as exc: # the ceremony is a CLI: name it, exit 1 + report.note("error", "ceremony", str(exc)) + return _finish(report, dry_run) + finally: + if pool is not None: + await pool.close() + + +def _finish(report: _Report, dry_run: bool) -> int: + header = "beamline staff seed (dry run)" if dry_run else "beamline staff seed" + print(header) + for line in report.lines: + print(f" {line}") + if report.failed: + return _EXIT_ERROR + return _EXIT_SEEDED if report.seeded else _EXIT_CLEAN + + +def build_parser() -> argparse.ArgumentParser: + """The CLI surface, separate from `main` so tests can pin the + defaults and flags without touching a database or the environment. + + Each name flag defaults from its matching environment variable so + the real names never appear as a CLI literal in a shell history + unless the operator chooses to pass them that way; the deploy + host's own environment is the intended source. + """ + parser = argparse.ArgumentParser( + prog="python -m cora.api.beamline_staff_seed", + description=( + "Register the two 2-BM beamline staff as CORA human Actors under " + "pinned, deployment-stable ids, so they exist as usable principals " + "for hands-on testing. Display names come from the flags below " + "(or their matching environment variables) and land only in the " + "actor_profile PII vault; neither this ceremony nor the repository " + "ever carries a real name. Idempotent; re-runs report and change " + "nothing." + ), + ) + parser.add_argument( + "--operator-a-name", + default=os.environ.get("BEAMLINE_STAFF_OPERATOR_A_NAME"), + help="Display name for slot '2-bm-operator-a' (default: $BEAMLINE_STAFF_OPERATOR_A_NAME).", + ) + parser.add_argument( + "--operator-b-name", + default=os.environ.get("BEAMLINE_STAFF_OPERATOR_B_NAME"), + help="Display name for slot '2-bm-operator-b' (default: $BEAMLINE_STAFF_OPERATOR_B_NAME).", + ) + parser.add_argument("--dry-run", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + names_by_slot = { + "2-bm-operator-a": args.operator_a_name, + "2-bm-operator-b": args.operator_b_name, + } + return asyncio.run(seed_beamline_staff(names_by_slot=names_by_slot, dry_run=args.dry_run)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/api/tests/integration/test_beamline_staff_seed_postgres.py b/apps/api/tests/integration/test_beamline_staff_seed_postgres.py new file mode 100644 index 00000000000..17304f8adf3 --- /dev/null +++ b/apps/api/tests/integration/test_beamline_staff_seed_postgres.py @@ -0,0 +1,188 @@ +"""The beamline staff seed ceremony, end to end against real Postgres. + +Covers the four claims the task requires proof for: both actors land +under their pinned ids with `kind=human`, a re-run is a true no-op, the +appended event's payload carries no name, and the vault holds the name +that was actually configured for this run, not a placeholder. + +Every name used here is invented ("Test Operator A/B"); the real 2-BM +staff names are personal data and never appear in this repository, +including in tests. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from uuid import uuid4 + +import asyncpg +import pytest +import pytest_asyncio +from testcontainers.postgres import PostgresContainer + +from cora.api.beamline_staff_seed import ( + BEAMLINE_STAFF_SLOTS, + OPERATOR_A_ACTOR_ID, + OPERATOR_B_ACTOR_ID, + seed_beamline_staff, +) +from cora.infrastructure.postgres.pool import create_pool +from tests._postgres import normalize_async_url + +pytestmark = pytest.mark.integration + +SeedDatabase = tuple[asyncpg.Pool, str] + +_NAMES: dict[str, str | None] = { + "2-bm-operator-a": "Test Operator A", + "2-bm-operator-b": "Test Operator B", +} + + +@pytest_asyncio.fixture +async def seed_database( + postgres_container: PostgresContainer, + template_database: str, +): + """A per-test database plus its URL, because the ceremony builds its + own pool from a URL rather than borrowing the fixture's.""" + test_db = f"seed_{uuid4().hex[:12]}" + admin_url = normalize_async_url(postgres_container.get_connection_url(), database="postgres") + admin = await asyncpg.connect(admin_url) + try: + await admin.execute(f'CREATE DATABASE "{test_db}" TEMPLATE "{template_database}"') + finally: + await admin.close() + + test_url = normalize_async_url(postgres_container.get_connection_url(), database=test_db) + pool = await create_pool(test_url, min_size=1, max_size=4) + try: + yield pool, test_url + finally: + await pool.close() + admin = await asyncpg.connect(admin_url) + try: + await admin.execute(f'DROP DATABASE "{test_db}"') + finally: + await admin.close() + + +async def _run_ceremony( + url: str, *, dry_run: bool = False, names: dict[str, str | None] | None = None +) -> int: + return await seed_beamline_staff( + names_by_slot=_NAMES if names is None else names, + dry_run=dry_run, + database_url=url, + ) + + +async def test_ceremony_seeds_both_actors_with_pinned_ids_and_human_kind( + seed_database: SeedDatabase, +) -> None: + pool, url = seed_database + + exit_code = await _run_ceremony(url) + assert exit_code == 2 + + for actor_id in (OPERATOR_A_ACTOR_ID, OPERATOR_B_ACTOR_ID): + row = await pool.fetchrow( + "SELECT event_type, payload FROM events WHERE stream_id = $1", actor_id + ) + assert row is not None + assert row["event_type"] == "ActorRegisteredV2" + assert row["payload"]["kind"] == "human" + + +async def test_ceremony_rerun_changes_nothing(seed_database: SeedDatabase) -> None: + pool, url = seed_database + + first = await _run_ceremony(url) + assert first == 2, "first run must report seeded" + + events_after_first = await pool.fetchval("SELECT COUNT(*) FROM events") + + second = await _run_ceremony(url) + assert second == 0, "second run must report all-exists" + + events_after_second = await pool.fetchval("SELECT COUNT(*) FROM events") + assert events_after_second == events_after_first, "a re-run must append zero events" + + +async def test_seeded_event_payload_carries_no_name(seed_database: SeedDatabase) -> None: + pool, url = seed_database + assert await _run_ceremony(url) == 2 + + for actor_id in (OPERATOR_A_ACTOR_ID, OPERATOR_B_ACTOR_ID): + payload = await pool.fetchval("SELECT payload FROM events WHERE stream_id = $1", actor_id) + assert "name" not in payload + + +async def test_seeded_name_lands_only_in_the_profile_vault(seed_database: SeedDatabase) -> None: + pool, url = seed_database + assert await _run_ceremony(url) == 2 + + row = await pool.fetchrow( + "SELECT name FROM actor_profile WHERE actor_id = $1", OPERATOR_A_ACTOR_ID + ) + assert row is not None + assert row["name"] == "Test Operator A" + + row_b = await pool.fetchrow( + "SELECT name FROM actor_profile WHERE actor_id = $1", OPERATOR_B_ACTOR_ID + ) + assert row_b is not None + assert row_b["name"] == "Test Operator B" + + +async def test_dry_run_writes_nothing(seed_database: SeedDatabase) -> None: + pool, url = seed_database + + exit_code = await _run_ceremony(url, dry_run=True) + assert exit_code == 2, "dry run against a fresh database reports would-seed" + + stream_ids = [slot.actor_id for slot in BEAMLINE_STAFF_SLOTS] + event_count = await pool.fetchval( + "SELECT COUNT(*) FROM events WHERE stream_id = ANY($1::uuid[])", stream_ids + ) + assert event_count == 0 + + profile_count = await pool.fetchval( + "SELECT COUNT(*) FROM actor_profile WHERE actor_id = ANY($1::uuid[])", stream_ids + ) + assert profile_count == 0 + + +async def test_missing_name_fails_loudly_and_writes_nothing(seed_database: SeedDatabase) -> None: + pool, url = seed_database + + exit_code = await _run_ceremony(url, names={"2-bm-operator-a": "Test Operator A"}) + assert exit_code == 1 + + stream_ids = [slot.actor_id for slot in BEAMLINE_STAFF_SLOTS] + event_count = await pool.fetchval( + "SELECT COUNT(*) FROM events WHERE stream_id = ANY($1::uuid[])", stream_ids + ) + assert event_count == 0, "a missing name must fail before any write, for either slot" + + +async def test_blank_name_fails_loudly_and_writes_nothing(seed_database: SeedDatabase) -> None: + _, url = seed_database + + exit_code = await _run_ceremony( + url, names={"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": " "} + ) + assert exit_code == 1 + + +async def test_mid_ceremony_exception_reports_error_and_exit_one( + seed_database: SeedDatabase, monkeypatch: pytest.MonkeyPatch +) -> None: + _, url = seed_database + + async def explode(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("synthetic mid-ceremony failure") + + monkeypatch.setattr("cora.api.beamline_staff_seed.verify_schema_version", explode) + + exit_code = await _run_ceremony(url) + assert exit_code == 1 diff --git a/apps/api/tests/unit/api/test_beamline_staff_seed.py b/apps/api/tests/unit/api/test_beamline_staff_seed.py new file mode 100644 index 00000000000..7fe506ac24a --- /dev/null +++ b/apps/api/tests/unit/api/test_beamline_staff_seed.py @@ -0,0 +1,201 @@ +"""beamline_staff_seed's pure parts: pinned identity, fail-loud name +validation, report semantics, and the CLI. + +The database-touching flow lives in the integration tier +(test_beamline_staff_seed_postgres); this tier pins what must never +drift without ceremony: the two pinned actor ids (a re-pin orphans +every deployment that already ran this ceremony), the loud refusal to +seed a blank display name, and the CLI/env-var defaulting. + +Every name literal here is invented ("Test Operator A/B"); the real +2-BM staff names are personal data and never appear in this +repository, including in tests. +""" + +from uuid import UUID + +import pytest + +from cora.api.beamline_staff_seed import ( + BEAMLINE_STAFF_SLOTS, + OPERATOR_A_ACTOR_ID, + OPERATOR_B_ACTOR_ID, + _BeamlineStaffNameMissingError, # pyright: ignore[reportPrivateUsage] + _Report, # pyright: ignore[reportPrivateUsage] + _require_all_names_configured, # pyright: ignore[reportPrivateUsage] + build_parser, +) + +pytestmark = pytest.mark.unit + + +def test_operator_actor_ids_are_the_locked_constants() -> None: + assert UUID("02900000-0000-7000-9000-0000000a0010") == OPERATOR_A_ACTOR_ID + assert UUID("02900000-0000-7000-9000-0000000b0010") == OPERATOR_B_ACTOR_ID + + +def test_operator_actor_ids_are_distinct_from_the_seeded_agent_range() -> None: + """Seeded agents live under `01900000-0000-7000-8000-...`; a human + staff id must never fall in that range, or a UUID alone can no + longer tell a human actor from a seeded agent.""" + for actor_id in (OPERATOR_A_ACTOR_ID, OPERATOR_B_ACTOR_ID): + assert str(actor_id).startswith("02900000-") + assert "-8000-" not in str(actor_id) + + +def test_beamline_staff_slots_has_exactly_two_slots_with_distinct_ids() -> None: + assert len(BEAMLINE_STAFF_SLOTS) == 2 + actor_ids = {slot.actor_id for slot in BEAMLINE_STAFF_SLOTS} + event_ids = {slot.event_id for slot in BEAMLINE_STAFF_SLOTS} + correlation_ids = {slot.correlation_id for slot in BEAMLINE_STAFF_SLOTS} + assert len(actor_ids) == 2 + assert len(event_ids) == 2 + assert len(correlation_ids) == 2 + + +def test_beamline_staff_slots_env_vars_are_distinct() -> None: + env_vars = {slot.env_var for slot in BEAMLINE_STAFF_SLOTS} + assert len(env_vars) == len(BEAMLINE_STAFF_SLOTS) + + +def test_require_all_names_configured_passes_when_both_names_present() -> None: + _require_all_names_configured( + {"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": "Test Operator B"} + ) + + +def test_require_all_names_configured_rejects_missing_slot() -> None: + with pytest.raises(_BeamlineStaffNameMissingError): + _require_all_names_configured({"2-bm-operator-a": "Test Operator A"}) + + +def test_require_all_names_configured_rejects_blank_name() -> None: + with pytest.raises(_BeamlineStaffNameMissingError): + _require_all_names_configured( + {"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": " "} + ) + + +def test_require_all_names_configured_rejects_none_name() -> None: + with pytest.raises(_BeamlineStaffNameMissingError): + _require_all_names_configured( + {"2-bm-operator-a": "Test Operator A", "2-bm-operator-b": None} + ) + + +def test_require_all_names_configured_error_names_every_missing_slot() -> None: + """A config mistake should surface every unconfigured slot at once, + not just the first, so an operator fixes it in one pass.""" + with pytest.raises(_BeamlineStaffNameMissingError) as excinfo: + _require_all_names_configured({}) + message = str(excinfo.value) + assert "2-bm-operator-a" in message + assert "2-bm-operator-b" in message + assert "BEAMLINE_STAFF_OPERATOR_A_NAME" in message + assert "BEAMLINE_STAFF_OPERATOR_B_NAME" in message + + +def test_require_all_names_configured_error_never_carries_a_name() -> None: + """The error is safe to print or log precisely because it names + slots and env vars, never a value; assert the one name that WAS + supplied does not leak into the message about the other slot.""" + with pytest.raises(_BeamlineStaffNameMissingError) as excinfo: + _require_all_names_configured({"2-bm-operator-a": "Test Operator A"}) + assert "Test Operator A" not in str(excinfo.value) + + +def test_report_all_exists_leaves_seeded_and_failed_unset() -> None: + report = _Report(lines=[]) + report.note("exists", "actor 2-bm-operator-a") + report.note("exists", "actor 2-bm-operator-b") + assert report.seeded is False + assert report.failed is False + + +def test_report_any_seed_marks_seeded() -> None: + report = _Report(lines=[]) + report.note("exists", "actor 2-bm-operator-a") + report.note("seeded", "actor 2-bm-operator-b") + assert report.seeded is True + assert report.failed is False + + +def test_report_any_error_marks_failed() -> None: + report = _Report(lines=[]) + report.note("seeded", "actor 2-bm-operator-a") + report.note("error", "ceremony", "synthetic failure") + assert report.failed is True + + +def test_parser_defaults_read_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BEAMLINE_STAFF_OPERATOR_A_NAME", "Test Operator A") + monkeypatch.setenv("BEAMLINE_STAFF_OPERATOR_B_NAME", "Test Operator B") + from importlib import reload + + from cora.api import beamline_staff_seed + + reload(beamline_staff_seed) + args = beamline_staff_seed.build_parser().parse_args([]) + assert args.operator_a_name == "Test Operator A" + assert args.operator_b_name == "Test Operator B" + reload(beamline_staff_seed) + + +def test_parser_defaults_are_none_without_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BEAMLINE_STAFF_OPERATOR_A_NAME", raising=False) + monkeypatch.delenv("BEAMLINE_STAFF_OPERATOR_B_NAME", raising=False) + from importlib import reload + + from cora.api import beamline_staff_seed + + reload(beamline_staff_seed) + args = beamline_staff_seed.build_parser().parse_args([]) + assert args.operator_a_name is None + assert args.operator_b_name is None + assert args.dry_run is False + + +def test_parser_accepts_cli_overrides() -> None: + args = build_parser().parse_args( + [ + "--operator-a-name", + "Test Operator A", + "--operator-b-name", + "Test Operator B", + "--dry-run", + ] + ) + assert args.operator_a_name == "Test Operator A" + assert args.operator_b_name == "Test Operator B" + assert args.dry_run is True + + +def test_main_parses_argv_and_returns_the_ceremony_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from cora.api import beamline_staff_seed + + received: dict[str, object] = {} + + async def fake_ceremony(**kwargs: object) -> int: + received.update(kwargs) + return 2 + + monkeypatch.setattr(beamline_staff_seed, "seed_beamline_staff", fake_ceremony) + + exit_code = beamline_staff_seed.main( + [ + "--operator-a-name", + "Test Operator A", + "--operator-b-name", + "Test Operator B", + "--dry-run", + ] + ) + + assert exit_code == 2 + assert received["names_by_slot"] == { + "2-bm-operator-a": "Test Operator A", + "2-bm-operator-b": "Test Operator B", + } + assert received["dry_run"] is True diff --git a/docs/deployments/2-bm/governance.md b/docs/deployments/2-bm/governance.md index 674a2f0c650..0f4019c9f9c 100644 --- a/docs/deployments/2-bm/governance.md +++ b/docs/deployments/2-bm/governance.md @@ -1,19 +1,23 @@ # Governance -*Who may act at 2-BM, and the trust policies that gate their commands. Static config; the per-run -[decisions](experiment.md) operators and agents make are live, not here.* +*Who may act at 2-BM, and the trust policy that would gate their commands, if one were defined. Static config; +the per-run [decisions](experiment.md) operators and agents make are live, not here.* ## Who acts -The operator pool on shift, conceptually beamline-scoped. Facility-process principals (proposal PIs, the safety -review board, the beamline scientist acting in a review-chain capacity) are facility-wide and live at -[APS](../aps/index.md#safety-and-governance). See [Model](../../architecture/model.md) for the aggregate shape. +Two beamline staff hold operator principals at 2-BM today, seeded as `human` Actors by the +`cora.api.beamline_staff_seed` ceremony under pinned, deployment-stable ids. Their display names are personal +data: the ceremony writes each name only to the `actor_profile` PII vault, supplied at deploy time from the host +environment. The repository carries the pinned seat ids and nothing else about these two people: an id is opaque, +and no name, badge, ORCID, or address is checked in anywhere. Facility-process +principals (proposal PIs, the safety review board, the beamline scientist acting in a review-chain capacity) are +facility-wide and live at [APS](../aps/index.md#safety-and-governance). See [Model](../../architecture/model.md) +for the aggregate shape. | Actor | Kind | | --- | --- | -| `2-BM Operator 1` | `human` | -| `2-BM Operator 2` | `human` | -| `2-BM Operator 3` | `human` | +| 2-BM operator (seat A) | `human` | +| 2-BM operator (seat B) | `human` | ## The trust boundary @@ -25,9 +29,16 @@ review board, the beamline scientist acting in a review-chain capacity) are faci | --- | --- | --- | | `2-BM Zone` | `2-BM Local Conduit` | `2-BM Zone` -> `2-BM Zone` | -A Policy governs who may issue which command across a Conduit. +## Policy: not yet defined -| Policy | Permitted principals | Permitted commands | -| --- | --- | --- | -| `2-BM Operations Policy` | `2-BM Operator 1..3` (above) | Operator-driven commands (Equipment, Recipe, Operation, Run, Subject, Dataset, Caution, Clearance, Supply, Campaign) | -| `2-BM Agent Policy` | `Run Debrief` (see [APS principals](../aps/index.md#safety-and-governance)) | Decision family: `RegisterDecision`, `RateDecision`, `AppendInferences` | +`TrustAuthorize` gates commands against exactly one configured Policy per deployment (`Settings.trust_policy_id` +is a single, optional `UUID`, not a collection). A deployment cannot run more than one Policy at a time, so a +two-Policy split (one for operator commands, a separate one for agent-issued Decision commands) is not a shape +`Settings` can express; any such split would have to be modeled inside one Policy's own permitted-principals and +permitted-commands rows instead. + +2-BM has not defined a Policy yet, and `trust_policy_id` is unset. With no Policy configured, the deployment +runs on `AllowAllAuthorize`, the permissive stub that admits every command from every principal regardless of +Zone, Conduit, or Actor kind. The two seeded operators above are therefore usable principals for hands-on +testing, not principals a Policy has actually vetted. Defining a real Policy for 2-BM, and switching +`trust_policy_id` to point at it, is future work.