Skip to content

The Tango arm: a second control substrate, and a typed address at the registry seam#568

Merged
xmap merged 10 commits into
mainfrom
worktree-tango-control-port
Jul 16, 2026
Merged

The Tango arm: a second control substrate, and a typed address at the registry seam#568
xmap merged 10 commits into
mainfrom
worktree-tango-control-port

Conversation

@xmap

@xmap xmap commented Jul 16, 2026

Copy link
Copy Markdown
Owner

The Operation BC's ControlPort gains its second control substrate, and the control address becomes a typed sum at the layer where substrate identity actually lives.

The shape

ControlPort stays str-addressed for callers. The Conductor receives raw address strings from recipe steps and cannot know a substrate: whether 2bm:rot is Channel Access, 2bm:cam1:image is pvAccess, or id19/bsh/1/state is a Tango attribute is a property of the deployment route table, never of the string. So the string surface is where callers belong.

The typed promotion lands at the registry-to-adapter seam instead. ControlPortRegistry matches a route by prefix, parses the address per that route's declared substrate, and hands a typed ControlAddress (EpicsPvAddress | TangoAttributeAddress | InMemoryAddress) to a SubstrateControlPort. Adapters stop re-parsing strings on every call. The sum discriminates substrate FAMILY, not transport: both EPICS transports share EpicsPvAddress, because a PV name is the same shape on either and CA-vs-PVA is a route decision. OPC UA stays deferred until its adapter lands.

TangoControlPort ships behind an optional tango extra. PyTango binds the C++ Tango core plus omniORB, so it stays out of the base install; the adapter probes importability at construction and fails as a ValueError at wiring rather than an ImportError mid-conduct.

The trigger is real, not speculative: the p10 and p21 (DESY PETRA III) deployment descriptors already declare Tango.

Tested against a real device, which mattered

The unit ACL suite fakes the tango module and passed 19/19. That number meant less than it looked: a fake written alongside the adapter encodes the same assumptions, so both agree precisely where both are wrong about the library. Driving the real adapter against a real in-process device server (tango.test_context.DeviceTestContext, no Tango database, no subprocess) found two defects the fake had certified as correct:

  • DevEnum reads emitted the ordinal, not the label. Real read_attribute returns a bare int, so the hasattr(value, "name") probe missed and a shutter read as '1', never 'OPEN'. Silent, because kind='Categorical' promises a label. Now resolved from the attribute config and cached per address. The .name branch stays: it is correct for DevState, and generalising that true fact one step too far is what caused the bug.
  • An ATTR_INVALID array crashed the conduct task. Tango discards the value but keeps data_format, so tuple(None) raised a bare TypeError that is not a port error family and escaped the Conductor's closed catch tuple. An unarmed detector was enough to trigger it.

The integration module is cheaper than the EPICS softIOC fixture it mirrors. The old rationale for faking ("CI has no TangoTest device", "PyTango is not installed") was false on both clauses: DeviceTestContext ships inside PyTango, and CI has always run uv sync --all-extras.

Reviews

A naming review (R1-R6) and a Stage-3 gate review both ran. Gate verdict: PASS_WITH_CHANGES, no P0.

Its P1 is fixed here: a malformed Tango address (a trailing-slash TRL typo in a recipe step) raised MalformedControlAddressError, which was not in the Conductor's closed _CONTROL_ERRORS tuple, so it escaped and stranded the Procedure in Running with a dangling in-flight marker. The rule that let it through was prose naming only one port module, so the new error family in the sibling module fell outside it by construction. That rule is now a fitness test rather than a docstring.

Known limits, deliberately

This is an honest Tango adapter, not a finished one. Documented in the integration module's "Out of scope" and left for follow-ups:

  • Every real device timeout maps to ControlNotConnectedError: real DevFailed stacks are 2-deep root-cause-first and every timeout leads with the generic API_CorbaException, which sits in _NOT_CONNECTED_REASONS, so _TIMEOUT_REASONS is dead code.
  • set_timeout_millis is never called, so PyTango's 3000 ms client default caps every call and a timeout_s above 3 s is inoperative.
  • API_EventTimeout is treated as terminal though Tango self-heals after ~10 s.
  • A subscribe timeout can leak the subscription under thread-pool saturation.
  • API_UnauthorizedAccess is a literal cppTango never emits; the real denial reason is API_ReadOnlyMode.
  • Read failures surface as ControlWriteRejectedError, because ControlPort has no read-failure family. That is an open port-taxonomy question, not an oversight.

