Cross-platform internals

This contributor reference covers backend architecture, delivery mechanisms, and verification lanes. For user-facing capability claims, see Cross-platform support.

Evidence, limitations, and claim maintenance

Canonical evidence

These are the only sources that promote or sustain a Supported cell.

evidence where what it proves cadence
macOS live loop .github/workflows/live.yml macos job capture → resolve → invoke AXPress → verified Calculator display; complete health-v1 document; Accessibility grant — each asserted against the app bundle the job launched, by process id every push to main
macOS grant-carry upgrade verification bglab-mac, measured during the axn/119 v0.3.0 rollout daemon install from ~/.local/lib/axon/0.3.0 repointed the LaunchAgent from 0.2.3 while the unchanged com.bleugreen.axon bundle identity carried Accessibility and Screen Recording grants across the upgrade: the daemon returned ready in about one second with all fifteen capabilities usable and no System Settings prompts manual (2026-08-11); re-run on changes to bundle identity or installation registration
Linux live loop .github/workflows/live.yml linux job AT-SPI capture/resolve/invoke with verified readback on GNOME Calculator under GNOME/Mutter Wayland; honest refusal of global input at both policies; systemd-user lifecycle and health-v1, including the session accessibility switch this runner has on every push to main
Windows live loop .github/workflows/live.yml windows job the interactive-session daemon serves look through the DACL-restricted pipe; against a probe-owned isolated Edge window targeted by its window-owning process id, Ctrl+L, text, Return, and a page-content click dispatch through the foreground rung and are verified from page state; the complete health-v1 document and the desktop's unchanged registration are also asserted every push to main
Hermetic X11 foreground test .github/workflows/test.yml Linux job; rust/axon-linux/tests/x11_foreground.rs the X11 activate/prove/dispatch/restore conversation against a real X server with a miniature EWMH window manager every pull request
Hermetic AT-SPI activation test .github/workflows/test.yml Linux job; rust/axon-linux/tests/atspi_activation.rs that the attributes call is issued against the application root and only once per application, that the bounded wait ends when a withholding provider publishes rather than when the bound expires, and that a provider which never publishes is reported as withheld rather than as empty — against a private session bus and a provider built to withhold the way Chromium does; and, on a second private bus, that the session's accessibility switch is read live rather than remembered and reaches health-v1 as a degraded session every pull request
Windows session-1 probes axon-win probe value, events, timeout, pixel-click, foreground; findings recorded in this document value set and readback, event delivery, provider timeouts, the pixel-click allowlist entry, the foreground hand-back finding manual; re-run and re-date when the area changes
Hermetic X11 pixel test .github/workflows/test.yml Linux job; rust/axon-linux/tests/x11_pixel.rs against a real X server: that the two delivery variants route as the acceptance table assumes — a targeted event reaching only a client that selected it, an owner event reaching the creating client regardless — that a window is bound to the process that owns it and refused when covered at the point, that screen coordinates convert through the window's own geometry, that a chord's modifier state survives the wire, and that none of it moves the real pointer or the X input focus every pull request
Linux toolkit acceptance harness scripts/linux-toolkit-acceptance/; results in RESULTS.md and RESULTS-live-x11.md which toolkits act on background XSendEvent delivery with the session focus and real pointer unchanged, and whether AT-SPI extents match the toolkit's own rectangles; each row backed by a real-pointer and a focused-keystroke control, each phase re-proving the background before it sends. Its keyboard phase runs after its click phase on the same window, so a keyboard row for a toolkit whose click is accepted is not independent — see the Chromium gap above manual (2026-08-10, hermetic Xvfb and a live X11 session, GTK 3/4, Qt 6, WebKitGTK, Firefox, Chromium 108/124/150); re-run and re-date on toolkit releases
Linux pixel rung bench verification fedora bench, live Xfce X11 session, recorded in this document that the implemented rung delivers end to end through the daemon: an Electron 43 (Chromium 150) page reported a click as isTrusted, and GTK 3 and Qt 6 typed delivered text into an unfocused window, each with the frontmost window, the X input focus and the real pointer unchanged either side; and that Chromium keystrokes with no prior click are silently dropped manual (2026-08-10); re-run and re-date when the area changes
Linux Chromium activation probe recorded in Linux backend that Chromium-family trees are gated by org.a11y.Status.IsEnabled and by an attributes or relations call, and not by AT-SPI listener registration; the daemon's before-and-after capture of Chrome. What only real browsers can show — the daemon's own half of the mechanism is gated per pull request by the hermetic AT-SPI test above manual (2026-08-08); re-run and re-date when the area changes
Platform spikes rust/SPIKE-FINDINGS.md session topology, WebView2 and WebKitGTK activation and traversal, the Mutter geometry caveat, verified invoke dispatch dated snapshots (2026-08-02 through 2026-08-04)

Environment notes

macOS. The live loop covers the semantic rung end to end. The pointer, keyboard, and screenshot cells are this audit's most surprising finding: macOS is the most-used platform, and its pixel and foreground paths are covered by an extensive deterministic suite (DeliveryRoutingTests, ForegroundEscalationTests, PointerTargetValidationTests, DragEventPathTests) but by no live probe. The promotion path is to extend the macOS live loop with a click that falls to the pixel rung and a screenshot capture.

Windows. Mechanism and probe findings are in Windows backend. Two restrictions are deliberately not rows in the matrix because they are per-session and per-target facts rather than environments: a daemon in a service session reports global input unusable in health-v1, and an elevated target refuses by name at dispatch time.

Linux X11. The hermetic test proves the protocol conversation against a real X server; what it cannot prove is a full desktop's window manager, and the fleet has no X11 desktop. Enrolling one, or recording a dated probe against one, is the promotion path for the limits above. On every Linux environment scroll is refused — AT-SPI has no portable delta-scroll operation, and it is never silently replaced with global wheel input.

GNOME/Mutter (Wayland). The project's one real Linux desktop, so it carries the Linux live loop alone. Its two compositor-specific findings are separate claims with separate evidence: synthetic input is refused because the compositor forbids it (live-verified), and AT-SPI Component geometry is untrustworthy for GTK4 descendants (spike-recorded, dated). Mutter advertises the RemoteDesktop and ScreenCast portals. AXN-172 measured WINDOW authorization and restore-token behavior, but the stream metadata has no application identity. Axon therefore keeps app-scoped look screenshots refused while the distinct capture_screen operation returns only an honestly labeled user-authorized source. Tokens are optimistic restoration hints, never global health proof.

One thing about that runner is not a property of GNOME and must not be read as one: a screen reader runs on it, so its toolkit-accessibility is already true and org.a11y.Status.IsEnabled answers true. A stock GNOME session answers false, and on that session every Chromium-family application is absent from the AT-SPI bus. Nothing in the live loop depends on the difference, because the loop exercises GNOME Calculator and GTK providers publish either way — but no cell above is evidence about a session with accessibility switched off. The loop asserts the switch in the health document precisely so this paragraph stays falsifiable: if the runner ever loses its screen reader, the lane goes red here rather than quietly broadening what green means.

KWin and Sway/wlroots. Neither has ever run Axon. The expectations in their rows follow from the Wayland session classification and toolkit AT-SPI support, but expectation is not evidence: both rows stay Experimental until a runner or a dated probe says otherwise. A Sway session may run no X server at all, in which case nothing in the X11 row applies to it.

