diff --git a/02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md b/02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md new file mode 100644 index 0000000..8da690a --- /dev/null +++ b/02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md @@ -0,0 +1,130 @@ +--- +topic: the mesh +status: accepted +date: 2026-10-02 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md +--- + +# 188. A provider declares what it derives for each consumer, and the mesh tells both ends + +## Context + +An arrangement between a consumer and a provider is delivered entirely by the mesh. Where the +provider is, which port it answers on, what name the consumer must present, where its password +is — each arrives as a fact the consumer reads from its binding, or as `${bound:…}` filled into a +file before the declaration leaves the control plane. The provider invents none of it and hands +none of it back ([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)). + +One kind of value escapes that. Where the **provider names the resource** — a bucket, a database, +a vhost — the name is derived from the consumer, per consumer, and the mesh has no way to carry +it. `serves` is a literal block in the provider's definition: the same values for every consumer. +A provisioner's contract takes a provision and returns nothing. So a value the mesh's own rule +produced reaches neither end as a statement; it is recomputed at one end and transcribed at the +other. + +The object store is the instance ([issue 124](../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)). +Its provisioner normalises the login the mesh minted into a bucket name and creates, checks and +removes exactly that; the rule lives in twenty lines of the module's own TypeScript. Its three +consumers each write the answer into their own definition by hand. Two transcribed it correctly; +one named a predecessor's bucket, and would have authenticated successfully and been refused on +every object, which reads like a credential fault and is not one. + +Even corrected, the transcriptions are wrong in a second way. Each is `mesh--`, so +each **names the machine the module happens to run on today** — a definition stating a fact about +one installation, which [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md) +forbids and whose check does not catch because the name is not a domain. Move any of the three to +another machine and its configuration points at a bucket its key cannot open. + +The shape is not the object store's. A database provisioner that prefixed names, a queue provider +that scoped vhosts, any provider that derives a resource from who is asking: each forces the +consumer to reproduce somebody else's rule and keep it in agreement by hand. + +## Decision + +**1. A served value may name the consumer the mesh is serving.** A `serves` block, which is +literal today, may interpolate the mesh's own statement of who the consumer is: + +- `${consumer:as}` — the identity the mesh minted for this consumer, exactly as the login it is + told to present ([ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md)); +- `${consumer:as:dns}` — the same identity written as a DNS label. + +Nothing else. **The mesh learns no protocol here; it spells its own name in an alphabet it already +knows.** The identity is the mesh's, minted by the mesh, already capped at twenty characters +because of what an S3 access key accepts; `dns` is that same name with its separator written `-` +instead of `_`, which is the whole of the difference between the mesh's identifier alphabet and +the one buckets, vhosts and hostnames use. A provider that needs a prefix or a suffix writes it +around the placeholder, because a served value is a string. + +The rejected alternative is **the provider returning values from provisioning** — the natural +channel, since the provider is what derived them. It is rejected for three reasons, in order of +weight. It inverts the delivery the mesh is built on: a grant would carry data the provider wrote +rather than only data the mesh minted, and [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md) +removed exactly that second path once already. It makes a consumer's declaration incomplete until +its provider's reconcile loop has run, so a consumer could not be composed before a provider +answered — a bootstrap order the mesh does not have and does not want. And it puts the rule where +nothing can check it: a value that arrives from a running process cannot be refused at resolution, +only discovered wrong later, which is the failure this record exists to end. + +**2. The mesh resolves it once, per consumer, and tells both ends from the one resolution.** At the +moment a consumer's declaration is composed, the mesh knows exactly who the consumer is. There, and +only there, the placeholders are filled. The result reaches: + +- the **consumer**, as the served facts in its binding file and as `${bound::}` in + any file it writes — unchanged mechanisms, carrying one more key; +- the **provider**, as `serves` on that consumer's entry in its contributions file, so the + provisioner is *told* the name rather than recomputing it. + +**The provider stops deriving in code and starts declaring.** One statement, filled once, delivered +to both ends: the two cannot disagree, because there is no second computation to disagree with. + +**3. A served value stays settled before it is per-consumer.** Settings still compose into `serves` +([ADR 0174](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)), and the consumer +placeholders are filled after that, so an operator may set a prefix and the mesh still derives the +rest. A `${consumer:…}` naming a fact or an alphabet the mesh does not have is refused when the +definition is parsed, with what it may say. + +**4. A consumer may no longer name the resource its provider derives.** With the value delivered, +a literal in a consumer's definition is not merely redundant — it is the one thing that can +disagree with what the provider will actually create. The three object-store consumers lose their +hand-written bucket names in this change. + +## Consequences + +- One more thing a definition may say, and one less thing a module may be wrong about. The + vocabulary grows by a placeholder; the catalogue loses three literals that named this + installation's control node. +- A provider's naming rule becomes readable in its definition instead of in its source. `minio`'s + `bucketFor` goes; the manifest says `"bucket": "${consumer:as:dns}"` and the provisioner uses + what it is given. +- A provider that already serves consumers keeps serving them: the derived value equals what the + code derived, so no bucket, database or login changes name. This is a change of **who says it**, + not of **what is said**. +- The mesh now holds a rule in another system's alphabet — one rule, `dns`, stated once. A second + alphabet is a decision, not an addition: the cost of each is that the mesh must be right about + somebody else's naming, and that cost is only worth paying where the mesh already mints the name. + +## How this is checked + +- A served value naming an unknown fact or alphabet is refused at parse, with the list of what it + may say — tested on both halves of the message. +- Resolving a consumer whose provider derives a value puts that value in the consumer's binding + file, in its `${bound:…}` substitutions, and in the provider's contributions entry for that + consumer — one test asserting the three agree, because agreeing is the whole point. +- Two consumers of one provider on one machine get two different derived values, and neither gets + the other's. +- A catalogue-wide test refuses a consumer definition that writes a literal where its provider + derives: the provider's `serves` names the key, so the catalogue can say which definitions + transcribe one. +- `dns` is checked against the identity the mesh actually mints, not against an invented string: + the test derives an identity with `ConsumerIdentity` and asserts the label it becomes. + +## References + +- [issue 124 — a consumer cannot be told a value its provider derived for it](../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md) +- [ADR 0048 — a provider creates the credential the mesh minted, and seals nothing](0048-a-provider-creates-the-credential-the-mesh-minted.md) +- [ADR 0049 — a consumer's identity fits the tightest backend](0049-a-consumers-identity-fits-the-tightest-backend.md) +- [ADR 0174 — a node varies a module through settings and kept regions, never through an edit](0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md) +- [ADR 0155 — a definition names no installation, and how that is checked](0155-a-definition-names-no-installation-and-how-that-is-checked.md) +- [design 27 — a module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md) diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index bbff2dc..a73aea7 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -188,6 +188,7 @@ python3 00-META/checks/index.py fail if stale - **0185** — [A control plane behind its seat's row serves what it can](0185-a-control-plane-behind-its-seats-row-serves-what-it-can.md) - **0186** — [A ban list never holds a neighbour, and the mesh's own bans are its own wherever they hang](0186-a-ban-list-never-holds-a-neighbour.md) - **0187** — [A dead tracker is not the machine's failure](0187-a-dead-tracker-is-not-the-machines-failure.md) +- **0188** — [A provider declares what it derives for each consumer, and the mesh tells both ends](0188-a-provider-declares-what-it-derives-for-each-consumer.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md b/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md index c6fada1..9533868 100644 --- a/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md +++ b/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md @@ -2,7 +2,7 @@ layer: to-be status: in-progress code: [mesh-controller internal/catalogue] -updated: 2026-09-30 +updated: 2026-10-02 decisions: - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md - 02-DECISIONS/0155-a-definition-names-no-installation-and-how-that-is-checked.md @@ -14,6 +14,7 @@ decisions: - 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md - 02-DECISIONS/0038-the-mesh-assigns-the-port.md + - 02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md --- # 27 — A module requires, the mesh resolves @@ -206,6 +207,22 @@ name when nothing sets it. That is the contract half of this design's operator p the placeholder allows: the definition says which values reach which requirement, and nothing else does. *How it is checked:* the unit tests named in issue 173, and the plan comparison that closed it. +*A provider says once what it derives for each consumer (2026-10-02, +[ADR 0188](../../02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md), +[issue 124](../../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md)):* +where a provider **names the resource** it gives each consumer — a bucket, a database, a vhost — the +name is derived per consumer, and a literal `serves` block could not carry it. A served value may +now name the consumer the mesh is serving: `${consumer:as}`, the identity the mesh minted, and +`${consumer:as:dns}`, that same identity written as a DNS label. Nothing else — **the mesh learns no +protocol here; it spells its own name in an alphabet it already knows.** Settings are laid on first, +so an operator may still set a prefix and the mesh derives the rest. The mesh fills it at the one +moment it knows who the consumer is, and the one filled value reaches both ends: the consumer, as +its binding's served facts and as `${bound::}` in any file it writes; the provider, +as `derived` on that consumer's entry in its contributions file, so its provisioner is told the name +rather than recomputing it. A consumer that writes the derived value into its own definition instead +of asking for it is refused, naming the placeholder to use. *How it is checked:* the unit tests in +ADR 0188's "how this is checked", each run against the unchanged controller first. + ## How a definition reads what was resolved **One form, naming a requirement and a field of its contract.** A definition that needs the database's @@ -214,7 +231,9 @@ name in a configuration file writes the same thing: the requirement's name and t controller fills it at resolution. This one form replaces the placeholders that exist today, one per mechanism: bound values, secrets, -ports and machine facts. +ports and machine facts. It subsumes the consumer placeholder too — a value a provider derives is +read by the consumer exactly as any other field of the contract is, and `${consumer:…}` is only +how the *provider* states the rule. **The seat placeholder stays, for the controller alone.** The controller composes its own declaration and reaches the store and broker it made before any module existed, so it cannot be diff --git a/04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md b/04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md index 2df48fb..375016d 100644 --- a/04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md +++ b/04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md @@ -1,9 +1,9 @@ --- -status: located +status: resolved opened: 2026-09-26 -located-in: [mesh-controller internal/catalogue/declaration.go, mesh-sdk src/provisioner, mesh-catalog modules/minio] -fixed-by: -amended-design: +located-in: [mesh-controller internal/catalogue, mesh-sdk src/provisioner, mesh-catalog modules/minio] +fixed-by: 02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md +amended-design: 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md --- # 124 — A consumer cannot be told a value its provider derived for it, so it transcribes one @@ -63,3 +63,27 @@ compares it to what the provider will actually create. The one wrong instance wa - What would have caught the wrong instance? A test that resolves a consumer's grant and compares the bucket in its own configuration against the one the provider would create is a check that could exist today, for any interface, without the mechanism above. + +## Answered, 2026-10-02 — [ADR 0188](../../02-DECISIONS/0188-a-provider-declares-what-it-derives-for-each-consumer.md) + +The channel is the provider's own `serves` block, which may now name the consumer the mesh is +serving: `${consumer:as}` and `${consumer:as:dns}`. The mesh fills it once, where it knows who the +consumer is, and delivers the one filled value to both ends — the consumer's binding and its +`${bound:…}` substitutions, and the provider's contributions entry, so a provisioner is told the +name rather than deriving it. Each open question above, answered: + +- **Should a provider return values from provisioning?** No. It would make a grant carry data the + provider wrote, make a consumer's declaration wait on its provider's reconcile loop, and put the + rule where nothing can refuse it. The reasoning is in the record. +- **Or should `serves` say a value is derived?** Yes, and the mesh performs the derivation — but it + learns no protocol doing it. The only fact is the identity the mesh itself minted, in one of two + alphabets it already knows. +- **Should a consumer that names the resource be refused?** Yes. A consumer's file that already + contains the value the mesh is about to derive for it is refused at resolution, naming the + placeholder to write instead. That is the check this report asked for, and it is exact rather than + heuristic: a derived value carries the identity minted for this consumer on this machine, which + nothing else would spell out. + +minio's `bucketFor` is gone; its manifest serves `"bucket": "${consumer:as:dns}"`. The three +consumers' hand-written bucket names are gone with it — each of them also named the machine the +module happens to run on, which is the second thing wrong with a transcription.