None are reachable today: no deployment wires a Tango route yet.

Verification

Pre-commit end to end (ruff, gitleaks, pyright, tach, architecture fitness). Tiers: 12614 unit, 1080 integration, 29563 architecture, 3338 contract, 8 e2e.

The typed-address migration also turned out to be incomplete across seven integration modules. Nothing was broken at runtime, which is why tests and scoped typechecks all passed it; only a full-tree pyright surfaced it.

🤖 Generated with Claude Code

xmap and others added 10 commits July 16, 2026 17:29
The ControlPort docstring carried a watch item: at the second-substrate
trigger, promote `address: str` to a typed-sum `ControlAddress`. Tango
fired that trigger. This lands the address space itself, ahead of the
adapter that motivated it.

The promotion deliberately does NOT reach the caller-facing surface.
`ControlPort` stays `str`-addressed because the Conductor, acquisitions
and the beam-availability lookup receive raw address strings from recipe
steps and cannot know a substrate: whether `2bm:rot` is Channel Access,
`2bm:cam1:image` is pvAccess, or `id19/bsh/1/state` is a Tango attribute
is a property of the deployment ROUTE table, never of the string. So the
string surface is where callers belong, and substrate identity lives in
the one component that already knows it, the ControlPortRegistry.

`ControlAddress` is therefore the registry-to-adapter seam's vocabulary:
EpicsPvAddress | TangoAttributeAddress | InMemoryAddress, plus the single
`parse_control_address(substrate, raw)` dispatch the registry calls once
it has matched a route. The sum discriminates substrate FAMILY, not
transport within a family: both EPICS transports take EpicsPvAddress
because a PV name is the same shape on either, and CA-vs-PVA stays a
route decision. OPC UA stays deferred; no variant until its adapter
lands.

`SubstrateControlPort` is the typed sibling Protocol the adapters
implement, generic and contravariant in the address variant because an
adapter consumes addresses and legitimately accepts only its own. The
registry holds adapters as SubstrateControlPort[Any]: the substrate-to-
variant correlation it guarantees is a runtime invariant the type system
cannot track. It takes a port-suffix allowlist slot for the same reason
ControlPort holds one, with the qualifier naming which seam it faces.

Malformed addresses become MalformedControlAddressError at the routing
boundary, alongside NoAdapterForAddressError, rather than surfacing as a
split failure deep inside an adapter's wire call. Every variant
round-trips through `str(...)` so log breadcrumbs stay stable.

The Substrate literal gains `tango` here rather than with the adapter
because `parse_control_address` needs the value to dispatch on; the
factory arm that builds a TangoControlPort follows with the adapter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nto it

Puts the ControlAddress sum to work. The registry is the only component
that can turn a raw address into a typed one, because the route table it
owns is where substrate identity is declared, so it becomes the seam
between the two surfaces: it implements the caller-facing `str`
ControlPort, and per call it matches a route by longest prefix, parses
the address per that route's declared substrate, and dispatches the
typed variant to a SubstrateControlPort.

The consequence for adapters is that they stop re-parsing. Each EPICS
adapter now takes an EpicsPvAddress instead of splitting or validating a
string on every read, write and subscribe. Parsing happens once, at the
boundary, and a malformed address fails there as
MalformedControlAddressError rather than surfacing mid-call.

`register` therefore grows the route's substrate as a parameter: it is
what selects the parse arm, and it cannot be inferred from the adapter
instance. `register_str_port` is the second door, for adapters that are
legitimately `str`-surfaced: InMemoryControlPort keys an opaque dict and
has no substrate syntax to parse, so the registry wraps it rather than
forcing a typed variant on a test double. build_control_port routes
in_memory through that door and the real substrates through the typed
one.

The `tango` factory arm raises for now; it arrives with the adapter in
the next commit, which is also where the config test's tango case lands.
The Substrate literal already carries the value from the previous commit
because parse_control_address dispatches on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The second substrate, and the one that fired the typed-address trigger
the previous two commits answered. Tango is the control floor at ESRF
(BLISS) and MAX IV (Tango/Sardana), and the p10 and p21 deployment
descriptors already declare it, so the ControlPort's EPICS-only adapter
set was a real gap rather than a speculative one.