XWayland. The trap worth naming, and the reason Wayland is classified before any X connection is attempted: Mutter publishes EWMH properties for X11 clients and injects XTest globally, so a backend could activate an X11 window, prove it came forward, and dispatch — while a Wayland-native application held a focus X11 can neither see nor give back. Semantic capture of X11-client applications works because they register on the AT-SPI bus like any other application.

Known gaps

Gaps are listed rather than omitted, because a caller can discover each one at runtime through a typed refusal or a health-v1 capability entry; each should be discoverable here first.

Maintaining these claims

  1. A cell keeps Supported only while its named evidence is current: the lane green on main, or the probe's recorded date recent for the area it covers.
  2. A live-lane failure demotes the cell in the same change that fixes the backend or the lane. A red lane under a green matrix is a documentation bug.
  3. Probe-backed cells carry their date; re-run the probe and re-date the cell when its backend area changes.
  4. New environments enter as Experimental. Promotion requires a named live check (preferred) or a dated probe run recorded in the environment's note.
  5. Refusals and unimplemented capabilities are documented with the same vocabulary the runtime uses — the refusal reasons in platform-spec.md and the health-v1 reason codes in embedding.md.
  6. The machine-readable analogue of this matrix is status --json: the same per-session verdicts, as data. A claim here that the health document contradicts is wrong in one of the two places; fix the one that is lying.

Native concept map

This table is an architectural map, not a claim that the APIs are behaviorally identical. Locator values remain native vocabulary.

Concept macOS Accessibility (AX) Windows UI Automation (UIA) Linux AT-SPI2
Element kind AX role and subrole, such as AXButton control type, such as Button accessible role
Default semantic activation AXPress InvokePattern.Invoke exposed AT-SPI action, commonly click/press
Stable developer identity AXIdentifier AutomationId accessible-id
Read/set editable value settable AXValue ValuePattern value/text/editable-text interfaces
Change observation AXObserver notifications UIA event handlers AT-SPI event listeners over D-Bus
Synthetic input fallback Core Graphics events (CGEvent) SendInput X11 XTest; Wayland libei/portals when available

The backend translates native objects into the shared snapshot model and native results into the shared honest-result model. It does not translate native role or action names into a universal taxonomy.

Rust architecture

The Rust sibling has one platform-neutral core and narrow native backends:

MCP stdio facade
  -> JSON-RPC router
    -> shared core
       snapshot model, semantic-name contract, and private retained-handle lifecycle
       locator filtering, scoring, confidence, and explanations
       .axn parser, parameter binding, runner, and traces
       capability model and honest result envelopes
    -> PlatformBackend
       capture and application/window enumeration
       semantic action dispatch and value access
       observation and waiting
       pointer/keyboard fallback and screenshots
       recording and permission/capability discovery
  -> user-session socket daemon

The backend trait should expose capabilities alongside capture, dispatch, observation, and recording operations. Platform-specific permission failures and API restrictions cross the boundary as typed capability or operation errors; they do not leak arbitrary native status codes into the public protocol. Native diagnostic details may be retained as supplemental information.

At the public Rust boundary, an element target is exactly {app, name}. Raw snapshot handles and standalone locator objects are rejected before backend work. The provider uses axon-core's app-scoped semantic-name registry and native locator evidence to resolve that name against a fresh capture. Retained handles are private cache hints only; locator scoring is the durable fallback after the interface changes. Missing and ambiguous names fail closed, and accepting the old target vocabulary as a fallback would create a second, stale public contract.

The socket-daemon shape stays aligned with macOS: a long-lived process owns native subscriptions, retained element handles, and caches, while short-lived MCP stdio facades connect locally. This is architectural parity, not a demand that installation and lifecycle management be identical across operating systems.

The observer sensitivity contract

A keystroke burst captured while a sensitive element held focus is never serialized. Shared core keeps the step, because the flow needs it, but authors it as a declared sourceless secret argument reference: the characters stay in the daemon process, and replay asks whoever runs the recording for the value. That refusal lives in axon-core rather than in any backend, because no platform hook can be trusted to do it — neither Windows low-level keyboard hooks nor X11 XRecord have an OS gate of any kind, and macOS secure event input is a system-wide mode that a web password field may never engage.

The refusal is only ever as good as the sensitive flag the observer sets on RecordedElementEvidence, so each platform owes a predicate at least this broad, and owes a unit test that a password-role element is classified sensitive so the floor cannot silently narrow again:

platform at minimum
macOS role or subrole naming "secure", or a description naming a password. The subrole is the one that matters: NSSecureTextField reports role AXTextField with subrole AXSecureTextField, so a role-only test misses the ordinary AppKit password field entirely.
Windows UIA_IsPasswordPropertyId on the element.
Linux AT-SPI STATE_PROTECTED, or role ROLE_PASSWORD_TEXT.

The same flag also withholds the element's value from the evidence, so a provider must never read AXValue, the UIA value pattern, or the AT-SPI text interface of an element it has just classified sensitive.

An observer that additionally has an OS-level signal reports it as SecureInputChanged, which makes shared core drop pending state and discard events entirely while it is active. macOS has one: IsSecureEventInputEnabled is a real system mode, and while it is on the CGEvent tap genuinely stops being handed keystrokes, so saying so is describing what happened.

Windows deliberately reports nothing here, and the reason generalizes. Raising SecureInputChanged is not a stronger version of the per-element predicate; it is a different outcome. UserActionRecorder::consume returns early for every event while it is active, so a burst raised this way never reaches flush_text, which is the one place that authors the declared secret argument. Mapping UIA_IsPasswordPropertyId onto it would therefore replace the honest shape with silent dropping — the outcome the recorder's own charter rejects — rather than add to it. Windows has no system-wide secure-input mode to report either way: a low-level keyboard hook keeps delivering password-field keystrokes unconditionally, and the per-element predicate above is the whole defence.

The UAC secure desktop is not an exception to that, because it is not a mode at all. It is a separate desktop, and a hook installed on the interactive desktop simply never sees any of its input. There is nothing to detect and nothing to report; the events do not arrive.

One gap the per-element predicate does not close on any platform: sensitivity is read when the burst is flushed, and a burst is flushed by the next event. Type a password and then click elsewhere, and the read that decides finds the newly focused element. Closing it means carrying sensitivity on the keystroke itself, which is a change to the shared event vocabulary rather than to any observer.

One lifecycle vocabulary, three native mechanisms

Every platform exposes the same four verbs — daemon install, daemon uninstall, daemon restart, and shutdown — plus status --json and version, with the same exit-code contract. What differs is the mechanism each verb uses, because start-at-login is a platform-native concern: a LaunchAgent limited to the Aqua session on macOS, an interactive ONLOGON scheduled task on Windows, and a systemd user unit bound to graphical-session.target on Linux.

None of the three copies a binary anywhere, so there is exactly one registration truth and callers must invoke from a permanent path. Windows derives and registers the windowless daemon beside the invoking CLI, Linux registers the invoking executable, and macOS registers the enclosing Axon.app when there is one, because that is the only way a privacy grant binds to the bundle identity instead of to a path that changes every release. All three treat a successful authenticated health round trip as the readiness contract; a bound socket or an existing pipe is never taken as evidence that a daemon is serving.

