Files
hq/03-DESIGN/01-to-be/23-choosing-a-provider.md
jschoubben ce6ae943b7 Merge main: renumber this branch's records around the trunk's
Both lines of work numbered from the same point, so four decision records and one design
document existed twice with different content. The trunk keeps its numbers and this branch
yields — the only rule that scales, because the trunk's are already cited by what merged
before them.

  0117 the bus is the only broker        -> 0125
  0118 a module declares its own seats   -> 0126
  0119 amqp is a provision, not the bus  -> 0127
  0120 the mesh bus is required          -> 0128
  0123 a seat carries its role's protocol -> 0129
  0124 the predecessor is ending          -> 0130
  design 29, what a module declares       -> design 32

Applied to the code repositories too, because a stale reference is worse when numbers
collide than when they dangle: the reader lands on a real record that decided something
else.

Two reconciliations the merge forced, both real:

**0110 was marked wholly superseded and was not.** Its successor says in as many words that
everything 0110 decided about what a seat *is* stands untouched — and two records that
landed on the trunk rest on exactly that part. So it is accepted again, extended rather than
replaced, with a note saying which of its claims moved and where.

**A seat's protocol becomes columns, not fields.** The trunk moved the seat set out of
compiled code into a table the controller owns. This branch had added what a role accepts,
emits and serves to the Go slice. The decision is unaffected and the mechanism is better for
it: giving a role a protocol is now a write rather than a rebuild, which is the trunk's own
argument applied to what this branch added.

One check still fails and it fails on main too: a record resting on ADR 0112 while that is
still 'proposed'. Left alone — it is not this merge's to answer.
2026-09-27 18:23:41 +02:00

96 lines
5.9 KiB
Markdown

---
layer: to-be
status: designed
code: []
updated: 2026-09-25
decisions:
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md
- 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
---
# 23 — Choosing a provider
A provision is named for what the consumer's code is coupled to
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)):
`postgres-database`, not `database`. That decides *what kind* of provider satisfies a requirement.
It does not decide *which* provider, and the mesh runs more than one of most kinds.
## Why there is a choice at all
Node-specific services delivered to the mesh is the design, not an exception. Every control-capable
node runs its own relational store, its own cache, its own object store; a single node may run
several relational stores, each raised by the module that needed a particular engine or version.
The one provision that is single today — the identity provider — already serves applications whose
home is another node. So for a given provision name there are usually several providers, one per
node, and they are **not** interchangeable: each holds different data and lives in a different
place. A consumer bound to the wrong one reads the wrong database or takes a network hop it did not
need.
Naming the kind is therefore only half of "how a consumer gets what it needs". The other half is
which provider, and it has two shapes: **consume a provider**, or **carry your own**.
## Consuming a provider
A provider is not a mesh-wide singleton. It is identified by the node it runs on together with the
module that provides it — a (node, module) pair. A consumer's requirement resolves to one such
provider, and which one is part of the **assignment**, not the manifest
([ADR 0046](../../02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md)):
the same module, assigned twice, may be served by two different providers.
**The default is co-location.** A consumer that names no provider is served by the provider of that
provision on its own node. This is the ordinary case and is meant to need nothing said — a module
that wants a database wants, almost always, the database on the machine it runs on. A mesh that
happens to run exactly one provider of a kind is simply the case where co-location and "the only
one there is" name the same thing; that is *there happens to be one*, not a mesh-wide scope written
into the provision.
**Coupling to data is named.** The exception to co-location is a consumer coupled to a *particular
provider's contents*: two modules that must share one database, or a consumer that must reach a
provider on a different node. That coupling is exactly what may not be guessed, so the assignment
names the provider. Naming it is also what makes a later move safe — the mesh knows the binding is
to that provider and not to whichever one is nearest.
**A seat names the mesh's one provider of a kind.** Where a seat delivers the provision, its holder
answers for it when several providers exist and the consumer named none. That is not picking: the
choice was made once, mesh-wide, by assigning the holder, rather than once per consumer by naming it
([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)),
[26 — The seats](26-the-seats.md)). A named provider still wins over the seat, because a consumer
coupled to particular contents has said so.
**Ambiguity is refused, never resolved by picking.** If several providers of a kind exist, none is
named, none is co-located, and no seat delivers it, the requirement is unsatisfiable and is refused
with the candidates shown — the same stance
[ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md) took
against a confidently-wrong match, applied to the instance rather than the dialect. A wrong answer
delivered quietly costs more than a refusal.
## Carrying your own
A module need not consume a provider at all. It may carry its **own** instance of an engine inside
its own composition — reachable only on the module's own network, publishing no host port, and
**not** declared as a provision. Nothing else in the mesh can see it or bind to it, and it cannot
collide with anything on a well-known port. To resolution it does not exist; it is an internal part
of the module, like any other container the module runs.
This is legitimate but it is the exception, and the design says when: **only when a genuine engine
fork or a pinned server version makes the shared provider unusable.** A module written against a
customised engine, or one that needs an extension the node's provider does not carry, has no choice
but to carry its own. A module that merely pins an old image of an ordinary engine does not — the
version on a compose file is the *server's*, and the application talks to a newer shared server
perfectly well once its data is migrated in. The rule is *share by default; embed only when a fork
or a version forces it*. Most of the per-module stores that exist in the mesh being migrated onto
are the first kind wearing the second's clothes, and consolidate onto the node's provider.
The distinction is worth stating because the two cases look identical from outside — a module with
a database either way — and the mesh must be able to tell them apart to reason about either. A
consumed provider is a binding the mesh records, rotates and can move. An embedded instance is a
private detail the mesh does not manage and must not mistake for a provider.
## What is not settled here
A provider that moves between nodes must keep its identity, so that a consumer's recorded choice
does not silently rebind to a different provider that inherited its place. That is a property the
provider lifecycle must supply, and this document names it as a requirement rather than describing
its mechanism.