TangoControlPort consumes TangoAttributeAddress, so it reads
`address.device` and `address.attribute` instead of splitting a TRL on
every call; the registry did that once at route-match time. The ACL work
is the same shape as the EPICS adapters': attribute data format maps to
MeasurementKind (SCALAR/SPECTRUM/IMAGE, plus DevEnum to Categorical),
Tango's attribute quality collapses onto the port's three-value Quality
(VALID to Good, WARNING and CHANGING to Uncertain, ALARM and INVALID to
Bad), and DevFailed reasons map onto the port's exception families.

PyTango stays out of the base install. It binds the C++ Tango core plus
omniORB, a heavy native stack that only Tango-floor deployments need, so
it ships as the optional `tango` extra and the adapter imports lazily.
Construction probes importability via `_optional_tango.require_tango`,
which raises ValueError rather than ImportError so a missing extra
surfaces where the port is materialised, at wiring time and mapped to
422, instead of as an uncaught ImportError deep in the conduct loop
after a procedure has already started. This mirrors the `bo` group's
`_optional_torch.py` posture. Pinned <11 to flag a future major shift.

The tests inject a fake `tango` module rather than driving a device: CI
has no TangoTest the way it runs a live EPICS softIOC, so every ACL
branch is covered against a double. That leaves this adapter without the
live-substrate integration test each EPICS adapter has, which is a known
and deliberate gap, not an oversight; closing it needs a TangoTest
device in CI and is the next thing to settle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unit ACL suite fakes the `tango` module, and it passes 19 out of 19.
That number meant less than it looked. A fake written alongside the
adapter encodes the same assumptions as the adapter, so the two agree
precisely where both are wrong about the library, and the suite confirms
its author rather than PyTango. Driving the real adapter against a real
device found two defects the fake had certified as correct:

  - A DevEnum read returns a bare int from `read_attribute`, so the
    adapter's `hasattr(value, "name")` probe misses and it stringifies
    the ordinal: a shutter reads as '1', never 'OPEN'. It fails silently,
    because `kind='Categorical'` promises a label, so a comparison to
    'OPEN' just never matches. The `.name` branch is not careless, it is
    correct for DevState; the mistake was generalising a true DevState
    fact one step too far, and the fake agreed.
  - Tango discards the value on ATTR_INVALID but keeps `data_format`, so
    an INVALID spectrum or image reaches `tuple(None)` and raises a bare
    TypeError. It is not a port error family, so the Conductor's
    deliberately closed `_CONTROL_ERRORS` tuple does not catch it and it
    crashes the conduct task. The trigger is an ordinary unarmed detector.
    The fake paired a live value with ATTR_INVALID, a combination the
    Tango core cannot produce, so that cell was never crossed.

Both are pinned here as strict xfail asserting the behaviour we want, so
their fix flips them green and `strict` refuses to let a stale marker
survive. The rest of the module is the coverage the fake could not vouch
for: every MeasurementKind branch, quality collapse, the write round-trip
and its coercion failure, access-denied, subscribe fan-out, aclose.

The old rationale for faking was false on both clauses, so it is gone
rather than reworded. `DeviceTestContext` ships inside PyTango and runs a
real device server in-process with no Tango database and no subprocess,
which makes this cheaper than the EPICS softIOC fixture it mirrors, and
CI has had PyTango all along because setup runs `uv sync --all-extras`.
The fake keeps its tier: it is still the right tool for injecting a
failure by reason, which is awkward against a live device.

Two Tango constraints the EPICS pattern does not carry are documented in
the module: PyTango binds one process-global executor to the first loop
it sees, so the loop scope is pinned to session locally rather than
changing the global default, and omniORB allows one DeviceTestContext per
process, which the module-scoped fixture satisfies.

Timeout, event-error, read-failure-class and authorisation paths are left
out of scope with reasons, each landing with the fix it belongs to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On ATTR_INVALID the Tango core discards the value but keeps data_format,
so an invalid spectrum still classified as Array while attr.value was
None, and _unpack_value reached tuple(None). The resulting TypeError is
not a ControlPort error family, so the Conductor's deliberately closed
_CONTROL_ERRORS tuple did not catch it and it escaped the step loop and
crashed the conduct task. The trigger is nothing exotic: an unarmed
detector, an offline camera, an attribute on a device in FAULT.

_unpack_value now returns None whenever the attribute carries no value,
before any kind branch. That also fixes a quieter case in the same
branch: a Categorical invalid read used to stringify None into the
literal label 'None', a truthy string a consumer cannot tell from a real
one.