Status is one versioned document, health-v1, described by schema/health-v1.schema.json and modelled in both Swift (AxonCore) and Rust (axon-core::health) against the same shared fixtures. Degradation is data: a daemon that is not running, a Windows service session, a Linux greeter with no AT-SPI bus, and a denied macOS grant are all schema-valid documents that exit 0. The consumer contract is Embedding Axon.

Linux lifecycle

axon-linux daemon install writes ~/.config/systemd/user/axon.service with ExecStart=<invoking executable> serve, reloads the user manager, and enables the unit. The unit is PartOf/WantedBy graphical-session.target rather than login, because AT-SPI only exists once a desktop is up; a daemon started at the greeter would have nothing to talk to. It uses Type=simple because Axon does not implement sd_notify readiness, so systemd's notion of "started" is earlier than Axon can serve and the CLI's health round trip is the real readiness signal.

On a host with no graphical session the unit is enabled but deliberately not started, and install says so and exits 0. Blocking for a readiness that cannot arrive until someone logs in would report a timeout for work that was done.

The daemon serves health and shutdown as ordinary JSON-RPC methods on the mode-0600 $XDG_RUNTIME_DIR/axon-v1.sock, so a lifecycle command learns which process it stopped from the reply. Session facts are detected independently — user manager, display, session bus — so a host can report an honest partial state instead of one collapsed guess. Synthetic pointer and keyboard input are reported per session rather than per build, because the same binary can deliver them on an X11 session with a window manager and cannot on a Wayland one, and the same answer feeds both status and the dispatch ladder.

Nothing a client does ends the daemon. A hang-up before the request, partway through it, or between the request and its answer ends that connection alone, and a request deadline bounds a client that connects and then says nothing, since the daemon answers one connection at a time. That loop lives in the axon-linux library rather than in the binary precisely so a test can drive hostile clients against it without a desktop or an accessibility bus.

Windows backend

Windows is first because UIA provides strong semantic capture, patterns, and events with a close conceptual fit to AX.

Linux backend

Linux capture and semantic actions use AT-SPI2 over the accessibility D-Bus. The backend must expect differences among desktop environments, widget toolkits, compositors, and application accessibility implementations rather than equating “Linux” with one uniform tree.

The Chromium-family claims above were probed on bglab-ub on 2026-08-08, against Electron 33.2.1 (Chromium 130) and Chrome for Testing 151.0.7922.77. Each application was given a private session bus, accessibility bus, and registry, so that the desktop's own running screen reader could not be mistaken for the mechanism under test. With a listener registered and nothing else, both applications stayed absent from the bus, whether they were already running or launched afterwards. With IsEnabled true at startup both appeared as exactly three nodes — application, window, null reference — and stayed that way whether or not a listener was registered. One GetAttributes on the application root produced the full tree: 284 nodes for Chrome and 26 for Electron, arriving after 1.12s and 0.09s. Through the daemon, the same Chrome capture answered operation accessible proxy failed: atspi: null reference before this was implemented, and the complete tree including the document web afterwards, at the same cost on the first look as on the third. GNOME Calculator captured 107 nodes at unchanged latency, because a provider that publishes its tree pays only the ask.

Two details are worth carrying to whoever re-runs that probe. libatspi reads the X root window's AT_SPI_BUS property before it consults the session bus, so an application on the desktop's display silently joins the desktop's accessibility bus unless AT_SPI_BUS_ADDRESS is exported — an isolation that looks airtight and is not. And the folklore about listener registration is not baseless, just wrong here: upstream at-spi-bus-launcher has grown a handler that flips IsEnabled when a client registers an event listener, which would make registration reach the first gate indirectly. The 2.60.4 build on this stack contains no such handler, and even where it did, that only puts the application on the bus — the empty tree behind the window stays empty until something asks for attributes or relations. It is also not a free action: that handler writes the desktop's toolkit-accessibility setting, turning accessibility on for every application on the session.

First integration target: Cairn

The first real-world target is Cairn's own interface: WebView2, backed by Chromium, on Windows and WebKitGTK on Linux. Accessibility exposure from these embedded webviews is the load-bearing integration risk. A backend that can enumerate native controls but cannot obtain the webview content tree does not meet Cairn's use case.

Early backend work should therefore test provider activation, complete webview subtree capture, stable developer identifiers, native activation, editable values, event delivery, and coordinate conversion against Cairn before broad desktop coverage. KVM access to each executor allows the semantic result to be checked against the visible interface as well as the API response.

Delivery matrix

The delivery contract in platform-spec.md is one vocabulary across all three backends, but the rungs each backend can actually offer differ. This table is what a caller can rely on today; anything absent from it refuses rather than falling through to a louder mechanism.

action macOS Windows Linux
invoke semantic (AXUIElementPerformAction, any named action) semantic (UIA InvokePattern only) semantic (AT-SPI Action.DoAction, any named action)
type semantic (AXValue + readback), then pixel, then foreground semantic (UIA ValuePattern + readback) semantic (AT-SPI EditableText.SetTextContents + readback)
scroll semantic (AXScrollToVisible); wheel bursts ride pixel then foreground semantic (UIA ScrollItemPattern) semantic (AT-SPI Component.ScrollTo)
click semantic when the element advertises AXPress, else pixel, else foreground pixel for a probe-verified window class, else refused pixel for a Chromium-family target, else foreground on X11 with an EWMH window manager, else refused
keyboard pixel with app, else foreground refused pixel for a GTK 3 or Qt 6 target named by app, else foreground on X11 with an EWMH window manager, else refused
drag pixel with a semantic-name endpoint, else foreground not implemented not implemented

No backend reports pixel for a mechanism it cannot bind to a verified target.

On Windows the mechanism exists. A click resolves the leaf window through the resolved element's UIA ancestry, proves that window sits inside the top-level window the caller actually captured, refuses across an integrity boundary, reconciles the target's DPI awareness with the daemon's, and reports the client-coordinate transform as evidence. What gates it is the class allowlist in rust/axon-win/src/pixel.rs: a window class enters that table only after axon-win probe pixel-click observed a real state change inside the target with the foreground window and the cursor position both unchanged.

The messages are delivered synchronously rather than posted, and that choice is load-bearing rather than incidental. A posted message only enters the target's queue, so reading the foreground and the cursor immediately afterwards samples a moment before the handler runs — a window procedure that activated its application or moved the pointer while handling the click would do so after the daemon had already reported both invariants intact. SendMessageTimeoutW does not return until the window procedure has processed the message, which gives the delivery an explicit end for the invariant checks to straddle. It is bounded, and a timeout is reported as a failure to deliver rather than as a delivery.

The table currently holds one class, Button, earned against Character Map's "Advanced view" checkbox: the dialog expanded from 437 to 586 pixels and gained nine controls, with GetForegroundWindow and GetCursorPos identical before and after and the reported transform reconstructing the screen point exactly. Because the foreground rung is withheld below it, that entry is also the only way a Windows click is delivered at all today; every other class refuses, naming itself.

A short allowlist is the intended resting state rather than an oversight, and the same probe run shows why. A window procedure that examines a click and does nothing returns from it exactly like one that acts on it: Windows.UI.Core.CoreWindow, the class hosting every UWP and WinUI surface, took the whole sequence and Calculator's display never left zero. Delivery proves the handler ran and never proves it did anything, and the allowlist is the only thing keeping "the message was processed" from quietly standing in for "the control was clicked".

