Name the substrate's actual products
The design layer described every service by role and never once by name: Postgres appeared in zero design documents. That was over-application of the research rule "never identify the mesh it observed", which is about node names and domains, not software. Two things were actually broken by it. substrate.lock pins images by digest and a digest belongs to a named image, so the bundle could not be written from the design. And a reader could not tell a settled choice from an unexamined one -- "a relational store" reads identically either way. ADR 0048 names them: PostgreSQL, LavinMQ, MinIO, an OCI registry, Docker. The argument for each is continuity, which is a real argument -- replacing a substrate service migrates the mesh's own state. Role and product are now both written, because the design depends on the protocol while the installer needs the product. Also separates two questions the substrate doc had merged: being substrate and being in the bundle. Only Postgres must precede the control plane; the rest are substrate by role and ordinary by delivery. Whether the bus joins it is left open, because it turns on the control plane's internal shape. Names the forge as Gitea, and records ingress/Traefik as an unclosed gap rather than a naming one -- nothing says what terminates TLS or which tier owns it. Fixes a miscount: the host's bootstrap vocabulary is six shapes, not five.
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-08-27
|
||||||
|
deciders: jochen
|
||||||
|
reconstructed: false
|
||||||
|
extends: 0030-the-repository-structure.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# 48. The substrate is named
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[The substrate](../03-DESIGN/01-to-be/07-the-substrate.md) is defined by a test — *what the
|
||||||
|
control plane consumes and cannot grant itself* — and the design layer describes its members
|
||||||
|
entirely by role: a relational store, a message bus, an object store, an image registry.
|
||||||
|
|
||||||
|
**No design document names a product.** Postgres appears in zero of them. The names occur only
|
||||||
|
in the as-is layer and in research, describing what already runs.
|
||||||
|
|
||||||
|
That is a gap rather than a discipline. The rule it came from —
|
||||||
|
[`01-RESEARCH`](../01-RESEARCH/README.md)'s *research never identifies the mesh it observed* —
|
||||||
|
is about node names and domains, not about software. Nothing is protected by declining to write
|
||||||
|
*Postgres* in a public repository, and something is lost: a design that never names a product
|
||||||
|
does not record that the choice was made.
|
||||||
|
|
||||||
|
Two costs, both already accrued:
|
||||||
|
|
||||||
|
- **`substrate.lock` cannot be written from the design.** It pins images by digest, and a digest
|
||||||
|
belongs to a named image.
|
||||||
|
- **A reader cannot tell a settled choice from an unexamined one.** "A relational store" reads
|
||||||
|
the same whether the store was chosen deliberately or never considered.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The substrate is named, and the names are these:**
|
||||||
|
|
||||||
|
| Role | Product | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| relational store | **PostgreSQL** | In use, understood, and the provisioning model already assumes its notions of database, role and schema. |
|
||||||
|
| message bus | **LavinMQ** | In use, speaks AMQP, which is what [ADR 0001](0001-nodes-communicate-over-a-broker.md) assumes. Interchangeable with other AMQP brokers at the protocol level, which is what makes it a safe choice rather than a locked-in one. |
|
||||||
|
| object store | **MinIO** | In use, speaks the S3 protocol, which is the closest thing to a portable object-store interface. |
|
||||||
|
| image registry | **the OCI distribution registry** | In use, and the format is the standard rather than a vendor's. |
|
||||||
|
| container runtime | **Docker** | In use. Podman is the plausible alternative and was not chosen for any deficiency — Docker is what the machines run today and what the current tooling assumes. |
|
||||||
|
|
||||||
|
### Outside the substrate
|
||||||
|
|
||||||
|
The gap is not only the substrate's. The design layer names roles for these too, and the same
|
||||||
|
correction applies — a role is a legitimate abstraction, but the product belongs beside it:
|
||||||
|
|
||||||
|
| Role, as the design says it | Product | Tier |
|
||||||
|
|---|---|---|
|
||||||
|
| **the forge** | **Gitea** | a hosted workload — the mesh builds from it but does not need it to run |
|
||||||
|
| **the coordinator** | the mesh's own pipeline | tier 2 — part of the control plane, not a product |
|
||||||
|
| **ingress** — *exposure*, *certificates* | **Traefik** | see below |
|
||||||
|
|
||||||
|
**Ingress is a real gap rather than a naming one, and this record does not close it.** The
|
||||||
|
connectivity context lists *exposure* and *certificates* among its responsibilities, and no
|
||||||
|
design document says what terminates TLS, how a route reaches a container, or which tier that
|
||||||
|
belongs to. Traefik is what does it today. Whether it is substrate turns on the same test — can
|
||||||
|
the control plane grant itself a route? — and nobody has applied the test. **Named here so the
|
||||||
|
gap is visible; left open because naming it is not answering it.**
|
||||||
|
|
||||||
|
**Identity is deliberately absent.** Whether an identity provider is substrate at all depends on
|
||||||
|
whether the control plane delegates authentication, which is undecided
|
||||||
|
([`07-the-substrate.md`](../03-DESIGN/01-to-be/07-the-substrate.md)). Naming a product before
|
||||||
|
deciding whether the role exists would be the mistake this record is correcting, in reverse.
|
||||||
|
|
||||||
|
**The role and the product are both written.** A design says *the relational store (PostgreSQL)*
|
||||||
|
rather than one or the other. The role is what the argument turns on; the product is what gets
|
||||||
|
installed, and a reader needs both.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **`substrate.lock` becomes writable.** It pins named images by digest, which was impossible
|
||||||
|
while the design refused to say which images.
|
||||||
|
- **Continuity is the argument, and it is a real one.** Every choice here is what already runs.
|
||||||
|
Nothing was re-litigated, because nothing about the new shape gives a reason to — and changing
|
||||||
|
a substrate service is a migration of the mesh's own state, which is not a cost to pay for
|
||||||
|
novelty.
|
||||||
|
- **Protocols, not products, are what the design depends on.** The bus is reached over AMQP, the
|
||||||
|
object store over S3, the registry over the OCI protocol. Replacing a product is then a
|
||||||
|
substrate migration rather than a redesign — which is the property that makes naming them safe
|
||||||
|
rather than a commitment that cannot be revisited.
|
||||||
|
- **The relational store is the exception**, and it should be said. The provisioning model uses
|
||||||
|
databases, roles and schemas as Postgres means them, and
|
||||||
|
[ADR 0044](0044-a-module-declares-presence-instantiation-and-exclusion.md) already records that
|
||||||
|
two stores from different vendors are not substitutable for a consumer. Replacing it is not a
|
||||||
|
swap.
|
||||||
|
- **The rule that caused this is narrowed, not repealed.** Research still does not identify the
|
||||||
|
mesh it observed — node names, domains, addresses. Product names were never in scope, and the
|
||||||
|
over-application cost the design layer its concreteness.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [`07-the-substrate.md`](../03-DESIGN/01-to-be/07-the-substrate.md) — the test these satisfy.
|
||||||
|
- [ADR 0001](0001-nodes-communicate-over-a-broker.md) — why the bus speaks AMQP.
|
||||||
|
- [ADR 0046](0046-the-installer-fetches-what-it-pins.md) — pinning by digest, which needs a name.
|
||||||
|
- [ADR 0044](0044-a-module-declares-presence-instantiation-and-exclusion.md) — why the store is
|
||||||
|
the one that cannot simply be swapped.
|
||||||
@@ -35,9 +35,9 @@ today, that is a gap in the vocabulary rather than a reason to privilege that sh
|
|||||||
|
|
||||||
## Two classes of scenario
|
## Two classes of scenario
|
||||||
|
|
||||||
The design below describes a scenario as a complete mesh — forge, coordinator, delivery cascade
|
The design below describes a scenario as a complete mesh — forge (Gitea), coordinator,
|
||||||
— because what it tests is a module. **That is the larger of two classes, and not the first one
|
delivery cascade — because what it tests is a module. **That is the larger of two classes, and
|
||||||
built** ([ADR 0029](../../02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md)).
|
not the first one built** ([ADR 0029](../../02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md)).
|
||||||
|
|
||||||
| | **Bootstrap scenario** | **Full scenario** |
|
| | **Bootstrap scenario** | **Full scenario** |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
layer: to-be
|
layer: to-be
|
||||||
status: designed
|
status: designed
|
||||||
code: []
|
code: []
|
||||||
updated: 2026-08-26
|
updated: 2026-08-27
|
||||||
decisions:
|
decisions:
|
||||||
- 02-DECISIONS/0037-the-host-applies-it-does-not-decide.md
|
- 02-DECISIONS/0037-the-host-applies-it-does-not-decide.md
|
||||||
- 02-DECISIONS/0030-the-repository-structure.md
|
- 02-DECISIONS/0030-the-repository-structure.md
|
||||||
@@ -71,15 +71,16 @@ in front of them.
|
|||||||
node except through the host.
|
node except through the host.
|
||||||
- **Not a surface.** Tier 3 is how people and agents reach it. It has one interface; the
|
- **Not a surface.** Tier 3 is how people and agents reach it. It has one interface; the
|
||||||
surfaces are what speak to that interface.
|
surfaces are what speak to that interface.
|
||||||
- **Not the substrate.** It *runs on* tier 1 — a store, a broker, an object store, a registry —
|
- **Not the substrate.** It *runs on* tier 1 — PostgreSQL, LavinMQ, MinIO, an OCI registry
|
||||||
and cannot start without them, which is what makes them a lower tier.
|
([ADR 0048](../../02-DECISIONS/0048-the-substrate-is-named.md)) — and cannot start without
|
||||||
|
them, which is what makes them a lower tier.
|
||||||
- **Not privileged on a node.** It has no more access to a machine than the declaration
|
- **Not privileged on a node.** It has no more access to a machine than the declaration
|
||||||
vocabulary allows ([ADR 0039](../../02-DECISIONS/0039-the-link-is-the-security-boundary.md)).
|
vocabulary allows ([ADR 0039](../../02-DECISIONS/0039-the-link-is-the-security-boundary.md)).
|
||||||
|
|
||||||
## It is also a consumer
|
## It is also a consumer
|
||||||
|
|
||||||
The property that makes tier 2 unlike the others: **the control plane has requirements of its
|
The property that makes tier 2 unlike the others: **the control plane has requirements of its
|
||||||
own.** It needs a database, a broker, and somewhere to keep artifacts — the same things any
|
own.** It needs a PostgreSQL database, an AMQP virtual host, and a bucket — the same things any
|
||||||
module needs, granted the same way.
|
module needs, granted the same way.
|
||||||
|
|
||||||
That is the circularity the tiers exist to resolve rather than hide: the control plane cannot
|
That is the circularity the tiers exist to resolve rather than hide: the control plane cannot
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
layer: to-be
|
layer: to-be
|
||||||
status: designed
|
status: designed
|
||||||
code: []
|
code: []
|
||||||
updated: 2026-08-26
|
updated: 2026-08-27
|
||||||
decisions:
|
decisions:
|
||||||
- 02-DECISIONS/0030-the-repository-structure.md
|
- 02-DECISIONS/0030-the-repository-structure.md
|
||||||
- 02-DECISIONS/0037-the-host-applies-it-does-not-decide.md
|
- 02-DECISIONS/0037-the-host-applies-it-does-not-decide.md
|
||||||
- 02-DECISIONS/0038-a-node-joins-by-linking-first.md
|
- 02-DECISIONS/0038-a-node-joins-by-linking-first.md
|
||||||
|
- 02-DECISIONS/0046-the-installer-fetches-what-it-pins.md
|
||||||
|
- 02-DECISIONS/0047-the-bundle-may-carry-actions-the-link-may-not.md
|
||||||
|
- 02-DECISIONS/0048-the-substrate-is-named.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# The substrate
|
# The substrate
|
||||||
@@ -28,13 +31,25 @@ The test, applied:
|
|||||||
|
|
||||||
| | control plane needs it | can it grant itself one? | |
|
| | control plane needs it | can it grant itself one? | |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| a relational store | its own state lives there | no — provisioning needs the store | **substrate** |
|
| a relational store — **PostgreSQL** | its own state lives there | no — provisioning needs the store | **substrate** |
|
||||||
| a message bus | it reaches nodes over it ([ADR 0001](../../02-DECISIONS/0001-nodes-communicate-over-a-broker.md)) | no — it cannot grant itself a virtual host | **substrate** |
|
| a message bus — **LavinMQ** | it reaches nodes over it ([ADR 0001](../../02-DECISIONS/0001-nodes-communicate-over-a-broker.md)) | no — it cannot grant itself a virtual host | **substrate** |
|
||||||
| an object store | artifacts and blobs it delivers | no — it needs a bucket to hold them | **substrate** |
|
| an object store — **MinIO** | artifacts and blobs it delivers | no — it needs a bucket to hold them | **substrate** |
|
||||||
| an image registry | images it delivers to nodes | no — it needs a repository | **substrate** |
|
| an image registry — **the OCI registry** | images it delivers to nodes | no — it needs a repository | **substrate** |
|
||||||
| an identity provider | only if it delegates authentication | — | **conditional, below** |
|
| an identity provider | only if it delegates authentication | — | **conditional, below** |
|
||||||
| anything else the mesh hosts | no | — | not substrate |
|
| anything else the mesh hosts | no | — | not substrate |
|
||||||
|
|
||||||
|
**The role and the product are both written**, here and everywhere
|
||||||
|
([ADR 0048](../../02-DECISIONS/0048-the-substrate-is-named.md)). The role is what the argument
|
||||||
|
turns on — the test above works on roles, and would give the same answers for a different store.
|
||||||
|
The product is what actually gets installed and pinned, and a design that names only the role
|
||||||
|
does not record that the choice was ever made.
|
||||||
|
|
||||||
|
The dependency is on the **protocol**, not the product: AMQP for the bus, S3 for the object
|
||||||
|
store, the OCI protocol for the registry. That is what keeps the naming safe rather than a
|
||||||
|
commitment that cannot be revisited — replacing one is a substrate migration, not a redesign.
|
||||||
|
The store is the exception, and the exception matters: the provisioning model uses databases,
|
||||||
|
roles and schemas as PostgreSQL means them, so it is the one member that is not a swap.
|
||||||
|
|
||||||
## What that resolves
|
## What that resolves
|
||||||
|
|
||||||
**Four or five?** [Research 006](../../01-RESEARCH/006-mesh-from-scratch/00-overview.md) asks
|
**Four or five?** [Research 006](../../01-RESEARCH/006-mesh-from-scratch/00-overview.md) asks
|
||||||
@@ -67,8 +82,24 @@ cannot obtain it* is.
|
|||||||
|
|
||||||
## The pinned bundle
|
## The pinned bundle
|
||||||
|
|
||||||
The substrate is what `substrate.lock` contains, and this is the only place in the mesh where
|
`substrate.lock` holds **what must exist before the control plane runs** — which is a smaller
|
||||||
versions are pinned by hand rather than resolved.
|
set than the substrate, and the difference is easy to miss. It is the only place in the mesh
|
||||||
|
where versions are pinned by hand rather than resolved.
|
||||||
|
|
||||||
|
Being substrate and being in the bundle are two different questions:
|
||||||
|
|
||||||
|
| | is it substrate? | must it precede the control plane? |
|
||||||
|
|---|---|---|
|
||||||
|
| PostgreSQL | yes — the control plane's own state lives in it | **yes** — there is nowhere to put that state otherwise |
|
||||||
|
| LavinMQ | yes — it cannot grant itself a virtual host | **not established** — see below |
|
||||||
|
| MinIO | yes — it cannot grant itself a bucket | no — nothing is delivered before the mesh exists |
|
||||||
|
| the OCI registry | yes — it cannot grant itself a repository | no — the first node fetches upstream ([ADR 0046](../../02-DECISIONS/0046-the-installer-fetches-what-it-pins.md)) |
|
||||||
|
|
||||||
|
The three on the bottom rows are **substrate by role and ordinary by delivery**: by the time
|
||||||
|
they are wanted there is a control plane, and it provisions them the way it provisions anything.
|
||||||
|
That keeps the bundle to roughly one image rather than four, which is what makes it small enough
|
||||||
|
for the review [ADR 0047](../../02-DECISIONS/0047-the-bundle-may-carry-actions-the-link-may-not.md)
|
||||||
|
requires.
|
||||||
|
|
||||||
**Why pinned:** the bundle is applied when no mesh exists, so nothing can resolve a version, ask
|
**Why pinned:** the bundle is applied when no mesh exists, so nothing can resolve a version, ask
|
||||||
a registry, or check a constraint. What the host carries must already be exact.
|
a registry, or check a constraint. What the host carries must already be exact.
|
||||||
@@ -85,17 +116,21 @@ is what keeps the bundle small enough for a person to read and check.
|
|||||||
The order, from [research 011](../../01-RESEARCH/011-the-module-graph/worked-provider.md):
|
The order, from [research 011](../../01-RESEARCH/011-the-module-graph/worked-provider.md):
|
||||||
|
|
||||||
```
|
```
|
||||||
0 a container runtime exists detected, or installed as a package
|
0 Docker exists detected, or installed as a package
|
||||||
1 the store runs pulled by digest, from the bundle
|
1 PostgreSQL runs pulled by digest, from the bundle
|
||||||
2 a database is created in it an action, run locally
|
2 a database is created in it an action, run locally
|
||||||
3 the control plane's schema applied an action, against that database
|
3 the control plane's schema applied an action, against that database
|
||||||
4 the control plane starts and only now is there a mesh
|
4 the control plane starts and only now is there a mesh
|
||||||
5 everything else is provisioned the ordinary path
|
5 LavinMQ, MinIO, the registry, and the ordinary path
|
||||||
|
everything else are provisioned
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only PostgreSQL is raised from the bundle, for the reason in *The pinned bundle* above — the
|
||||||
|
rest of the substrate is wanted only once there is a control plane to provision it.
|
||||||
|
|
||||||
**Step 0 is easy to leave out and it is where several things meet.** A substrate service is a
|
**Step 0 is easy to leave out and it is where several things meet.** A substrate service is a
|
||||||
container, so something must run containers before anything else happens — and a container
|
container, so Docker must be running before anything else happens — and Docker is a *package*,
|
||||||
runtime is a *package*, not a container. It is:
|
not a container. It is:
|
||||||
|
|
||||||
- what the host's capability detection already reports, and the first use of that report by
|
- what the host's capability detection already reports, and the first use of that report by
|
||||||
something other than a person;
|
something other than a person;
|
||||||
@@ -104,7 +139,7 @@ runtime is a *package*, not a container. It is:
|
|||||||
- a package, which needs the machine's own package manager and a network — both permitted by
|
- a package, which needs the machine's own package manager and a network — both permitted by
|
||||||
[ADR 0046](../../02-DECISIONS/0046-the-installer-fetches-what-it-pins.md).
|
[ADR 0046](../../02-DECISIONS/0046-the-installer-fetches-what-it-pins.md).
|
||||||
|
|
||||||
So the host's bootstrap vocabulary is five shapes: **package**, **container**, **file**,
|
So the host's bootstrap vocabulary is six shapes: **package**, **container**, **file**,
|
||||||
**directory**, **service**, and **action**. Files, directories and services exist; the rest do
|
**directory**, **service**, and **action**. Files, directories and services exist; the rest do
|
||||||
not yet.
|
not yet.
|
||||||
|
|
||||||
@@ -117,6 +152,14 @@ host's vocabulary grows by one shape rather than by one resource type per substr
|
|||||||
## Open
|
## Open
|
||||||
|
|
||||||
- **Whether identity is the fifth.** Above; it follows from a decision not yet taken.
|
- **Whether identity is the fifth.** Above; it follows from a decision not yet taken.
|
||||||
|
- **Whether the bus must precede the control plane.** The bundle table marks this *not
|
||||||
|
established*, and it is the one row that could still move. The control plane reaches nodes over
|
||||||
|
AMQP, but at step 4 there is exactly one node and it is the local machine — so whether LavinMQ
|
||||||
|
is needed to *start* or only to *reach a second node* depends on whether the control plane's own
|
||||||
|
contexts talk to each other over the bus. If they do, LavinMQ joins PostgreSQL in the bundle and
|
||||||
|
the bootstrap grows a step; if they do not, it is provisioned like anything else. **This is a
|
||||||
|
question about the control plane's internal shape, not about the substrate**, which is why it is
|
||||||
|
not answered here.
|
||||||
- ~~**Whether the host can do step 2.**~~ **Resolved** by
|
- ~~**Whether the host can do step 2.**~~ **Resolved** by
|
||||||
[ADR 0047](../../02-DECISIONS/0047-the-bundle-may-carry-actions-the-link-may-not.md). A service
|
[ADR 0047](../../02-DECISIONS/0047-the-bundle-may-carry-actions-the-link-may-not.md). A service
|
||||||
running on this machine is part of this machine, so the scope was never in question — the real
|
running on this machine is part of this machine, so the scope was never in question — the real
|
||||||
|
|||||||
Reference in New Issue
Block a user