This does NOT raise ControlValueCoercionError, which was the obvious
candidate. Three reasons. An invalid scalar already reads back as
None/Bad today, so raising only for arrays would make one physical
condition, an invalid attribute, produce either a Measurement or an
exception depending on nothing but data format, which a caller cannot
reason about. Raising also discards the forensic pairing of quality=Bad
with quality_detail=attr_quality=ATTR_INVALID that the Conductor records
against the failed step, which is the evidence an operator needs. And
the error itself is the wrong shape: it takes (address, raw_type,
target_kind) and means a value whose TYPE will not coerce, which is not
what an absent value is. Whether an absent value is usable is the
consumer's judgment, and the consumers already make it correctly:
_require_finite_number(None) raises ControlValueCoercionError, naming
the kind IT needs, and a check criterion treats None as a clean
mismatch. Both degrade to a structured step failure.

_to_reading stays outside read()'s try. Making _unpack_value total is
what closes this; catching translation failures around it would launder
a genuine CORA bug into a step failure, and the Conductor's no-catch-all
posture deliberately lets those surface.

The fake could not have caught this and now can. _DeviceAttribute took
value and quality as independent kwargs and paired a live value with
ATTR_INVALID, which the Tango core cannot produce, so the invalid-array
cell was unreachable and the suite stayed green over the crash. It now
couples them the way Tango does. Removing the guard makes the repaired
fake fail 3 of the 4 new cases (Array, Image, Categorical; Scalar never
crashed), so the unit tier now falsifies this on its own.

The integration test asserts the reading rather than the raise, and its
strict xfail is gone: the real-device case passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A DevEnum read returns a bare int. The adapter probed the value for a
`.name` and, missing it, stringified the ordinal: a shutter read back as
'1', never 'OPEN'. It failed silently, because kind='Categorical'
promises a label, so a decider comparing a reading to 'OPEN' never
matched and never raised. Labels are not on the value at all; they live
in the attribute config, which the adapter never asked for.

The `.name` branch stays and is checked first. It is not the bug: a
DevState value really is an IntEnum carrying its own label, and DevState
reports NO enum labels, so it cannot be served by the config path. The
original mistake was generalising a true DevState fact to DevEnum, and
since the fake supplied a `.name`-carrying object for both, nothing
could contradict it.

_resolve_enum_labels mirrors EpicsCaControlPort's: cached per address,
cleared on aclose so a reopened port cannot trust labels from before a
device restart. It caches the NEGATIVE too, which the EPICS twin does not
need to: there, Categorical always means an enum, whereas DevState is a
common Tango Categorical with no labels, so without caching the empty
result every DevState read would re-fetch config forever. A config fetch
is 0.07 ms on loopback but a real CORBA round trip on a beamline network,
which is why it stays off the conduct loop's per-read path.

A failed config fetch falls back to the ordinal rather than raising: the
read itself succeeded, and turning a readable attribute into a failed
step because its config was momentarily unreachable would be a
regression, not a fix. An ordinal outside the label range falls back the
same way.

A DevEnum SPECTRUM stays an Array of ordinals, documented rather than
fixed. `_kind_for` sorts on data format before type, and a per-element
label array has no consumer today; inventing one here would be scope, not
correctness.

The fake now models what PyTango returns: a bare ordinal on the value, a
configurable enum_labels on get_attribute_config, and empty labels by
default because that is what every non-enum attribute reports. Reverting
the adapter to the old `.name`-only branch now fails the unit tier on its
own, so the fake finally falsifies this instead of certifying it.

Two integration tests cover what the fake structurally cannot: the label
against a real device (its strict xfail is gone, and strict is what
caught the XPASS), and a write-then-read pair proving the per-address
cache stores labels rather than a resolved label. Both write before
reading, per the module's own rule, so neither depends on running first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The docstring claimed "the kernel never reissues an in-use port to a
concurrent bind, so each xdist worker gets a distinct port". That is true
and it is what xdist needs, but it was carrying more weight than it can
bear: the function closes the socket before returning, so the port is not
reserved, and it comes from the same ephemeral range the kernel draws
bind(0) from. _pin_epics_env calls it at session start while the softIOC
does not bind until module setup, so the gap can be minutes wide.

Investigated because a single run of the four control-port integration
modules gave 43 passed and 14 ERROR-at-setup in the EPICS CA module, and
this race was the suspect: a Tango integration module had just landed, and
DeviceTestContext binds a dynamic loopback port in the same process.