One caveat travels with the current entry: it was earned at 100% display scaling against a per-monitor-aware window, so the coordinate reconciliation that a DPI-unaware target needs was a no-op in that run. The probe reports dpiAwareness for exactly this reason — an entry earned where the transform had no work to do is a narrower claim than one earned where it did.

On Linux the mechanism is XSendEvent against a window the backend resolved, and it is the only X11 mechanism with the rung's shape: XTest and virtual pointers are global devices however narrowly aimed, and on Wayland libei has no window parameter at all, which is why the Wayland refusal is permanent. Relabelling XTest as pixel would make the contract's central promise false.

What decides where it may run is a measurement rather than an assumption. scripts/linux-toolkit-acceptance/ delivers window-targeted XSendEvent input to a background window per toolkit and reads back whether the target acted, with a real-pointer click and a focused keystroke as controls so that a silent target cannot be confused with a misaimed harness. Measured on Fedora 43 in a hermetic Xvfb lane and again on a live Xfce X11 session: only Chromium acts on a background click with the focus and pointer unchanged, on all three engine generations measured; GTK 3, WebKitGTK and Qt act on background keystrokes under the same conditions; GTK 4 receives neither. Qt also acts on a click while requesting activation, which the two lanes disagree about precisely because the live lane's window manager refused the request — an acceptance that depends on that refusal is not a background delivery. GTK additionally honours a synthetic click only while the real cursor is already inside the target window, which is why the mechanism looks available when tried by hand. Chromium's keystrokes are withheld despite the fixture recording them: they land only once a background click has already reached that window, and the harness measures its keyboard phase after its click phase, so the row records that state rather than an independent acceptance (AXN-102).

That makes the Linux rung the same shape as the Windows one: offered only where a probe has verified the target, keyed here on the AT-SPI toolkit name and version the application declares about itself rather than on a window class, and gated by the table in rust/axon-linux/src/pixel.rs. One entry is coarser than that implies — Chromium reports a constant 1.0 as its version, so an entry authorizes the whole family — which is why it carries three engine generations of evidence and an obligation to re-measure when the family releases.

The delivery variant is measured per toolkit and is load-bearing. An event sent with the matching mask reaches whichever clients selected it on the destination window; an event sent with an empty mask reaches the client that created the window whatever it selected. GTK 3 acts only on the second, Chromium and Qt only on the first, and sending a toolkit the other arrives as silence.

Binding differs by action because the two carry different things. A click resolves its window by descending from the root to whatever owns the resolved element's point and requiring that to be one of the target process's own managed top-level windows, which answers ownership and occlusion together — that descent is this backend's only hit test, AT-SPI having no portable point-to-element lookup. Keystrokes name an application rather than an element, so they bind only while the application has exactly one managed top-level window and refuse the ambiguity otherwise. Both revalidate immediately before sending, and both read the frontmost window, the X input focus and the real pointer back afterwards. The focus is read separately from the frontmost window because they are different facts, and the harness caught Qt moving one of them while the other stood still.

The pointer reading gates a click and only reports for a keystroke. XSendEvent keyboard events touch no pointing device, so motion around one is the person at the machine using their own mouse, and failing on it would reject deliveries that worked on evidence they cannot have caused. A click keeps the clause because an event that reached the global devices would move the real cursor, and nothing on this side can tell that from the hand on the mouse, so the fail-safe reading stands. Which of the two happened is on the wire as pointerAsserted.

Unlike the Windows rung, this one has no completion boundary to read those invariants across: XSendEvent hands the events to the X server, and when the target's own main loop dequeues them is not observable from here. The readings are taken after a bounded pause instead, and that pause is a backstop rather than the defence. The defence is the acceptance table, which refuses a toolkit measured to act while requesting activation instead of delivering to it and watching.

keyboard has no pixel rung on Windows and will not grow one in this shape. The rung is target-bound input derived from verified window geometry; keyboard names an application and an input string, so there is no element, no window to bind to, and no transform to report. It refuses backgroundPixelUnsupported saying exactly that and falls to the foreground rung. Literal text into a known field has an honest background home through type and UIA ValuePattern; shortcuts and named keys use the explicitly permitted foreground rung.

Linux offers the foreground rung where the session supports it. Windows offers it in an interactive desktop session where activation can be proved.

On Windows every seam is implemented and all but one of them is proved on a real desktop. frontmost_application reports the foreground window's process id — the same vocabulary capture records as the application identifier, so the transaction's identity comparison can actually succeed — and activate_application brings the target forward with the AttachThreadInput assist that SetForegroundWindow requires of a background process. pointer_location and move_pointer hand the real cursor back, which matters because SendInput moves it. axon-win probe foreground shows activation proved, the dispatch running once, and the pointer returning to where it started.

The hand-back has a stronger Windows constraint than permission alone. A live Notepad control proved that SendInput can return its full inserted-event count while the target has consumed only a prefix; changing foreground immediately then redirects the suffix. The identical already-frontmost dispatch landed completely. Windows exposes no target-consumption fence for this global input stream, and a trailing sent message is not one because sent messages may run ahead of queued input.

The rung therefore promises only what the backend can prove: activation of the intended target and exactly one dispatch. Windows leaves the target frontmost after a dispatch and reports restored: false with the input-stream reason; pre-dispatch exits still restore, and pointer restoration remains guaranteed cleanup work. Other backends restore after dispatch when their mechanisms provide a safe boundary. This is deliberately available only under the per-action foregroundPermitted opt-in; backgroundOnly remains non-disruptive.

Linux implements the same seams and offers the rung on an X11 session with an EWMH-capable window manager, withholding it everywhere else. The seams live on PlatformBackendsupports_foreground_transaction, frontmost_application, activate_application, and, for a mechanism that moves the real cursor, pointer_location and move_pointer — while the transaction itself is shared in rust/axon-core/src/delivery.rs. That the two backends implement the same seams and reach opposite conclusions is the point: the rung is offered on what the running session can actually prove, not on what the build contains.

What a dispatch at either rung is allowed to claim is shared in that same module. goal_success takes the action's verification and the carrying rung's activation proof and answers with the action's success; restoration stays in the cleanup evidence rather than in that condition. macOS reaches the same rule through a different shape: PrimitiveActionResult.unverifiedDispatch constructs the delivered-but-unproved result directly, rather than computing it at each call site.

Windows session and integrity constraints

SendInput is the foreground rung and needs the explicit opt-in. It also needs a session that can reach the global input devices at all. Session 0 and a noninteractive window station present as an unusable pointerInput and keyboardInput capability in the health document, and the same overlay feeds the dispatch decision: the backend derives both from the session it actually occupies, classified in rust/axon-win/src/lifecycle.rs. This matters because UI Automation keeps answering in those sessions while SendInput posts to a desktop nobody is looking at, so nothing else in the daemon notices. When the capability is unusable the refusal is noDeliveryCandidate, not foregroundNotPermitted, at either policy — opting in cannot conjure a device.

An integrity or elevation boundary between the daemon and the target is answered per target rather than per session, because it depends on which window is being addressed. The pixel planner reads the target process's mandatory integrity level and refuses when it sits above the daemon's. That obstacle closes the foreground rung as well: UIPI discards posted messages and SendInput alike from a lower-integrity process, so leaving the loud rung open would answer an elevated window with an invitation to opt in to a dispatch Windows had already decided to throw away.

