Reconcile: adopt initialization's consolidated HQ as canonical, re-home this session's new work #24
@@ -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
|
||||
|
||||
The design below describes a scenario as a complete mesh — forge, coordinator, delivery cascade
|
||||
— because what it tests is a module. **That is the larger of two classes, and not the first one
|
||||
built** ([ADR 0029](../../02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md)).
|
||||
The design below describes a scenario as a complete mesh — forge (Gitea), coordinator,
|
||||
delivery cascade — because what it tests is a module. **That is the larger of two classes, and
|
||||
not the first one built** ([ADR 0029](../../02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md)).
|
||||
|
||||
| | **Bootstrap scenario** | **Full scenario** |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: []
|
||||
updated: 2026-08-26
|
||||
updated: 2026-08-27
|
||||
decisions:
|
||||
- 02-DECISIONS/0037-the-host-applies-it-does-not-decide.md
|
||||
- 02-DECISIONS/0030-the-repository-structure.md
|
||||
@@ -71,15 +71,16 @@ in front of them.
|
||||
node except through the host.
|
||||
- **Not a surface.** Tier 3 is how people and agents reach it. It has one interface; the
|
||||
surfaces are what speak to that interface.
|
||||
- **Not the substrate.** It *runs on* tier 1 — a store, a broker, an object store, a registry —
|
||||
and cannot start without them, which is what makes them a lower tier.
|
||||
- **Not the substrate.** It *runs on* tier 1 — PostgreSQL, LavinMQ, MinIO, an OCI registry
|
||||
([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
|
||||
vocabulary allows ([ADR 0039](../../02-DECISIONS/0039-the-link-is-the-security-boundary.md)).
|
||||
|
||||
## It is also a consumer
|
||||
|
||||
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.
|
||||
|
||||
That is the circularity the tiers exist to resolve rather than hide: the control plane cannot
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
layer: to-be
|
||||
status: designed
|
||||
code: []
|
||||
updated: 2026-08-26
|
||||
updated: 2026-08-27
|
||||
decisions:
|
||||
- 02-DECISIONS/0030-the-repository-structure.md
|
||||
- 02-DECISIONS/0037-the-host-applies-it-does-not-decide.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
|
||||
@@ -28,13 +31,25 @@ The test, applied:
|
||||
|
||||
| | control plane needs it | can it grant itself one? | |
|
||||
|---|---|---|---|
|
||||
| a relational store | 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** |
|
||||
| an object store | 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** |
|
||||
| a relational store — **PostgreSQL** | its own state lives there | no — provisioning needs the store | **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 — **MinIO** | artifacts and blobs it delivers | no — it needs a bucket to hold them | **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** |
|
||||
| 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
|
||||
|
||||
**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 substrate is what `substrate.lock` contains, and this is the only place in the mesh where
|
||||
versions are pinned by hand rather than resolved.
|
||||
`substrate.lock` holds **what must exist before the control plane runs** — which is a smaller
|
||||
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
|
||||
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):
|
||||
|
||||
```
|
||||
0 a container runtime exists detected, or installed as a package
|
||||
1 the store runs pulled by digest, from the bundle
|
||||
0 Docker exists detected, or installed as a package
|
||||
1 PostgreSQL runs pulled by digest, from the bundle
|
||||
2 a database is created in it an action, run locally
|
||||
3 the control plane's schema applied an action, against that database
|
||||
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
|
||||
container, so something must run containers before anything else happens — and a container
|
||||
runtime is a *package*, not a container. It is:
|
||||
container, so Docker must be running before anything else happens — and Docker is a *package*,
|
||||
not a container. It is:
|
||||
|
||||
- what the host's capability detection already reports, and the first use of that report by
|
||||
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
|
||||
[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
|
||||
not yet.
|
||||
|
||||
@@ -117,6 +152,14 @@ host's vocabulary grows by one shape rather than by one resource type per substr
|
||||
## Open
|
||||
|
||||
- **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
|
||||
[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
|
||||
|
||||
Reference in New Issue
Block a user