The suspicion was wrong, and the measurement says so. Forcing the
collision rather than waiting for it: EPICS uses this one number for both
its TCP server and its UDP name-search channel, which are separate port
namespaces. A TCP thief is SURVIVABLE, 6 of 6 trials, because CAS falls
back to another TCP port and UDP search still resolves the PV and
redirects the client. Only a UDP thief is fatal, 2 of 2 trials, and it
fails exactly as the observed run did, with the readiness poll timing out
and every test in the module erroring at setup. DeviceTestContext binds
TCP through omniORB, so it is the survivable case and cannot have caused
those 14 errors.

So no code change. The window is real but currently unreachable: nothing
in the test tier binds an ephemeral UDP port. And the obvious fix would
have missed anyway, which is the detail worth recording: socket.socket()
is SOCK_STREAM, so holding this socket would reserve the TCP number, the
harmless one, and leave the UDP number unreserved. Restructuring shared
EPICS fixtures to close a hole that is both unreachable and on the other
protocol is not a trade worth making, so the docstring now carries the
measurements and the trigger for when it does become worth it.

The 14 errors remain unexplained. The better-supported hypothesis is the
5.0s readiness deadline missed under transient load: that run took 57.3s
against a 46.6-47.1s baseline over 12 later runs, all green. Left alone
rather than tuned on one data point.

Verified unchanged: 60 passed serially and under -n 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The registry-to-adapter seam moved the substrate adapters onto typed
addresses, but seven integration modules kept calling the old surface.
They all pass, because none of this is wrong at runtime: a test holding a
bare EpicsCaControlPort and handing it a PV string still reads the right
PV. It is wrong to the type checker, which is why the migration missed
them and why only a full-tree pyright surfaced them.

Two shapes. Tests that built a bare adapter and drove it with a str now
route through a ControlPortRegistry, which is the component that owns the
str surface and is what production wires via build_control_port; the
adapter itself speaks EpicsPvAddress. Tests already using the registry
just gained the substrate argument that selects the parse arm.

`control_port_reuse` yields the registry now rather than the adapter,
which is why its prefix parameter stops being ignored. The registry closes
every route it holds, so the adapter still shuts down with the block.

One case is not epics_ca: the 8-ID XPCS scenario registers an
InMemoryControlPort, which is str-surfaced and has no address syntax to
parse, so it takes register_str_port. Registering it as a substrate route
made the registry parse an EpicsPvAddress and hand the typed variant to an
adapter expecting a string, and that test was the one that caught it.

The adapter's own error is a PyTango typing limitation, not ours:
tango.asyncio.DeviceProxy is a partial over get_device_proxy whose
declared return does not vary with green mode, so it types as a bare
DeviceProxy while under Asyncio it returns an awaitable. The integration
module proves the runtime shape, since wait_for on a non-awaitable would
raise rather than pass, so this is a narrow ignore with the reason
recorded.

Full tree now green where it had never been run: pre-commit passes end to
end, including pyright, tach and the architecture fitness functions. Tiers
verified: 1080 integration, 12614 unit, 29560 architecture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Naming review of the series (R1-R6) found three real breaks, all in names
this branch coined, so they are fixed here while they are still cheap.

`register_str_port` reached for a Python type token where every other name
in the series uses domain words, and it stalls read aloud. It is also a
name that can go stale: if `ControlPort` ever stopped being `str`-surfaced,
`register_str_port` becomes a lie while `register_control_port` stays true.
The two doors were skeleton-mismatched too, a bare verb against
`<verb>_<type>_<noun>`, when each door has an obvious name available: the
Protocol it accepts. So `register` becomes `register_substrate_port` (takes
a `SubstrateControlPort`) and `register_str_port` becomes
`register_control_port` (takes a `ControlPort`). The mild
`ControlPortRegistry.register_control_port` stutter is the cheaper cost.

`_StrAddressSubstrate` broke R3 outright: its head noun said Substrate but
the class is a `SubstrateControlPort` implementation, so the family noun
was missing from last position and the name named the wrong family. Worse,
`Substrate` is a live imported type ten lines above it, so a reader meets
the word twice with two meanings. Now `_StrAddressSubstratePort`, and the
test double follows as `_FakeSubstratePort`.

`parse_control_address` took (substrate, raw) while the three variant
`parse` staticmethods it dispatches to take (raw, substrate). One family,
two argument orders. Aligned on subject-first, matching `strptime`.

Renaming a method called `register` is a trap, because the projection
registry, the tool registry, the signing registry and several lookups all
have one and none of them should move. So the callers came from pyright
rather than from a pattern match: rename the definition, let the type
checker enumerate what broke. That found 51 sites across 12 files and no
false ones.