UIA exposes InvokePattern rather than an open-ended named-action vocabulary, so the Windows backend performs Invoke and says so instead of claiming a name it cannot honour.

Linux compositor and toolkit overlays

Under X11 the foreground rung is XTest, gated on the opt-in and on a window manager that publishes _NET_ACTIVE_WINDOW and _NET_WM_PID. Those two properties are the whole transaction: the first is how the foreground is read and set, and the second is what ties a window back to an application. Without a manager honouring them there is nothing to activate through, so pointerInput and keyboardInput are unusable with reason no-window-manager. The XTEST extension itself is probed rather than assumed: a server started without it answers every other question about the session normally, and advertising input on the strength of a window manager alone would report the capability usable and discover otherwise only at the moment of dispatch.

A keystroke aimed at an application is resolved to the backend's own AT-SPI identity before the transaction begins, because that is the string frontmost_application answers with and activate_application raises. An application that cannot be resolved refuses with targetIdentityUnavailable rather than falling through to whatever holds the foreground, which would post keystrokes into work the caller never named.

Under Wayland they are unusable whatever else is true. The compositor refuses synthetic input from an ordinary client, and X11 cannot read or set the Wayland foreground. XWayland is the trap worth naming: Mutter publishes EWMH properties for X11 clients and injects XTest events globally, so a backend could activate an X11 window, prove it came forward, and dispatch — while a Wayland-native application held the focus it could neither see nor give back. A mechanism that works while its proof quietly does not is precisely what this contract refuses, so the session is classified as Wayland before any X connection is attempted, and every pointer or keyboard action refuses with noDeliveryCandidate carrying that reason.

Because XTest moves the real cursor, a Linux click captures the pointer before dispatch and warps it home afterwards, ahead of returning the prior window, and reports the outcome as pointerRestored. keyboard does not move it, and reports null rather than claiming a restoration that never happened.

AT-SPI paths are unaffected by any of this, because they mutate the accessibility tree rather than the session. AT-SPI identities carry the bus name alongside the object path, since every application's root object sits at the same path and the path alone would name several applications at once.

drag remains unimplemented on Linux. It holds a button down across the whole gesture, so it needs its own capability and its own account of a press held across a failed restoration, and has neither.

AT-SPI value setting no longer takes focus first. Focus is a system-wide side effect, and an action that changes it is foreground however it finally mutates the target, so the semantic rung sets the value directly and reads it back.

Keeping implementations aligned

The platform-neutral specification drives a shared conformance suite. Its fixtures should be language-neutral data wherever possible, with adapters that run them against Swift and Rust. Schema snapshots verify the public tools; synthetic trees verify locator behavior; strict target fixtures reject handles and standalone locators while accepting {app, name}; request/response fixtures verify JSON-RPC and MCP envelopes; .axn fixtures verify parsing and traces; backend harnesses verify capability and honest-result semantics. schema/fixtures/delivery holds the delivery vocabulary and result shapes, read by both Tests/AxonCoreTests/SharedDeliveryConformanceTests.swift and rust/axon-core/tests/delivery.rs, so a rung or refusal reason cannot be renamed on one side alone.

Native integration tests remain necessary because no fixture can prove UIA or AT-SPI exposure. Conformance establishes that implementations mean the same thing; native tests establish that each backend can deliver it.

Continuous integration lanes

The Test workflow is the merge gate. Its deterministic Swift and Rust jobs run for pull requests and pushes to main on the enrolled macOS, Linux, and Windows self-hosted runners. Each job selects both self-hosted and the runner's dedicated axon-live-* label, so GitHub cannot silently route it to a hosted machine or to the wrong operating system.

The Linux job also runs the hermetic X11 foreground test under Xvfb. That test brings its own miniature EWMH window manager rather than depending on an installed desktop, so it needs only the Xvfb binary and behaves the same on every run. It gates pull requests deliberately: the project's only real Linux desktop is a GNOME Wayland session, which is the worst possible place to verify X11 activation, and the live lane asserts the opposite property there — that global input stays withheld at both policies with a reason naming Wayland.

The Test workflow's macOS job also rehearses the live lane's recovery branches. Because the live workflow has no pull_request trigger, its steps otherwise first execute on a real desktop with nothing having tried them, and the branches deciding whether that desktop keeps its start-at-login daemon only run once a probe has already failed. scripts/test-macos-live-recovery extracts those step bodies from the workflow file itself and drives them against stubbed launchctl, pgrep, open, and Axon commands, asserting the branch and the message each scenario produces. It runs on the macOS runner rather than a hosted one so that the shell interpreting it is the same bash 3.2 the live lane gets.

The Test workflow's Windows job does the same for the Windows lane, whose stages live in .github/scripts/windows-live-probe.ps1 rather than in the workflow file. scripts/test-windows-live-recovery.ps1 dot-sources those stages and replaces every function that touches the machine with one that drives a fake it can inspect afterwards, so a scenario can assert which branch ran, what it said, and what order it did things in. The probe script keeps those functions together in a marked region and the harness fails when it finds one it does not stub, because a seam nobody stubbed is a scenario reaching a real desktop.

The separate Live desktop verification workflow is a reporting lane. It runs only after a push to main or an explicit manual dispatch, never for a pull request. Its self-hosted jobs use dedicated axon-live-* labels and serialize per machine because the same desktops also serve as Cairn executors. A live-lane failure reports a real integration regression without blocking a pull request:

Every live runner is also somebody's desktop, which is the source of the one failure mode that would quietly hollow these lanes out. The endpoint is a rendezvous, not a proof of authorship: only serve binds it and every other subcommand is a client, so a probe that starts its own daemon and then talks to the endpoint is answered by whichever daemon holds it — routinely the installed release the desktop user already runs. The freshly built daemon loses the bind, and when a probe backgrounds it the shell never sees that it exited, so every assertion afterwards is true of a binary nobody changed.

All three lanes close this. Each stops the desktop's own daemon once for the whole job rather than once per probe, confirms nothing is answering on the endpoint before any probe starts, and asserts that the process id in the health document is the process id it launched. What that pid names differs by platform. On Linux it is the serve process the probe backgrounded. On macOS the app bundle serves the endpoint from inside its own process — there is no serve child — so it is the pid of the app that open -n launched, found by the bundle executable's path under the workspace, which no installed copy shares, and required to be the only process running from it. On Windows nothing the job runs launches the daemon at all: Task Scheduler does, in the logged-in session, and it reports nothing about the process it started, so the pid is found by the probe executable's path — outside the workspace, and shared by no installed copy — and likewise required to be the only process running from it. scripts/assert-daemon-under-test carries the check for the two lanes that run under bash; the Windows stage carries its own, because the relay's environment is PowerShell in session 0 with no jq.

Two properties of the stop matter as much as making one. It has to be performed by the build under test rather than by the runner's installed CLI, which is whichever release that desktop last installed and may predate the verb entirely; the macOS lane called shutdown on an installed binary that had no such subcommand, and || true meant nothing ever reported it. And it must not be tolerated: axon shutdown and systemctl stop exit non-zero while anything is still answering, which is exactly what makes their success the endpoint-is-free guarantee the probes rest on. What a request that fails means, and what one means that succeeds after another has already been acknowledged, are different questions that the exit code does not answer on its own — the Windows park works both out from the process, described with the rest of that lane's patience below.

