Skip to content

feat(messaging): a NATS transport, so the bus has a client the fleet shares - #15

Merged
crypto-a merged 1 commit into
mainfrom
14/nats-transport
Sep 7, 2026
Merged

feat(messaging): a NATS transport, so the bus has a client the fleet shares#15
crypto-a merged 1 commit into
mainfrom
14/nats-transport

Conversation

@crypto-a

@crypto-a crypto-a commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #14. NATS JetStream is live on compliance-engine and nothing could talk to it, because no service had a client.

The argument for it being here at all

This arrived on its first copy, not its third, so it is worth stating what carries it rather than waving it through.

It is the kit.clients case: not an abstraction over a domain, but the ack policy, the redelivery behaviour, the reconnection settings and the header convention. Those are wrong in the same way in every service that decides them alone, and they are wrong invisibly, found during an incident with a consumer that has been quietly reprocessing or quietly skipping.

What it refuses to hold is the half that keeps that honest:

Holds Refuses
Connect, publish, pull-subscribe What events exist, and their fields
Ack, nak, term Any service's domain types
Reconnection, metrics, readiness Stream and consumer creation
The four headers Schemas, versioning, a registry

The payload is opaque bytes. Streams are not created here either: retention and replica counts are operational decisions, and a library that makes them makes them once, wrongly, in production, because the call is idempotent and the second caller's arguments are ignored.

One design decision worth reviewing closely

The event id rides as Nats-Msg-Id, not a name of ours. That is JetStream's own deduplication header: the server refuses a second publish carrying an id it has already seen inside the stream's duplicate window.

Using Scadable-Event-Id would have looked tidier and left that mechanism switched off, pushing duplicate suppression entirely onto consumers. This way publishing is idempotent inside the window for free, and past it, it is still the id a consumer dedupes on.

At-least-once, said plainly

fetch returns unsettled messages; settling is an explicit call. Nothing acknowledges on the caller's behalf, because an auto-acking API turns at-least-once into at-most-once at the exact moment a handler raises.

Two consequences that are deliberate:

  • An unreadable message is terminated, not naked. It will be malformed next time too, so a nak would spend the whole attempt budget rediscovering that. One poisonous message also does not cost the rest of its batch.
  • A failed settle is logged and swallowed. The work happened; only the acknowledgement did not, so the message comes back and the handler's idempotency covers it. Raising would tell a caller its work failed, which is worse than the truth.

The gates this had to pass

  • nats-py is an optional extra, for the reason telemetry is: a dependency here is one all ten repos inherit and cannot refuse. The import is function-local, CI's minimal-install job now asserts kit.messaging imports without it, and I verified the package imports with neither extra present. Connecting without it raises a BrokerUnavailable naming the extra.
  • 100% branch coverage, against a stub connection rather than a server, using the same seam service_client(transport=...) uses.
  • pyright strict: nats-py ships no type information, so the module is narrowed to Any at the boundary exactly as _metrics narrows the OTel SDK, keeping strictness on our code rather than turning the setting off.
  • Metrics label the stream, never the subject. kit's own metrics docstring warns one label carrying a tenant id turns one series into millions, and a subject carries one in every useful design. The four instruments are registered in _build(), without which record() would have dropped every one silently.

A version bug fixed on the way past

pyproject.toml and kit.__version__ both said 0.6.1 at the commit tagged v0.6.2, so api, harness and dispatcher have been logging the wrong version at boot, which defeats that field's whole purpose. test_version.py never caught it because it compares the source to the installed metadata and both derive from the same file.

Both go to 0.7.0 here.

After this merges

I cut the v0.7.0 tag. brain is pinned at v0.5.2, so that is a two-minor jump, and v0.5.2..v0.6.2 moved install_conventions, so it is not purely additive.

…shares

NATS JetStream is live on compliance-engine and nothing could talk to it,
because no service had a client. This is that client, as transport policy
rather than as an abstraction over anything.

ADMITTED ON THE kit.clients ARGUMENT, and it arrived on its first copy rather
than its third, so it is worth stating what carries it. This is not an
abstraction over a domain: it is the ack policy, the redelivery behaviour, the
reconnection settings and the header convention, and those are wrong in the
same way in every service that decides them alone. They are also wrong
invisibly, and are found during an incident, with a consumer that has been
quietly reprocessing or quietly skipping.

WHAT IT REFUSES TO HOLD is the half that keeps that honest. The payload is
opaque bytes: no schema, no event registry, no versioning, because what an
event IS belongs to the services that agree on it. It creates no streams and
no consumers either, since retention and replica counts are operational
decisions and a library that makes them makes them once, wrongly, in
production.

THE FOUR HEADERS ARE THE JUDGEMENT CALL. They are in because they are how
tenancy and idempotency travel: without a fixed convention two services
disagree about where the tenant id lives, and a redelivered message cannot be
recognised as a duplicate. The event id rides as Nats-Msg-Id rather than a
name of ours, which is not cosmetic: that is the header JetStream itself
deduplicates on, so publishing is idempotent inside the stream's window for
free instead of leaving the whole problem to consumers.

AT LEAST ONCE, SAID PLAINLY. fetch returns unsettled messages and settling is
an explicit ack, nak or term. Nothing acknowledges on the caller's behalf,
because an auto-acking API turns at-least-once into at-most-once at the exact
moment a handler raises. A message whose headers cannot be read is terminated
rather than naked: it will be malformed next time too, and a nak would spend
the whole attempt budget rediscovering that.

A failed settle is logged and swallowed. The work happened; only the
acknowledgement did not, so the message comes back and the handler's
idempotency covers it. Raising there would tell a caller its work failed,
which is worse than the truth.

nats-py is an OPTIONAL EXTRA, for the reason telemetry is: a dependency here
is one all ten repositories inherit and cannot refuse. The import is function
local, and CI's minimal-install job now asserts kit.messaging imports without
it. Connecting without the extra raises a BrokerUnavailable naming the extra,
which is a failure a service that forgot can actually read.

Metrics label the STREAM, never the subject. kit's own metrics docstring warns
that one label carrying a tenant id turns one series into millions, and a
subject carries one in every useful design. The four new instruments are
registered in _build, without which record() would have dropped every one of
them silently.

Version goes to 0.7.0, which also fixes a real bug: pyproject and
kit.__version__ both said 0.6.1 at the commit tagged v0.6.2, so three services
pinned to v0.6.2 logged the wrong version at boot, defeating the field's whole
purpose. test_version compares the source to the installed metadata, and both
derive from the same file, so it never caught it.

Gate green: ruff, pyright strict, 295 tests, 100% branch coverage, and the
package imports with neither extra installed.

Closes #14
@crypto-a
crypto-a merged commit 518a150 into main Sep 7, 2026
5 checks passed
@crypto-a
crypto-a deleted the 14/nats-transport branch September 7, 2026 17:01
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.

A NATS transport, so the bus has a client the whole fleet shares

1 participant