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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 92 additions & 6 deletions apps/api/src/cora/api/_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@
deployment has no pool at all (the in-memory kernel), so there is
nothing to report rather than nothing wrong."""

ActuationReach = Literal["inert", "reachable"]
"""Whether this deployment can drive a physical effect on the beamline.

`inert` = both actuation paths are shut: CORA may observe but cannot
move hardware. `reachable` = at least one path is open.

This is a REPORT, not a gate. It reads the settings the real gates
already use (`ControlPort` write switch, `ComputePort` substrate) and
summarises them so a skeptical controls engineer can VERIFY the
observe-only claim in one call instead of trusting it. It decides
nothing and refuses nothing; the gates do that. Making it a pure report
is deliberate: a report that is wrong is a bug someone files, whereas a
gate that can be talked into the wrong answer moves a motor. See
`derive_actuation`.
"""

# Total wall budget for the probe, and the slice of it the acquire may
# take. Both are load-bearing and neither is redundant:
#
Expand Down Expand Up @@ -120,24 +136,94 @@ async def probe_database(pool: asyncpg.Pool | None) -> DatabaseStatus:
return "ok"


def derive_actuation(settings: Settings) -> ActuationReach:
"""Summarise whether either actuation path can drive hardware.

Two paths reach a beamline, and this reports `inert` only when BOTH
are shut:

- the ControlPort write path: `control_writes_enabled` (a caput
that moves a motor);
- the ComputePort exec path: a compute job whose argv could itself
be `caput ...`, reaching a control system without touching the
ControlPort. Only `compute_substrate == "in_memory"` is provably
unable to spawn anything, so any OTHER substrate reads as
reachable.

The compute test is written as an ALLOWLIST (`== "in_memory"`), not a
denylist (`!= "local_process"`), on purpose: a future `globus` or
`slurm` substrate is not-in_memory and so reads reachable by default,
rather than slipping through a denylist the day its literal is added.

## Fail closed in the REPORTING direction

When the answer is uncertain, this returns `reachable`, never
`inert`. That deliberately INVERTS the `getattr(..., None)` idiom the
Conductor's `_ActuationObserver` uses (whose absent case fails OPEN),
because the pessimistic answer sits on the other side here: a false
`reachable` is an alarm someone investigates and files as a bug; a
false `inert` is a facility discovering CORA moved their stage after
being told it could not. Only the first is survivable.

## What this deliberately does NOT read

`cora_allow_raw_conduct` is excluded. It is read per-request, not at
the composition root, and it is inert while `compute_substrate` is
`in_memory` (a raw argv handed to the in-memory port spawns nothing).
It is logged at boot beside this summary (see `main`) so an operator
can still see it, but it does not move this value.