A stop also incurs a debt, and a final if: always() step pays it. axon shutdown is not a pause — it boots the LaunchAgent out precisely so KeepAlive cannot relaunch the daemon between it acknowledging the request and exiting, and the agent then stays unloaded for the rest of the login session while the plist sits on disk still reading as registered. Linux puts its systemd unit back: contents, enablement, and running state. macOS reloads the agent with axon daemon restart, which bootstraps the plist on disk without rewriting it; daemon install would repoint the desktop's registration at the job's dist directory and is never the restore. Neither lane trusts the restart's exit code, because whatever starts a daemon reports on starting it: systemctl start is a claim about exec under Type=simple, and launchctl bootstrap is a claim about loading a job. Both therefore require the restored daemon to answer a health round trip and to be the process systemd or launchd names, and neither treats the restart command's own status as the verdict — on macOS that command includes a readiness wait parsed with the building release's decoder, which a release skew the rest of the step deliberately tolerates could fail on its own.

The macOS probe leaves one thing behind that its Linux counterpart cannot, and scripts/stop-probe-app is what removes it. The two daemons behave differently when they lose the endpoint: serve exits, while the app bundle catches the socket error, records it in its menu, and keeps running. A probe app that never bound is therefore a live process answering no health request, which axon shutdown can neither see nor stop, since it stops a daemon by asking it to — and one that bound and then wedged still holds the advisory lock that makes the next serve refuse. Left alone it outlives its job and survives the next checkout, because deleting an executable does not change a running process's arguments. Both the parking and restore steps sweep it by workspace path, which is what keeps the sweep from ever reaching an installed copy; the Windows job's Remove leaked live-probe daemons is the same measure for the same reason.

Windows carries one rule the other two do not need, because its registration is a machine-wide object that a security product can act on by name. The daemon under test runs from a scheduled task of the probe's own, and the machine's Axon Windows Daemon task is read and started but never written — not unregistered, not repointed, not restored, because nothing touches it.

The earlier lane did write it. It unregistered the desktop's task, registered its own under the same name at a binary freshly built into C:\ProgramData\Axon\live, and put the original back in a finally. On 2026-08-08 Defender classified that binary as execution and persistence malware and quarantined it between the install and the restart. A behavioural detection of that kind removes what refers to the file as well as the file: an earlier detection on the same runner took C:\Windows\System32\Tasks\Axon Windows Daemon and its TaskCache registry keys with it. Every fresh CI build is a never-seen binary, so a registration that names one is the ordinary case rather than the unlucky one, and a desktop whose start-at-login registration names it can lose that registration to a scan it never ran.

Windows also records its debt where the debt can outlive the job. $GITHUB_ENV dies with the runner, so on macOS and Linux a job whose runner service is killed outright leaves a desktop stopped with nothing that remembers why. The Windows park writes what it found to a file on the machine before it stops anything, and the next park carries an unpaid debt forward rather than overwriting it — without that, the following run would find no daemon, record that there had never been one to put back, and let its own restore clear the debt and report success, turning an outage that a logon would have ended into one that lasts the whole login session. The restore step is conditioned only on the checkout having succeeded, and not on this job having parked anything, for the same reason: a job-scoped sentinel is a second answer to a question the machine already answers, and it can only subtract — exactly in the case the record exists for, since a run that fails before its own park is precisely the run that would otherwise walk past a debt it could have paid.

Three further consequences shape the lane. Each build is executed once — a version call — before anything on the desktop is borrowed, which is where block-at-first-sight's delay of up to a minute is paid and where a quarantine costs nothing, since the machine still has its own daemon at that point; it also keeps that delay out of the daemon's readiness measurement, which had been reporting 47 seconds for a daemon whose own startup log said 1.46. The probe re-asserts at the end that the desktop's registration still points where the park recorded it, so a lane that repointed the machine could not report success. And the restore needs no Axon binary at all when the build under test has vanished: Task Scheduler starts the registration at its own path, and the health round trip that decides the verdict is read through the executable that registration names.

That restore is also patient, and patient in a particular place. The daemon gives up thirty seconds after failing to get a UI Automation client, which is fail-fast behaviour worth keeping — and which a desktop under a background servicing operation can blow while being perfectly healthy four minutes later. So the lane carries the patience rather than the daemon's bound: when nothing is answering, the restore starts Axon Windows Daemon up to three times, and it reads the task's state before each start. That read is the part that is easy to leave out. Task Scheduler discards a start against a task whose previous instance has not finished, and schtasks /run reports success while discarding it, so a fallback that fires blindly can start nothing at all and leave a poll waiting for a daemon nobody launched — which is how run 31339688217 ended with a red lane and a healthy desktop. A running instance is therefore waited out rather than started over, with the health round trip polled throughout, since Running is equally what a healthy daemon looks like: the registered action is serve, so the task runs for as long as its daemon lives. When the budget expires with nothing answering, the stage fails exactly as loudly as before, because a desktop that cannot get its daemon back is a runner that needs a human.

The park is patient in the same way, and the daemon's bound is again what makes it necessary. axon-win shutdown waits ten seconds for the process it asked to stop, because the acknowledgement is sent before the UI Automation thread joins and the COM apartment is torn down, and reporting a stop before the process is gone races whatever runs next; anything slower than that wait it reports as a failure. On 2026-08-10 the wait expired on a runner that had just finished a cargo build, the stage failed the lane, and the process exited seconds later while the if: always() restore was already putting the desktop back — a red run over a healthy desktop. A failed request therefore opens a wait rather than ending the stage: the park polls the process the health document named, and asks again up to three times, roughly ninety seconds in all.

What it must not do is believe a later request that reports success. The pipe goes when a daemon acknowledges the request and the process goes when it has finished tearing down, so once one request has been acknowledged every request after it finds no pipe at all and reports exactly that. no daemon was running is a true statement about the pipe that says nothing about the process, and taking it for one would hand the probe a machine where the daemon this stage set out to stop is still there — which is the race the command's own wait exists to prevent, recreated by the lane that was supposed to be patient about it. So the process is the verdict every time round the loop, and a daemon whose document names no process id at all is waited for through the health round trip instead, which is the same tolerance the restore extends to an old enough release. What has not changed is what a stop that never takes costs: a daemon still running when the budget is gone fails the stage as loudly as it did before there was one, and it is named rather than killed, because a daemon this lane kills is a daemon it cannot put back. The debt is recorded before any of this, so a park that dies in the middle of it still tells the restore what it owes.

The repository's Actions policy requires approval for every outside contributor's workflow run before pull-request code can reach the self-hosted desktops. The live workflow still has no pull_request trigger because its interactive probes alter desktop state and should run only from trusted main or a manual dispatch.

Re-enrolling a live runner

Remove the offline runner in Settings > Actions > Runners, then stop and uninstall its local runner service. Download the current runner package from the repository's New self-hosted runner page, request a fresh registration token, and configure the replacement as the desktop user with exactly one dedicated label: axon-live-mac, axon-live-linux, or axon-live-windows. Install and start it with the runner package's platform service command (svc.sh install and svc.sh start on macOS/Linux). On Windows, run config.cmd from an elevated prompt and select service mode, or pass --runasservice during unattended configuration. Confirm the runner is online and carries self-hosted, its operating system and architecture labels, and its dedicated axon-live-* label before dispatching the workflow.

