The Tango arm: a second control substrate, and a typed address at the registry seam#568
Merged
Conversation
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>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ControlPortstaysstr-addressed for callers. The Conductor receives raw address strings from recipe steps and cannot know a substrate: whether2bm:rotis Channel Access,2bm:cam1:imageis pvAccess, orid19/bsh/1/stateis 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.
ControlPortRegistrymatches a route by prefix, parses the address per that route's declared substrate, and hands a typedControlAddress(EpicsPvAddress | TangoAttributeAddress | InMemoryAddress) to aSubstrateControlPort. Adapters stop re-parsing strings on every call. The sum discriminates substrate FAMILY, not transport: both EPICS transports shareEpicsPvAddress, 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.TangoControlPortships behind an optionaltangoextra. 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 aValueErrorat wiring rather than anImportErrormid-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
tangomodule 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:read_attributereturns a bareint, so thehasattr(value, "name")probe missed and a shutter read as'1', never'OPEN'. Silent, becausekind='Categorical'promises a label. Now resolved from the attribute config and cached per address. The.namebranch stays: it is correct for DevState, and generalising that true fact one step too far is what caused the bug.data_format, sotuple(None)raised a bareTypeErrorthat 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:
DeviceTestContextships inside PyTango, and CI has always runuv 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_ERRORStuple, so it escaped and stranded the Procedure inRunningwith 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:
ControlNotConnectedError: real DevFailed stacks are 2-deep root-cause-first and every timeout leads with the genericAPI_CorbaException, which sits in_NOT_CONNECTED_REASONS, so_TIMEOUT_REASONSis dead code.set_timeout_millisis never called, so PyTango's 3000 ms client default caps every call and atimeout_sabove 3 s is inoperative.API_EventTimeoutis treated as terminal though Tango self-heals after ~10 s.API_UnauthorizedAccessis a literal cppTango never emits; the real denial reason isAPI_ReadOnlyMode.ControlWriteRejectedError, becauseControlPorthas 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