Skip to content

refactor(transport): put a MessageTransport port in front of Matrix (CHOO-1436) - #363

Draft
amaudruz wants to merge 4 commits into
mainfrom
worktree-transport-port
Draft

refactor(transport): put a MessageTransport port in front of Matrix (CHOO-1436)#363
amaudruz wants to merge 4 commits into
mainfrom
worktree-transport-port

Conversation

@amaudruz

@amaudruz amaudruz commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Why

Replacing the Matrix homeserver with a Postgres-backed message store means touching every module that imports matrix-nio. There were eight, and two of them — bridges/agent/protocol/service.py and bridges/collaboration/bridge_core.py — reached past ClientBase to the raw nio client at 28 sites between them.

This is step 2 of the agreed plan: put a port in front of Matrix so every later step is "write another implementation" rather than surgery on 5,500 lines.

Behaviour is unchanged. That is the point of the step, and the thing to check in review.

What you get

git grep "^from nio\|^import nio" core/switch_core/ returns exactly one file: switch_core/transport/matrix.py. No module reaches for a raw client — the nio_client escape hatch is deleted, not deprecated.

A test enforces both by parsing imports (tests/switch_core/transport/test_no_nio_outside_transport.py), plus a test that the guard can actually detect a violation — a check that always passes is worse than none.

The port

  • transport/types.py — neutral vocabulary. SendResult, UploadResult, DownloadResult, HistoryPage, and the inbound DTOs RoomRef / InboundEvent / InboundMessage / InboundMedia / InboundMembership / InboundCustomEvent. Nothing here names Matrix.
  • transport/port.pyMessageTransport, a runtime-checkable Protocol. Every method corresponds to something a caller already did with a nio client.
  • transport/matrix.py — the implementation. Content dicts, m.relates_to, mxc URIs, pagination tokens, the sync loop, and the timestamp_to_event seek all live here.

What changed at the callers

  • ClientBase names no implementation. It holds a MessageTransport built by a factory the caller injects, so the choice of transport is the factory's business.
  • Credentials are opaque. access_token / device_id are gone; ClientBase holds a session_state dict it persists and hands back without reading. A client still holding a Matrix access token would still be a Matrix client.
  • Inbound events are DTOs. The transport converts at the edge, so all 29 on_* hooks and five subclasses take (RoomRef, Inbound*).
  • service.py and bridge_core.py are closed. History, single-event fetch, media download, the timestamp seek and all 16 com.switch.* sends go through the port.

Two design calls worth arguing about

The port raises; ClientBase still returns None. send_message used to log-and-return-None and callers depend on that, so ClientBase catches TransportError and preserves it exactly. The contract is right and behaviour is unchanged; the try/except pairs go when callers are ready.

InboundMedia extends InboundMessage. mypy forced this and it was the right correction — a media event is a message that carries a file, exactly as nio's own RoomMessageMedia and RoomMessageText both extend RoomMessage. Code that only cares about sender, body or thread now treats them alike, which is what the calling code already assumed.

The bug this nearly shipped — worth reading

The mechanical part of this migration was done with a codemod, and it half-converted read_context. Four reads still used nio attribute names on objects that are now DTOs. Because they were written defensively — getattr(event, "server_timestamp", None), hasattr(event, "source") — nothing raised. It degraded silently:

  • sender names fell back to the raw mxid; attachment metadata came out {"mimetype": "", "size": 0}
  • every timestamp was None, so since/before filtering never fired, oldest_timestamp was always null and truncated was wrong
  • _thread_root_id never matched, so every message became its own thread root — threading in read_context was entirely gone

The tell was inside a single function: one line already used the new timestamp field while another still used server_timestamp.

This is the failure mode the repo's "fail loud" rule exists to prevent. Those defensive spellings were written when the events really were nio objects, outlived their reason, and turned a rename into wrong data instead of a crash. They are removed along with the bug.

It was caught because the tests were treated as the specification: the fixtures were updated to the new vocabulary, but no assertion was weakened to make a failure go away. Ten tests kept failing and were right to.

Testing

  • 2204 passed, 0 failed. ruff and mypy clean across 196 source files.
  • FakeTransport (tests/switch_core/transport/fake.py) is the shared double replacing every ad-hoc fake nio client.
  • 16 new tests for the transport, including the send_media wire-format cases (caption vs filename, multi-file group marker) that would otherwise have been lost when that serialisation moved behind the port.
  • The only test file importing nio is the one testing the Matrix implementation.

Not verified