The Windows service remains under NETWORK SERVICE. Re-enrollment also requires recreating its localhost SSH key and installing that public key for the desktop user with a forced command that runs only C:\\ProgramData\\Axon\\live-probe.cmd; disable forwarding and pseudo-terminal allocation with the authorized-key restrict option. That file's source is .github/scripts/windows-live-relay.cmd. It lives on the machine rather than being read from the workspace, so changing what the key dispatches to takes machine access — but what each stage then runs is the probe script from the runner's own checkout, so the relay constrains which stage runs and not what code runs. The boundary for untrusted changes is the repository's Actions approval policy for outside contributors, not this file. It accepts only the four stage names, and it expands the requested one with delayed expansion, because a value substituted before cmd tokenizes the line lets an & in it run as a separate command before the allowlist is ever consulted. A runner whose copy predates the staged lane refuses every stage with exit code 126, which the workflow reports by name. Deploy it whenever either file changes. Give the private key only to NETWORK SERVICE and SYSTEM, pin the localhost host key in the runner's known_hosts, and verify an arbitrary SSH command is rejected before enabling the runner. This narrow relay is necessary because Windows services run in session 0 while UI Automation and the daemon's scheduled task must run as the logged-in desktop user.

Registration tokens are short-lived secrets. Generate one immediately before configuration with:

gh api repos/bleugreen/axon/actions/runners/registration-token -X POST --jq .token

Rust macOS backend v1

rust/axon-mac is the production ApplicationServices implementation of axon_core::PlatformBackend. It captures each selected application's complete Accessibility window tree, including native AXRole/AXSubrole vocabulary, labels, values, identifiers, actions, editability, and screen extents. Its role semantics deliberately mirror the Swift tables in Sources/AxonCore/AXHierarchyBulkCapturer.swift and Sources/AxonCore/AXRoleSemantics.swift: press is inferred for buttons, links, check boxes, radio buttons, and menu items, while combo boxes, text areas, and text fields are editable.

One vocabulary names an application throughout this backend. Resolution matches an AppQuery against the running applications it enumerates: process_id against the pid, name case-insensitively against the display name, and identifier against the bundle identifier. That is the meaning identifier carries on Windows and Linux too, where it is likewise the stable identity the backend publishes in Application.identifier, and macOS publishes its bundle identifier there rather than leaving it null.

A capture driven by a recorded input event queries on one key rather than all three, following the precedence RecordedAppIdentity::matches_runtime already defines: the process id, else the bundle identifier, else the name. A recorded identity is only as strong as its strongest key, so constraining the weaker fields alongside it cannot make the match more certain and can only strand the evidence for an event that was genuinely observed. Constraining all three is how every macOS recording once ended with zero actions.

The display name is NSRunningApplication.localizedName, falling back to the application element's AXTitle, decided in one place in rust/axon-mac/src/global_input.rs for enumeration, capture, and the recorder alike. That matches AppResolver in Sources/AxonCore, which reports and resolves applications by localized name and bundle identifier and never consults AXTitle; AXTitle names windows and elements, not applications. Sharing the decision keeps a recorded artifact calling an application what look calls it.

The v1 facade exposes look, find, click, type, keyboard, invoke, scroll, and run. look derives semantic names through axon-core and actions resolve the strict {app,name} target through the shared registry; ambiguous names return candidates without dispatch. Element clicks use AXPress, typing uses AXValue, invoke uses the requested AX action (defaulting to AXPress), and scrolling uses AXScrollToVisible. Global keyboard input has no semantic Accessibility action and therefore refuses honestly in v1. Pixel and foreground delivery are likewise unimplemented rather than approximated. Extended observation, history, permission prompting, and drag tools fail before native dispatch with capability errors.

Full application observations capture the first Accessibility window through Core Graphics' synchronous per-window image API, resize its long edge to the shared observation budget without upscaling, and encode it losslessly with ImageIO. This deliberately uses CGWindowListCreateImage rather than ScreenCaptureKit: the backend needs one already-resolved window image, while ScreenCaptureKit would add an Objective-C asynchronous callback bridge and capture-session lifecycle without improving that result. The direct framework FFI also preserves the backend's dependency-free native boundary. Core Foundation values returned under Create/Copy ownership are released exactly once; borrowed Accessibility array members are retained before they enter an owned wrapper.

Screenshot health is independent and truthful: the capability is usable only when CGPreflightScreenCaptureAccess reports a Screen Recording grant, and its restriction names that missing grant otherwise. One owned CGImage can feed ImageIO, native Vision text recognition, or both, so requesting screenshot and screen text never captures twice. A narrow Objective-C bridge owns Vision objects, autorelease and exception boundaries; Rust converts Vision's lower-left normalized boxes into absolute Accessibility coordinates. Capture, encoding, or OCR failure is returned as named observation unavailability while the semantic look still succeeds. The app-list, change-check, and child-page carve-outs remain imageless through axon-core's shared screenshot policy.

The daemon resolves the same production endpoint as every Swift client and the LaunchAgent: AXON_SOCKET_PATH, defaulting to /tmp/axon.sock. Development, conformance, and bench probes retain the higher-priority AXON_MAC_SOCKET private-endpoint override so branch builds never need to repoint an installed daemon. The server owns a lifetime flock, reclaims only stale socket nodes, and serves connections concurrently through one serialized native-backend worker. Health and shutdown bypass that worker so a long replay or wait cannot hide daemon liveness. The registered Swift daemon remains unchanged until the deliberate cutover stage.

The durable bglab-mac development identity is packaged by scripts/package-macos-rust-bench at ~/AxonBench/AxonMacDev.app, with the stable bundle identifier dev.axon.mac-bench. The script replaces and ad-hoc-signs only the binary inside that stable bundle; it never installs a daemon or edits TCC. scripts/probe-macos-rust-bench starts that binary on a unique isolated socket and performs a trivial Calculator AX read, which future builders run before asking for another Accessibility grant. Any live test that launches a desktop application on a bench machine must quit that application before handing the machine off. The live lane's clean-desktop guard treats a leftover application as an occupied session and refuses to run, so cleanup is part of every live test rather than an optional follow-up.

macOS application enumeration filters process paths to app-bundle main executables and excludes nested XPC services. XPC helpers can inherit their host application's AX title (Calculator's ThemeWidget service does), so AXTitle alone is not an application identity and would make strict app lookup falsely ambiguous.

macOS Accessibility trust is per-process, and behaviour is the truthful source

Measured. AXIsProcessTrusted() answers with a verdict HIServices resolves once per process and does not revisit. Measured on bglab-mac (2026-09-03) against a running daemon while the user toggled its Accessibility row, it is frozen in both directions: a daemon trusted at launch kept reporting trusted after the grant was revoked, and a daemon that started untrusted kept reporting untrusted after it was restored. In the same run, AX reads did fail on the still-trusted-looking process while its cached verdict said trusted — that is how look came to report application not found for an application that was running and visible. So the cached verdict and the API's actual behaviour disagree, and the cached verdict is the one that is wrong. On the build that made those measurements, which gated every AX call on the cached verdict, a re-granted daemon had to be restarted before it recovered.

