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