The integration suite against the real Matrix stack has not been run. For a change whose entire claim is behaviour preservation, that is the real safety net and it should be green before this merges.

Deliberately out of scope

  • matrix_user_id remains as the client's address. Renaming it to a neutral identity is a repo-wide codemod that belongs with the schema change in step 5.
  • The lifecycle is still Matrix-shaped: register_user provisioning and the next_batch resume cursor stay until the transport is actually replaced.

🤖 Generated with Claude Code

amaudruz and others added 2 commits September 3, 2026 17:18
Switch reaches matrix-nio from eight modules, so replacing the homeserver
would mean editing all of them at once. Introduce `switch_core.transport`:
`MessageTransport` states the contract, `types` holds transport-neutral
DTOs and results, and `MatrixTransport` is the one place nio may be
imported.

The contract is derived from what callers already do rather than invented
— its method set is the one the existing test doubles fake. Operations
raise `TransportError` instead of returning an error object, so a caller
cannot proceed on a failure it forgot to check; `ClientBase.send_message`
and `send_media` keep returning None on failure so behaviour is unchanged.

ClientBase now holds a transport rather than an AsyncClient, and its
Matrix imports drop from sixteen symbols to the seven inbound event types
that still appear in hook signatures. `nio_client` remains as a property
over the transport's raw client for the call sites in service.py and
bridge_core.py that are not yet expressed on the port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…436)

WIP: production code is complete, test fixtures are still being updated.

matrix-nio is now imported by exactly one module, `transport/matrix.py`,
and no module reaches for a raw client — the `nio_client` escape hatch is
gone rather than merely deprecated. A test asserts both, so the seam
cannot rot back open unnoticed.

ClientBase no longer names an implementation. It holds a `MessageTransport`
built by a factory the caller injects, and its credentials are an opaque
`session_state` it stores and returns without reading, so what
authentication needs is the transport's business alone.

Inbound events cross the boundary as DTOs rather than nio classes. The
transport converts at the edge, so the 29 `on_*` hooks and their five
subclasses take `(RoomRef, Inbound*)`. `InboundMedia` extends
`InboundMessage` because a media event is a message that carries a file,
which is what the calling code already assumed. Several dicts stop being
spelunked: sender name, mimetype, size and thread root are fields now.

read_context, media download and the timestamp seek go through the port,
so replacing the store later is a matter of another implementation rather
than surgery on service.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amaudruz amaudruz changed the title refactor(transport): put a port interface in front of Matrix (CHOO-1436) Nuking the shit out of Matrix, part I Sep 3, 2026
Four reads in read_context's helpers still used nio attribute names on
objects that are now DTOs. Because they were written defensively —
`getattr(event, "server_timestamp", None)` and `hasattr(event, "source")` —
the rename produced wrong data rather than an error:

- sender names fell back to the raw mxid and attachment metadata came out
  empty, since `event.source` no longer exists;
- every timestamp was None, so `since`/`before` filtering never fired,
  `oldest_timestamp` was always null and `truncated` was wrong;
- `_thread_root_id` never matched, so every message became its own thread
  root and threading in read_context was gone entirely.

Read the DTO fields instead. The defensive spellings are removed with
them: an attribute that must exist should fail when it does not.

Also adds the send_media wire-format tests that were lost when that
serialisation moved behind the port — caption versus filename, and the
multi-file group marker — and takes the member display name from the DTO
field rather than digging it back out of the content dict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amaudruz amaudruz changed the title Nuking the shit out of Matrix, part I refactor(transport): put a MessageTransport port in front of Matrix (CHOO-1436) Sep 3, 2026
…ort (CHOO-1436)

The collaboration lifecycle constructs BridgeClient directly rather than
through ClientFactory, so it kept passing `device_id` and `access_token`
after those were replaced by an opaque `session_state`. All four bridges
failed to start; core and the agent bus were unaffected, so the symptom
was human channels silently not relaying.

Nothing caught it because `BridgeClient.__init__` forwards `**kwargs: Any`
to ClientBase — as AdminClient and ResourceManagerClient also do — which
hides the constructor from the type checker. A keyword ClientBase no
longer accepts type-checks clean and fails when the client starts.

The transport factory becomes public so both call sites share one
definition instead of each deciding how to build a transport.

The regression test walks the AST for any `*Client(...)` call passing a
retired credential. Constructing a client correctly, which is what the
accompanying construction tests do, says nothing about a call site that
constructs it wrongly — those tests pass with this bug reintroduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant