From cbcbba8099a916a28a6dc79c4bf341d207f64bfb Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 17:13:07 +0200 Subject: [PATCH] A provision names its engine; the substrate supplies only the control plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **0027 — provisions.** A module written against PostgreSQL could be matched to a provider of SQL Server, resolve as satisfied, and fail on its first query. The name said the role, so nothing distinguished engines. Refusing on ambiguity could not help: with one provider of each name nothing is ambiguous. Enforced at parse rather than documented, because the old naming was the documentation. **0028 — the substrate.** 0006 admits an object store on the grounds that it cannot grant itself a bucket. That answers the second half of the test and assumes the first: the control plane does not need one. Verified — no S3 client in mesh-control, and internal/builder/registry.go records the deliberate choice to put artifacts in the OCI registry as content-addressed blobs. The row was inherited from the system being replaced, where an object store distributed module tarballs, and was never re-tested against the definition above it. So an object store is an ordinary module, and a mesh with nothing needing one runs none. Migrating it is module work, not substrate work. 0028 also states what 0006 left unsaid: a substrate service and a module of the same product are different instances. The substrate is raised from the bundle before any mesh exists, so it is not in the module graph — a workload depending on it would depend on something the graph cannot see, cannot rotate a credential for, and cannot move, and would put workload data in the store the control plane keeps its own state in. Both records were found by reading code against design rather than design against itself, which is the review that should have happened sooner. --- ...n-names-what-the-consumer-is-coupled-to.md | 109 ++++++++++++++++ ...lies-the-control-plane-and-nothing-else.md | 118 ++++++++++++++++++ 02-DECISIONS/README.md | 2 + 03-DESIGN/01-to-be/07-the-substrate.md | 23 +++- 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md create mode 100644 02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md diff --git a/02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md b/02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md new file mode 100644 index 0000000..6e2bce4 --- /dev/null +++ b/02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md @@ -0,0 +1,109 @@ +--- +topic: what runs on it +status: accepted +date: 2026-08-31 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0009-modules-and-the-graph.md +--- + +# 27. A provision names what the consumer is coupled to, not the role it plays + +## Context + +Provisions are named after roles. The catalogue and every test fixture built so far say: + +``` +provides: database +requires: database +``` + +**Nothing distinguishes one engine from another.** A module requiring `database` is satisfied by +any module providing `database`, so a module written against PostgreSQL can be matched to a +provider of Microsoft SQL Server, resolve as satisfied, deploy, and fail on its first query. + +**The mesh runs several engines** — PostgreSQL, Microsoft SQL Server, MariaDB, and others behind +products that expose their own. This is not a hypothetical collision. + +**The failure is in the direction that hides.** Resolution *succeeds*. Nothing is refused, nothing +is logged, and the breakage surfaces later as an error inside an application, on a machine, with +nothing connecting it back to a match made elsewhere by something that thought it had done its +job. **A wrong answer delivered confidently costs more than a refusal**, and the whole point of +refusing on ambiguity ([ADR 0009](0009-modules-and-the-graph.md)) was to not do this. + +**How it got in:** every test written for the resolver had exactly one provider of each name, so no +mismatch was expressible and none was caught. The fixtures agreed with the design. That is the same +fault as [`04-ISSUES/005`](../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md)'s +imagined output and [`019`](../04-ISSUES/019-a-comment-asserting-a-fact-about-a-machine/00-report.md)'s +unchecked comment, at the level of a name rather than a line. + +## Considered Options + +1. **Keep role names; let the operator assign correctly.** The mesh would refuse ambiguity when two + providers exist, so a person picks. **Rejected.** It makes correctness depend on somebody + knowing that the module they are assigning speaks a particular dialect — which is exactly the + knowledge the provisioning model exists to remove. And with one provider of each name, nothing + is ambiguous and nothing is asked. + +2. **A role name plus a `flavour:` or `engine:` qualifier**, matched as a second field. + **Rejected.** Two fields that must agree is a constraint the resolver has to enforce and a + manifest author has to remember, to express something one field already can. The name is the + contract; splitting it invites a requirement that names a role and forgets the qualifier, which + then matches everything again. + +3. **The name says what the consumer is coupled to.** **Adopted.** + +## Decision + +**A provision is named for the thing a consumer's code is written against.** + +``` +provides: postgres-database +requires: postgres-database +``` + +**The test is whether the consumer can tell the difference.** If swapping the provider would break +the consumer, the name must say which provider — because a match that breaks the consumer is not a +match. If the consumer genuinely cannot tell, a role name is correct and better. + +| provision | | why | +|---|---|---| +| `postgres-database`, `mssql-database` | **specific** | applications are written against a dialect; a swap breaks them | +| `route` | **role** | the consumer wants its name reachable and does not care what proxies it | +| `resolver` | **role** | the consumer wants names to resolve | +| `artifact-store` | **role** | the consumer fetches by digest over a protocol, and nothing else | + +**`database` is not a provision and may not be provided.** There is no context in which an +application talks to a generic database: it talks to PostgreSQL or it talks to SQL Server. A name +that cannot be true of any real consumer should not be expressible. + +**This is about coupling, not about products.** Two providers of `postgres-database` — a container +on this node and a managed instance elsewhere — are interchangeable and *should* both match. What +may not be interchangeable is what the consumer's queries are written in. + +## Consequences + +**Every manifest that names a database changes.** Doing this now costs a rename across a handful of +examples. Doing it after modules are migrated costs it across all of them, plus every deployment +that resolved against the old name. + +**Wrong requirements now fail loudly, and at the right moment.** A module requiring +`postgres-database` where only `mssql-database` is provided is unsatisfiable, so it is **refused at +resolution** with both names visible — rather than deployed and broken later. This is the property +that was lost, restored. + +**Generic role names are still right, and the rule says when.** This does not push specificity +everywhere; it puts it exactly where a consumer is coupled. Naming `route` after a particular proxy +would be the same error in the other direction, and would prevent a swap that genuinely changes +nothing. + +**It is checked, not merely stated** ([`00-META/how-we-build.md`](../00-META/how-we-build.md) §5). +A manifest providing a name known to be engine-generic is refused, naming what to say instead. +Without that, this record is a convention, and a convention is what the previous naming was. + +## References + +- [ADR 0009](0009-modules-and-the-graph.md) — provisions, and refusing on ambiguity +- [`03-DESIGN/01-to-be/07-the-substrate.md`](../03-DESIGN/01-to-be/07-the-substrate.md) — *the + provisioning model uses databases, roles and schemas as PostgreSQL means them*, which is this + record's point made about the substrate before it was made about modules diff --git a/02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md b/02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md new file mode 100644 index 0000000..0b6bc06 --- /dev/null +++ b/02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md @@ -0,0 +1,118 @@ +--- +topic: the tiers +status: accepted +date: 2026-08-31 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0006-the-substrate-and-the-control-plane.md +--- + +# 28. The substrate supplies the control plane and nothing else + +*Corrects one row of [ADR 0006](0006-the-substrate-and-the-control-plane.md) and makes explicit +something it left unsaid. The rest of that record stands.* + +## Context + +ADR 0006 defines the substrate by a circularity: **what the control plane needs in order to run, +and cannot ask itself for, because it is not running yet.** Two questions, and both must be +answered *yes* for something to be substrate. + +Its membership table admits the object store on this line: + +| role | product | | +|---|---|---| +| object store | **MinIO** | it cannot grant itself a bucket | + +**That answers the second question and assumes the first.** It is true that a control plane cannot +grant itself a bucket. Nothing establishes that it needs one. + +**It does not.** Verified 2026-08-31 against `mesh-control`: no S3 client, no bucket, no object +storage of any kind outside comments. Artifacts reach nodes as content-addressed blobs in the OCI +registry, and the code records the decision and its reasoning: + +> One store, and it is the registry the bootstrap already pulls from. An OCI registry is a +> content-addressed blob store that happens to also understand images… The alternative considered +> was a second store beside it — S3-shaped, buckets, signed URLs. It is the right answer for +> objects that are *mutable*, or need per-reader access, or are not build output. None of that +> describes a digest-pinned archive, and standing up a second service to hold one kind of +> immutable blob means two things to run, two things to back up and two ways for an artifact to be +> missing. + +**The row is inherited from the system being replaced**, where an object store distributed module +tarballs. Here nothing does, and the row was never re-tested against the definition it sits under. + +**A second thing ADR 0006 never says:** whether a substrate service and a module of the same +product are the same instance. It says the substrate is *not the control plane* and *not a place +for logic*, and stops. The question is not idle — an application wanting a database, on a mesh +whose substrate is already running PostgreSQL, has an obvious wrong answer available. + +## Considered Options + +1. **Leave the object store as substrate, unused.** Harmless-looking. **Rejected.** A membership + list that includes something nothing needs is a list that has stopped being derived from its + test, and the next member is admitted by precedent instead of argument. It also mandates that + every mesh run a service no mesh uses. + +2. **Applications share the substrate's instances.** One PostgreSQL, one of everything. + **Rejected**, below. + +3. **The substrate is exactly what the control plane consumes; everything else is a module.** + **Adopted.** + +## Decision + +**The object store is not substrate.** It fails the first half of the test: the control plane does +not need one. An object store is an ordinary module, required through the module graph like +anything else, and a module wanting one depends on a module providing one. + +**The substrate has four members, not five**: a relational store, a message bus, an image registry, +and conditionally an identity provider. The registry stays — the control plane genuinely cannot +deliver an artifact without somewhere to put it. + +**A substrate service and a module of the same product are different instances, and are not +shared.** The mesh's own PostgreSQL and a PostgreSQL a workload was given are two servers, two +containers, two lifecycles. + +Three reasons, and the first is the one that matters: + +**The substrate is not in the module graph.** It is raised from the pinned bundle the host carries, +before any mesh exists to declare it. A workload depending on it would depend on something the +graph cannot see, cannot rotate a credential for, and cannot move — which is every property the +provisioning model exists to provide. + +**It would put workload data in the control plane's own store.** The mesh's contexts own their +stores exclusively ([ADR 0008](0008-a-context-owns-its-store.md)). An application sharing that +server can exhaust it, lock it, or fill its disk, and the failure is the control plane going down +— which is the one failure that makes every other one harder to fix. + +**They are bounded differently.** The substrate is sized, backed up and upgraded as part of +bootstrapping a mesh. A workload's database follows the workload — moved with it, destroyed with +it, restored with it. + +## Consequences + +**Migrating an object store is ordinary module work**, not substrate work. It was previously going +to be done as part of completing the substrate, which would have been the wrong shape and would +have coupled every mesh to a service the mesh does not use. + +**A mesh with no workload needing one runs no object store at all.** That is the correct outcome +and was not previously available. + +**Two PostgreSQL containers on a node that hosts both is expected**, not duplication to be +optimised away. Anyone tidying them together should find this record first. + +**"Substrate by role and ordinary by delivery" loses one of its two members.** ADR 0006 uses that +phrase of the object store and the registry — things that are substrate but provisioned once a +control plane exists. It now describes the registry alone. + +**The definition is applied, not just stated.** Both halves of the circularity test are asked of +each member, and *cannot grant itself one* is not sufficient on its own — it is true of almost any +service, which is what made it possible to admit a member on that half alone. + +## References + +- [ADR 0006](0006-the-substrate-and-the-control-plane.md) — the definition, and the table this + corrects one row of +- [ADR 0008](0008-a-context-owns-its-store.md) — a context owns its store exclusively +- `mesh-control internal/builder/registry.go` — where artifacts go, and why not S3 diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 434e363..ae17da5 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -92,6 +92,7 @@ python3 00-META/checks/index.py fail if stale - **0006** — [The substrate and the control plane](0006-the-substrate-and-the-control-plane.md) - **0007** — [Connectivity](0007-connectivity.md) - **0008** — [A context owns its store, exclusively](0008-a-context-owns-its-store.md) +- **0028** — [The substrate supplies the control plane and nothing else](0028-the-substrate-supplies-the-control-plane-and-nothing-else.md) ### What runs on them, and how it gets there @@ -99,6 +100,7 @@ python3 00-META/checks/index.py fail if stale - **0010** — [Delivery](0010-delivery.md) - **0024** — [Model access is a provision, and a licence is a thing with a name](0024-model-access-is-a-provision.md) *(proposed)* - **0026** — [The mesh has a session of its own, and it is the node session's mechanism](0026-the-mesh-has-a-session-of-its-own.md) +- **0027** — [A provision names what the consumer is coupled to, not the role it plays](0027-a-provision-names-what-the-consumer-is-coupled-to.md) ### How it is built diff --git a/03-DESIGN/01-to-be/07-the-substrate.md b/03-DESIGN/01-to-be/07-the-substrate.md index 9c1beab..14601d3 100644 --- a/03-DESIGN/01-to-be/07-the-substrate.md +++ b/03-DESIGN/01-to-be/07-the-substrate.md @@ -36,12 +36,22 @@ The test, applied: |---|---|---|---| | 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 0002](../../02-DECISIONS/0002-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 object store~~ | ~~artifacts and blobs it delivers~~ | — | **not substrate** — [ADR 0028](../../02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md) | | 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** | | ingress — **Traefik** | not to start; only to be reached by name | — it grants itself one afterwards | **not substrate** ([ADR 0007](../../02-DECISIONS/0007-connectivity.md)) | | anything else the mesh hosts | no | — | not substrate | +*The object-store row was wrong, and how it was wrong is worth keeping.* It answered *can it +grant itself one* — no, it cannot grant itself a bucket — while assuming the first column. **The +control plane does not need an object store**: it has no S3 client and never has, and artifacts +reach nodes as content-addressed blobs in the registry. The row was inherited from the system being +replaced, where an object store distributed module tarballs, and was never re-tested against the +definition above it. *Both columns must be answered, and the second is true of almost any service.* + +**An object store is an ordinary module**, required through the module graph by whatever wants one. +A mesh with no workload needing one runs none. + **The role and the product are both written**, here and everywhere ([ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.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. @@ -83,6 +93,17 @@ cannot obtain it* is. its own.* A substrate service is an upstream image, pinned, with configuration. - **Not privileged.** The substrate is provisioned *from* by the control plane and grants nothing on its own initiative. +- **Not the mesh's supply of anything** + ([ADR 0028](../../02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md)). + A substrate service and a module of the same product are **different instances**. The mesh's own + PostgreSQL and a PostgreSQL a workload was given are two servers, and a node hosting both runs + two containers — expected, not duplication to be tidied away. + + The substrate is raised from the bundle before any mesh exists, so **it is not in the module + graph**: a workload depending on it would depend on something the graph cannot see, cannot rotate + a credential for, and cannot move. It would also put workload data in the store the control plane + keeps its own state in, where a workload that fills a disk takes down the one thing needed to fix + it. ## The pinned bundle