`ActuationKind` (the Conductor's Physical/Simulated/Hybrid stamp)
also cannot witness this posture and is not consulted: it tracks
whether a route is a SIMULATOR, not whether it is WRITABLE, so an
observe-only conduct over a non-simulated route still derives
Physical. Simulated-ness and write-reachability are orthogonal axes
that merely share the "actuation" lexeme.

## Scope

This is the ACTUATION axis only (moving hardware). The EGRESS axis
(sending data out, e.g. the LLM seam) and the SPEND axis are separate
promises with their own switches; when they gain reported summaries
they slot in beside this one rather than widening its meaning.
"""
control_can_write = settings.control_writes_enabled
compute_can_spawn = settings.compute_substrate != "in_memory"
if control_can_write or compute_can_spawn:
return "reachable"
return "inert"


def readiness_body(database: DatabaseStatus, settings: Settings) -> dict[str, str]:
"""Render the probe result. Fixed vocabulary, no free text.

`app_env` is here because `test` builds an in-memory kernel with no
persistence; a green probe over a store that loses every Run on
restart is worth making visible to whoever reads this.
restart is worth making visible to whoever reads this. `actuation`
is here because the pilot's whole value is that management, who will
never hold credentials, can curl the observe-only claim: a green
probe on a process that could move a beamline clears that same bar,
for a stronger reason.

Deliberately absent: the database URL, the driver's error text, and
the names of projections or bounded contexts. This endpoint is
unauthenticated, and none of that helps a probe while all of it
describes the deployment to anyone who can reach it. The logs carry
the detail.
the names of projections, bounded contexts, or route prefixes. This
endpoint is unauthenticated, and none of that helps a probe while all
of it describes the deployment to anyone who can reach it. `actuation`
is a scalar for exactly this reason: it says inert-or-reachable, not
WHICH prefixes are writable. The logs carry the detail.
"""
return {
"status": "ready" if database in ("ok", "skipped") else "not_ready",
"database": database,
"app_env": settings.app_env,
"actuation": derive_actuation(settings),
}


__all__ = ["DatabaseStatus", "probe_database", "readiness_body"]
__all__ = [
"ActuationReach",
"DatabaseStatus",
"derive_actuation",
"probe_database",
"readiness_body",
]
22 changes: 21 additions & 1 deletion apps/api/src/cora/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
from cora.api._enclosure_permit_observer import ControlPortEnclosureObserver
from cora.api._inference_recorder import DelegatingInferenceRecorder
from cora.api._procedure_watcher import procedure_watcher_lifespan
from cora.api._readiness import probe_database, readiness_body
from cora.api._readiness import derive_actuation, probe_database, readiness_body
from cora.api._run_initiator import run_initiator_lifespan
from cora.api._run_supervisor import run_supervisor_lifespan
from cora.api.middleware import BodySizeLimitMiddleware
Expand Down Expand Up @@ -182,6 +182,7 @@
from cora.infrastructure.deps import build_kernel
from cora.infrastructure.idempotency_pruner import idempotency_pruner_lifespan
from cora.infrastructure.kernel import Kernel
from cora.infrastructure.logging import get_logger
from cora.infrastructure.observability import configure_tracing, instrument_app
from cora.infrastructure.projection import (
ProjectionRegistry,
Expand Down Expand Up @@ -271,6 +272,8 @@ def _settings_for_app() -> Settings:
# local and any other env name keep the permissive default.
_PROD_LIKE_APP_ENVS = frozenset({"prod", "production", "staging"})

_log = get_logger(__name__)


def _enforce_production_principal_policy(settings: Settings) -> None:
"""Refuse to boot deployments where the principal-fallback would
Expand Down Expand Up @@ -756,6 +759,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
app.state.operation = wire_operation(
deps, control_port=shared_control_port, compute_port=compute_port
)

# Boot posture. The derived `actuation` summary rides ALONGSIDE
# its own raw inputs, never instead of them, so the one-word
# answer can be audited against the settings it came from
# rather than trusted. `cora_allow_raw_conduct` is logged but
# excluded from the summary (it is read per-request and inert
# under compute_substrate=in_memory); see `derive_actuation`.
_log.info(
"boot.actuation_posture",
actuation=derive_actuation(settings),
control_writes_enabled=settings.control_writes_enabled,
control_port_route_count=len(settings.control_port_routes),
compute_substrate=settings.compute_substrate,
compute_permitted_executable_count=len(settings.compute_permitted_executables),
cora_allow_raw_conduct=settings.cora_allow_raw_conduct,
)

app.state.safety = wire_safety(deps)
app.state.caution = wire_caution(deps)
app.state.calibration = wire_calibration(deps)
Expand Down
21 changes: 16 additions & 5 deletions apps/api/src/cora/infrastructure/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,22 @@

OtelExporter = Literal["otlp", "console", "none"]

# ComputePort substrate selector. Mirrors the operation-tier
# `ComputeSubstrate` in `cora.operation.adapters.compute_port_config`
# (a trivial 2-value Literal kept per tier rather than centralised in a
# new infrastructure module, since there is no shared route model the
# way ControlPort's `Substrate` rides `ControlPortRoute`).
# ComputePort substrate selector. Deliberately NARROWER than the
# operation-tier `ComputeSubstrate` in
# `cora.operation.adapters.compute_port_config`, which is a 3-value
# Literal (adds `globus`). `globus` is excluded here on purpose: it
# cannot be built from a flat config string (it needs an authorized
# client + endpoint id + a remote artifact probe), so it is injected as
# a prebuilt port at the composition root, never selected via this env
# var. This tier therefore names only the substrates a deployment can
# actually pick with `COMPUTE_SUBSTRATE`. Kept per-tier rather than
# centralised because there is no shared route model the way
# ControlPort's `Substrate` rides `ControlPortRoute`.
#
# `derive_actuation` (cora.api._readiness) reads this as an allowlist
# (`== "in_memory"` is the only provably-inert value), so if a real
# selectable substrate is ever added here, it correctly reads as
# actuation-reachable by default.
ComputeSubstrate = Literal["in_memory", "local_process"]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
Dict-backed, no substrate. A test seeds the observation progression a begun
move will report; the engine-under-test calls `begin` / `observe` / `cancel`
against the same instance and walks the seeded snapshots toward a terminal.
This is the only `TransferPort` adapter that exists: there is no production
substrate adapter yet, because the build trigger has not fired (see
`cora.operation.ports.transfer_port`). The fake serves the triage that asks
This is the test double. Real substrate adapters (`FdtTransferPort`,
`GlobusTransferPort`) now exist too, but none is constructed at the
composition root, so no live egress path runs (see
`cora.operation.ports.transfer_port` and
`test_dormant_outbound_seams_unwired`). The fake serves the triage that asks
whether a transfer is a conductor step or a long-running edge job.

## Seeding model
Expand Down
19 changes: 12 additions & 7 deletions apps/api/src/cora/operation/ports/transfer_port.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@

## Earned, not minted

There is no production consumer yet. This port exists to finalize the design
triage (is a transfer a step the conductor walks, or a long-running edge job
with its own lifecycle?), and ships with a test double only. A real adapter
(Globus first) and the executing aggregate land when the build trigger fires:
a promote or publish rule that blocks on a transfer outcome, an in-path
substrate with a native partial terminal, or a custody chain the existing
`Attestation` path cannot carry. Until then this is a spike, not a merge.
Adapters now exist (`InMemoryTransferPort` for tests; `FdtTransferPort` and
`GlobusTransferPort` as real substrates), and `cora.api._distribution_materializer`
consumes the port. But nothing constructs any of it: there is no transfer-port
factory (no `build_transfer_port` sibling to `build_control_port`), the only
consumer takes its port as an injected field, and that consumer is itself
never constructed in `src`. `test_dormant_outbound_seams_unwired` pins the
real adapters as unconstructed. So the executing aggregate and the
composition-root wiring still wait on the build trigger: a promote or publish
rule that blocks on a transfer outcome, an in-path substrate with a native
partial terminal, or a custody chain the existing `Attestation` path cannot
carry. Until one fires, the real adapters are dormant code, not a live egress
path.

## Why `begin` / `observe` / `cancel`, not blocking submit-and-await

Expand Down
48 changes: 48 additions & 0 deletions apps/api/tests/architecture/test_actuation_defaults_fail_closed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""The actuation defaults ship SAFE: a blank deployment cannot drive hardware.

CORA's observe-only pilot posture is not a mode an operator selects; it is
the DEFAULT. A `docker run` with only a database URL and a route table can
observe every configured PV and drive none. That property is the whole
adoption pitch ("install it, it cannot touch your hardware"), and it holds
only while all three actuation-relevant defaults stay on the safe side:

- `control_writes_enabled` defaults False (the ControlPort write gate);
- `compute_substrate` defaults `in_memory` (spawns no subprocess, so the
ComputePort exec path cannot reach a control system);
- `compute_permitted_executables` defaults empty (even if a deployment
switches to `local_process`, an empty allowlist permits nothing).

A future edit that flips any of these defaults would silently widen every
observe-only deployment on the growth ladder at once, with zero test
failure, until a facility noticed CORA had moved something. This fitness
turns that into a build break.

## Why the DECLARED default, not an instantiated Settings

`tests/conftest.py` sets `CONTROL_WRITES_ENABLED=true` for the test
environment (observe-only is a production posture; the suite exercises the
full writable surface). So `Settings().control_writes_enabled` is True under
test and would tell us nothing about what ships. `model_fields[...].default`
reads the class's DECLARED default directly, immune to the environment, so
this asserts the property a real deployment gets, not the one the test env
overrides.
"""

import pytest

from cora.infrastructure.config import Settings


@pytest.mark.architecture
def test_control_writes_default_is_off() -> None:
assert Settings.model_fields["control_writes_enabled"].default is False


@pytest.mark.architecture
def test_compute_substrate_default_is_in_memory() -> None:
assert Settings.model_fields["compute_substrate"].default == "in_memory"


@pytest.mark.architecture
def test_compute_permitted_executables_default_is_empty() -> None:
assert Settings.model_fields["compute_permitted_executables"].default == frozenset()
Loading
Loading