The design that follows from this is that behaviour, not the cached verdict, is the truthful source. rust/axon-mac/src/accessibility.rs is the one place that answers "can this process use Accessibility right now": it asks the API to do a trivial piece of work and classifies the AXError, falling back to the cached verdict only when the status does not settle the question. Every other module delegates to it.

The classification ladder is deliberately asymmetric. kAXErrorNoValue and kAXErrorAttributeUnsupported mean the call was served, so a session with nothing focused does not read as denied. kAXErrorCannotComplete is ambiguous between an unresponsive target and a denial, so it defers to the cached verdict rather than flipping it. Only kAXErrorAPIDisabled (-25211) makes a trusted-looking process denied. That asymmetry is a safety property: in the granted case the probe can only answer granted or unknown, and both preserve whatever the cached verdict already said, so the probe cannot invent a denial. It earns its keep immediately — measured in a non-interactive build slot that holds the grant but has no focused application, the system-wide read returns kAXErrorCannotComplete and the fallback preserves the granted answer.

Application enumeration folds the same statuses from the AXTitle read it makes on each candidate process. When that fold says denied, look refuses with the typed accessibility-denied capability error instead of the misleading application not found an empty list otherwise produces. This path does not depend on a focused application, so it is the one that stays measurable in any session.

Pending measurement. Three questions are open until the live revoke/re-grant run on bglab-mac, and none of the code above asserts an answer to them:

axon-mac probe trust [--pid N] [--interval-ms N] [--count N] is the harness that answers them, following the axon-win probe <name> convention. It emits one JSON object per sample carrying axIsProcessTrusted, axIsProcessTrustedWithOptions, the raw systemWideStatus, the raw pidStatus from the read enumeration actually makes, and the verdict the build would act on. Those columns beside each other across a toggle separate "the verdict is cached" from "the API is disabled" without any inference; pidStatus is the one the typed look refusal depends on. Update this section with the dated result once the run happens.

Permissions are reported per TCC row; capabilities compose them

kTCCServiceAccessibility and kTCCServiceScreenCapture are separate rows a user toggles separately, and the health report's permissions block says so: each PermissionState reports only its own grant. Capabilities are free to compose the two, and screenshot does — but deriving the screenRecording permission field back out of that composed capability made a denied-Accessibility daemon claim Screen Recording was ungranted while its TCC row read granted the whole time. MacPermissions in rust/axon-mac/src/platform.rs reads the two independently; capability_report is a pure function of them, and health uses both rather than deriving one from the other.

The Rust backend's screenshot capability is unusable without Accessibility even when Screen Recording is granted, because this backend names the window to capture by resolving the application through the Accessibility API. The Swift daemon resolves applications through NSWorkspace and so does not share that restriction; Tests/AxonCoreTests/HealthStatusTests.swift and schema/fixtures/health/macos-accessibility-denied.json describe that implementation, not this one. Both are right about themselves. Once axon-mac becomes the shipping macOS daemon this needs a decision: either the Rust backend grows a non-Accessibility application resolution path, or the shared fixture stops claiming to describe both implementations.

Recording refuses on capability before a session exists

recording.start and editor.recordFromHere ask global_input_observer() before dispatching to the daemon. That seam is the one place that knows whether this process can watch input, and asking first means a denied start never opens a recording it then has to abandon. It also matches the backend's established convention of refusing on capability before validating parameters. The refusal carries -32004 with kind: capability-unavailable and the stable code: accessibility-denied, matching look.

macOS browser Automation attribution

The Swift daemon sends Safari and Google Chrome events directly through NSAppleScript.executeAndReturnError; it does not launch osascript. Authorization is split by who is asking. Agent-facing verbs call AEDeterminePermissionToAutomateTarget with askUserIfNeeded: false and refuse on anything but noErr: the prompting call blocks its thread until a person dismisses the dialog, and browser verbs run synchronously on the socket-handling thread, so prompting from there would stall an agent's call indefinitely on a dialog nobody asked for. The prompting leg belongs to one deliberate gesture — the daemon menu's Browser Automation item, which requests consent off the main thread for each supported browser that is running. That item exists only when the gesture has something to do: it is built from a non-prompting reading of the same per-process answer ledger, and a denial keeps it (the gesture is then the only surface carrying the remediation) while an all-allowed reading retires it. The reading happens as the menu opens rather than on the menu's two-second refresh, because a determination is not free — it costs a TCC round trip, and it is what makes the process start holding macOS's answer, which must not happen earlier than a person's own use of Axon would cause it. The item therefore returns for a browser this process has no answer for, and not for a grant reset behind an answer it already holds: the ledger has the same lifetime as the process-held answer it mirrors, and a reset is visible only after a restart. An executed-leg denial is the one answer a later preflight cannot overwrite — it is the answer that proved the preflight wrong for that target — so neither a browser verb nor the consent gesture can retire the item on the strength of a noErr that contradicts a refused event. The intended sender is Axon.app/Contents/MacOS/Axon, registered in launchd's gui/<uid> domain with LimitLoadToSessionType=Aqua; the daemon bundle also carries NSAppleEventsUsageDescription.

Every decision names the leg that produced it — checked, prompted, or executed — in the JSON-RPC error's data.leg and in one stderr line per decision, which the LaunchAgent routes to ~/Library/Logs/Axon/daemon.err.log. A field report should be readable without a bench protocol.

An Automation trial must restart the daemon after resetting a grant. macOS resolves an authorization inside the sending process once that process holds an answer for a sender/target pair, and tccutil reset rewrites the TCC database without reaching into a running process. The 2026-08-14 trial ran denial, reset, and retry against a daemon that never restarted (launchctl print reported runs = 1 and a single exec throughout), so the retry could not have consulted tccd; the absence of any kTCCServiceAppleEvents request for Axon in that window is what an answer resolved inside the process looks like, not evidence of a suppressed prompt. The daemon therefore records, per process lifetime, the answer it holds for each target — from either leg, since it was a silent-leg answer that got reused — and a repeat denial names a daemon restart as the remediation instead of pointing at System Settings. Keeping the answer and not merely the fact of one is what lets a surface that must not prompt, the menu, read a grant Axon has already seen.

What remains unverified is whether the consent dialog appears at all for this launchd-started process. The menu-bar gesture is the discriminating test, on a machine whose daemon has restarted since the reset. If no dialog appears there, the next evidence is launchctl procinfo <pid> for the responsible-process attribution — the job is trampolined, started via xpcproxy, which is a platform binary — and the candidate fix is registering the bundle with SMAppService.mainApp so LaunchServices starts the app rather than launchd exec'ing the binary. If prompting proves impossible from this context, the honest answer is that browser Automation needs an MDM PPPC profile (kTCCServiceAppleEvents with AEReceiverIdentifier per browser) on managed Macs and is unavailable elsewhere, reported as a capability restriction rather than a permission the user can fix.

Apple Events TCC evidence must be attributed by client bundle identifier, indirect target bundle identifier, and the code-signing requirement stored in the row. A path or PID alone is not identity. Live probes must launch a signed bundle main executable through a private GUI-domain launchd label and socket, assert the answering PID and executable, and reset only the throwaway bundle's Apple Events grant. Existing daemon and browser state must be restored unconditionally. Since TCC stores the granting-time code requirement, upgrade persistence is meaningful only when both trial bundles use the same stable bundle identifier and designated requirement; ad-hoc signatures cannot establish that claim. The Rust macOS backend must preserve this attribution model when it adopts browser operations.