Skip to content

ADR-0003 The kernel owns the Network port, and its production adapter wraps paho-mqtt

Status

accepted, 2026-08-14.

Context

Section 2 of ARCHITECTURE.md names four seams and calls them the defining architectural decision of the repository: CLOCK, RNG, NETWORK, and STORAGE. It gives NETWORK the surface connect(), publish(), subscribe(), disconnect(), an MQTT client over TCP with TLS in production mode, and an in-memory bus with a fault-injection layer in simulation mode.

twinflow.kernel publishes Clock and nothing else. Three of the four seams have no port in this tree, and NETWORK has neither of its two adapters. No MQTT client is a dependency of any package in the workspace.

That gap is not visible from a checkout, because everything static about the garage tier passes. deploy/garage/docker-compose.yaml validates, the image builds, and the certificate script issues. The compose segmentation gate reports one crossing point with the OT segment internal, and the broker demands an identity on both listeners. Five of its seven services then fail at their first instruction, because the command each one names does not exist. The tier was asserted statically and had never been started.

Three obligations constrain the answer.

twinflow.sensors may not hold the transport itself. Its package docstring states the rule it follows: the devices publish value objects and something above wires them to a transport. Doctrine D-09 declares the layering, and section 2.9 of docs/design/iot-fleet.md places the wiring in twinflow-edge, a package the roadmap carries at no phase. So the seam has to arrive somewhere that exists.

The client cannot be selected on features alone, because the licensing rule binds first. Technology decision D15 admits permissive dependencies only, the CONTRIBUTING.md allowlist carries no Eclipse Public License row, and ADR-0002 records that this project refuses a dependency on its license tree rather than work around it. The same ruling refused certifi at MPL-2.0 and cost twinflow-api its HTTP test client.

The asynchronous clients collide with a rule this repository already made. Section 5.3 of docs/design/iot-fleet.md makes Clock.timeout the only timeout primitive, so a broker keepalive of sixty seconds is sixty sim seconds and compresses with everything else. Lint rule TFD014 bans asyncio.wait_for for the matching reason: it reads loop.time() and would not compress at all. A client whose own timeouts run on the event loop's clock puts a second, uncompressible time source underneath the seam.

docs/design/foundations.md section 5.4 illustrated the escape-hatch annotation form with the string reason="aiomqtt is the production transport" and a link to docs/adr/0004-mqtt-adapter.md. Both were contents of a worked example rather than a decision: no record stood behind either, and section 2 of the decision index assigns numbers in order, which makes this record 0003. The example now names the client this record chooses and the number this record holds, so a reader who copies the sample annotation copies a true one.

Licensing and release facts below were read from the PyPI JSON API on 2026-08-14.

Client Version Declared license Latest release Resolves paho
paho-mqtt 2.1.0 EPL-2.0 OR BSD-3-Clause 2024-04-29 is paho
aiomqtt 2.5.1 BSD (classifier) 2026-03-05 yes, >=2.1
gmqtt 0.7.0 MIT 2024-11-22 no

Decision

The Network port becomes a Protocol in twinflow.kernel, beside Clock, carrying the four calls section 2 of ARCHITECTURE.md gives the seam. Its simulation adapter is an in-memory bus in the kernel, ordered deterministically. Its production adapter wraps paho-mqtt version 2.1.0, taken under the BSD-3-Clause half of that project's EPL-2.0 OR BSD-3-Clause dual license, and lives under the kernel's production adapter path, which docs/design/foundations.md already exempts from the determinism lint. The client is an optional extra under doctrine D-10, so the base install stays pure Python and a reader taking one brick never resolves it.

Alternatives considered

Alternative Why it lost
aiomqtt The most actively released of the three, and it resolves paho-mqtt>=2.1.0,<3.0.0 into the tree regardless. So it inherits the same license question and adds a dependency to reach it. Its asynchronous surface also puts the event loop's clock underneath a seam whose only timeout primitive is Clock.timeout.
gmqtt MIT, needing no license reasoning at all, and more recently released than paho. It lost on the same clock argument, and on being a standalone client with no relationship to the Eclipse project that publishes the Sparkplug specification this repository implements.
Take paho-mqtt under EPL-2.0 A dual-licensed dependency is taken under one of its licenses, not both. EPL-2.0 is weak copyleft with no allowlist row, and adding one to accept a license the BSD-3-Clause half makes unnecessary is a condition taken on for nothing.
Write the MQTT client here Decision D14 in ADR-0002 wrote an agent seam rather than adopt a framework, so the precedent exists. It does not carry: an agent seam is a few hundred lines over a schema, and an MQTT 3.1.1 and 5.0 client with TLS, session state, and will handling is a protocol implementation nobody asked this project to own.
Put the port in a new twinflow-edge package Section 2.9 of docs/design/iot-fleet.md does place the edge agent there, and that package is still the right home for the connection state machine and the store-and-forward buffer. The port itself is not: ARCHITECTURE.md gives the kernel all four seams, and a port in a leaf package inverts the layering.
Leave the OT half of the garage tier unstarted The cheapest option, and it makes RA-b assert a segmentation claim over a topology with nothing on the OT segment. Gate VAL-GATE-QS-001 also times a quickstart ending on a live dashboard serving non-empty state, which no arrangement of unstarted containers reaches.

Consequences

What this buys. The fourth seam exists, so a device can publish without knowing which mode it is in. The fault catalog of section 5.24 of docs/design/iot-fleet.md gets the in-memory transport it drives in simulation mode. twinflow.sensors keeps its layering: it still publishes value objects and still imports no transport. The garage tier gets a runtime, which is what turns VAL-GATE-QS-001 from unarmable into merely unmet.

What it costs. A dependency now needs watching, and its most recent release is 2024-04-29, which is the oldest of the three candidates. The in-memory bus is a second implementation of message delivery, and two implementations of one contract disagree unless something asserts they do not.

The allowlist needs no new row, which is worth stating because the opposite is the natural guess. CONTRIBUTING.md already resolves an SPDX expression rather than pattern-matching it, and says why: OR lets the recipient pick, so one accepted term carries the whole, exactly as it already does for packaging at Apache-2.0 OR BSD-2-Clause. BSD-3-Clause is an accepted row, so EPL-2.0 OR BSD-3-Clause resolves to accepted with no edit. A dependency behind an extra is not counted at all, because nobody who omits the extra receives it.

The obligation this creates: this record is where the half taken is written down, since nothing in the resolved metadata distinguishes a project that took BSD-3-Clause from one that took EPL-2.0. A future release dropping the BSD-3-Clause half returns the decision to the table.

Validation

VAL-GATE-SEC-001 holds the license half. The allowlist gate reads metadata from the installed distributions rather than from a manifest, refuses a resolved dependency matching no row, and resolves OR by accepting on one accepted term. VAL-GATE-DET-001 holds the determinism half, because a run over the in-memory bus is part of the hashed event log and a nondeterministic delivery order changes the hash.

Nothing yet holds the claim that the two adapters implement one contract. That is stated plainly here rather than left implied. Until a shared conformance suite runs against both, the two agree only by construction, and this record is where a later reader learns it was known at the time.