It has one blind spot worth recording. Pyright treats `# type: ignore` as a
blanket line suppression rather than a mypy-style code-specific one, so a
`registry.register(...)  # type: ignore[arg-type]` in the registry's own
aclose test stayed invisible to it and failed at runtime instead. The unit
tier caught it. Static enumeration is the right instrument here, but it
cannot see through an existing ignore.

Green: pre-commit end to end, 12614 unit, 1080 integration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate review P1, reproduced by execution. A recipe step carrying a Tango
address with no attribute segment ("id19/bsh/1/", a trailing-slash typo)
prefix-matches its route, so the registry accepts it and then raises
MalformedControlAddressError while parsing. That class was not in the
Conductor's _CONTROL_ERRORS tuple, which is closed by design with no
Exception catch-all, so it escaped the step loop and out through the route
handler: start called once, complete never, abort never, the step record
left at in_flight. The Procedure sits in Running with a dangling marker and
no outcome, which is the exact failure the tuple exists to prevent.

Its sibling NoAdapterForAddressError, raised at the same routing boundary
for an address no prefix matches, IS a member. So the error's own docstring
claiming it propagates "the same way" was describing an intent the code
never carried out.

The interesting part is why it slipped. The tuple's rule was prose, and it
named exactly one module: "new exception classes in
cora.operation.ports.control_port must be added here explicitly". Promoting
the address to a typed sum put the new error family in the SIBLING module
control_address.py, so it fell outside the stated rule by construction
rather than by oversight. Re-homing it next to the other six is not
available, since control_port already imports control_address and that
would close an import cycle.

So the rule stops being prose. test_control_errors_closed_over_port_exceptions
enumerates every Exception subclass defined in either port module and
asserts each is caught or explicitly allowlisted with a reason, plus the
reverse direction so a retired class cannot leave a dead arm. Removing the
fix fails it with the offending name. Prose asking to be remembered is what
failed here; a test does not need remembering.

Also corrects two comments of the same species, promising a 422 that cannot
happen. build_control_port has two call sites, wire.py and the FastAPI
lifespan in main.py, neither of them a request handler, so a missing tango
extra fails startup rather than a request. The claim was copied forward from
_optional_torch.py, where it is true. The bo group's own wording is left
alone.

Green: pre-commit end to end, 12614 unit, 1080 integration, 29563 architecture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/infrastructure
  control_port_route.py
  apps/api/src/cora/operation
  conductor.py
  apps/api/src/cora/operation/adapters
  _optional_tango.py
  caproto_control_port.py 249-250
  control_port_config.py
  control_port_registry.py 240
  epics_ca_control_port.py 294
  epics_pva_control_port.py 287-288
  tango_control_port.py 257, 264, 271, 458-461, 470, 473
  apps/api/src/cora/operation/ports
  control_address.py
  control_port.py
Project Total  

This report was generated by python-coverage-comment-action

@xmap
xmap merged commit 9e0b838 into main Jul 16, 2026
16 checks passed
@xmap
xmap deleted the worktree-tango-control-port branch July 16, 2026 22:01
xmap added a commit that referenced this pull request Jul 18, 2026
…, compute allowlist (#571)

Four slices that move CORA from bench-quality to deployable at APS 2-BM, all default-safe.

- Read-only ControlPort gate: CONTROL_WRITES_ENABLED (default False) refuses writes before any substrate is contacted; a per-route read_only carves holes in a writable deployment. Re-integrated onto #568's two-surface registry as ReadOnlyControlPort (str) + ReadOnlySubstratePort (typed).
- Readiness probe /readyz: reports Postgres, the one dependency that can change after a fail-fast boot; bounds the pool acquire, not just the query. /health stays check-free liveness.
- Deployable image: first Dockerfile, linux/amd64-pinned (EPICS wheels are x86_64-only; aioca builds from sdist), non-root, one process per container.
- Compute executable allowlist: COMPUTE_PERMITTED_EXECUTABLES (default empty = deny all) refuses any command[0] the deployment did not declare, closing a probed hole where a conduct request ran an arbitrary binary as the API service account.

Scope stated honestly: the control gate closes the modeled actuation path and the allowlist bounds the compute exec path; neither is a Trust-authorized gate. Follow-ups: a derived actuation posture reported for verification, launch-spec lockdown, and an LLM egress off-switch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant