ADR 0225: a consumer's identity is bounded by the provision it requires

Issue 263: one global 20-character bound held keyless provisions to an
object store's key, was found only when a provider composed, and then
refused the provider's whole machine. Take ADR 0049's option C, check
overflows before merge, and leave an overflowing consumer out of its
provider's grants instead of refusing the provider.
This commit is contained in:
jochen
2026-10-06 02:18:03 +02:00
parent 202f2aa144
commit 8fade781a7
6 changed files with 189 additions and 4 deletions
@@ -108,6 +108,14 @@ declared slug is a strictly better escape hatch than an opaque hash. **D stays r
## Consequences (of E)
> **The mechanism changed — 2026-10-06, by [ADR 0225](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md).**
> Option C, named above as the later refinement, is taken: each offer states the longest identity its
> backend keeps, and a consumer is bounded by the provision it requires rather than by 20 everywhere.
> 20 stays the bound of the object store and of a provider that is told its consumers and does not
> say. The overflow is refused before merge by the catalogue check, and at composition the consumer
> is left out of its provider's grants and reported — never the provider's machine refused. What
> stands: the identity is said once, the slug is the remedy, nothing is hashed or truncated.
- A module manifest gains an optional `slug`; a node may carry one too. `ConsumerIdentity` prefers
the slug over the cleaned name for each half. `identityLimit` becomes 20 (the true minimum), and
`CheckIdentity` refuses at `module add` / assignment — now with a message naming the slug to set.
@@ -64,6 +64,13 @@ instead of `_`, which is the whole of the difference between the mesh's identifi
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 mechanism changed — 2026-10-06, by [ADR 0225](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md).**
> The identity is no longer capped at twenty characters for every provision: each offer states the
> bound its backend keeps, and twenty is the bound of the object store and of a provider that does not
> say. What stands: the identity is still the mesh's, and `dns` is still that name with its separator
> written `-`. An offer serving `${consumer:as:dns}` now bounds its consumers at 63 or less, so the
> label still fits.
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
@@ -0,0 +1,148 @@
---
topic: what runs on it
status: accepted
date: 2026-10-06
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md
---
# 225. A consumer's identity is bounded by the provision it requires, judged before merge, and never refuses its provider
## Context
[ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md) bounds every consumer's identity,
`mesh_<machine>_<slug-or-module>`, by the tightest backend anywhere in the mesh: an S3 access key's 20
characters. It chose that over per-interface bounds (its option C) because one constant unblocked the
object store, and named C as the refinement "if non-S3 consumers are paying for S3's limit often
enough to mind". [Issue 263](../04-ISSUES/263-every-consumer-pays-for-the-tightest-backends-name-limit/00-report.md)
is that point. The operator: "the 20 character limit has bitten us multiple times".
What happened on 2026-10-06, and what it shows:
- **The bound applied where it meant nothing.** A change made the network-manager modules require the
mesh's resolver provision. The resolver mints no credential and keeps no name: its provider is not
even told who its consumers are (it receives nothing). `networkmanager`'s identity, 23 to 26
characters on real machine names, was refused all the same.
- **It was found late and far from its cause.** The catalogue's module check and the controller's
tests passed. ADR 0049 says the refusal comes at assignment; this was a new requirement on modules
already assigned, so no assignment saw it. It surfaced when the provider composed its grants.
- **It refused the wrong machine, wholly.** The controller judged the identity while composing the
*provider's* declaration, and one refusal there fails the whole composition. The anchor holds the
resolver, so the anchor — every module on it — could not be pushed until a slug was changed
elsewhere.
The catalogue's providers keep very different names. Read from each provider's code: the object store
keeps the identity as an access key (20) and a bucket name; PostgreSQL as a role and a database (63);
MongoDB as a database (63); SQL Server as a login and a database (128); the identity provider as a
client id (255); the forge as a user name (40); the mail server as a mailbox's local part (64); the
public DNS provider as a label (63); the cache, the vault, the message broker and the time-series
store as names with no limit worth stating. The resolver and both route providers keep no name of
their consumers at all.
## Considered Options
1. **Keep one bound, raise or lower it.** Any single number is wrong for most provisions: 20 refuses
a database consumer for an object store's key, 63 lets the object store fail at provision time
again ([issue 034](../04-ISSUES/034-mesh-login-exceeds-s3-access-key-limit/00-report.md)). Rejected —
it is the cause.
2. **Per-interface bounds in a mesh-wide table of provisions.** Puts the fact in the controller, which
would then know what an S3 key is. The mesh is name-agnostic about what a provider does with an
identity (`ConsumerIdentity`); a table would make it learn every backend. Rejected.
3. **Each offer states its own bound; the bound a consumer meets is that of the provision it
requires** (ADR 0049's option C, placed where [ADR 0202](0202-a-provider-declares-what-it-derives-for-each-consumer.md)
already places what a provider derives: in its own definition). Chosen.
4. **Derive a different identity per provision** (C's own "against": one consumer, several names).
Not needed: the identity stays one name, said once, and must fit every provision the module
requires. Only the *bound* is per provision. Rejected as unnecessary.
And for where the refusal lands:
5. **Keep refusing the provider's composition.** Rejected — it makes one consumer's name a reason
no push reaches a machine that did nothing wrong.
6. **Refuse the consumer's whole machine.** Right machine, still too wide, and still late. Not chosen;
the consumer is named instead, and refused in its own pull request (below).
## Decision
**1. An offer states the longest consumer identity its backend keeps.** In the provider's
definition, beside the provision: `identity` with a `max` and the words for what keeps it (`in`), so a
refusal can say "an S3 access key keeps 20"; `in` alone for a backend with no limit worth stating; or
`false` for a provision that keeps no name derived from its consumer. The bound applied to a consumer
is that of the provision it requires, from the offer of the module answering it — not the tightest
backend in the mesh.
**Unsaid, the bound follows from what the provider is told.** A provider that receives the provision,
or serves its consumers a value built from their identity, is told who each consumer is and may make
a name of it in a backend nobody measured: it keeps ADR 0049's 20. A provider told neither keeps
nothing of its consumers: no bound. A provider whose definition is not at hand is held to 20.
**2. An overflow is refused before merge.** The catalogue check (`module check`, and the controller's
test over the real catalogue) judges every module's identity, built on the longest machine name of
the mesh, against the bound of every provision it wants that a module in the catalogue offers — the
tightest where several offer it. The pull request that introduces an overflow — a new requirement, a
lowered bound, a longer module name — is the one that fails, naming the module and the longest slug
that would fit. The longest machine name is a parameter of the check; its default is the mesh's own
longest, raised in the same change that names a longer machine.
**3. One consumer's identity never refuses its provider's machine.** When the provider's declaration
is composed, a consumer whose identity overflows the bound is left out of the grants and returned
beside them; every other consumer is granted and the declaration composes. The consumer is named
where an operator looks: on the push and plan of the provider, on the plan of the consumer's own
machine, and in `status` (and its document), which does not call the mesh well while one stands.
**4. An identity served as a DNS label stays inside one.** The mesh writes an identity into a label
without truncating it ([ADR 0202](0202-a-provider-declares-what-it-derives-for-each-consumer.md)),
so an offer that serves `${consumer:as:dns}` must bound its consumers at 63 or less.
What does not change: the identity is still derived once and said to both ends (ADR 0049, issue 023);
the slug is still the remedy; truncation and hashing are still refused.
## Consequences
- A consumer of a database or the identity provider keeps a legible name on a long machine name; only
a consumer of the object store still needs a slug of a few characters. Requiring a keyless
provision costs nothing in name length.
- The catalogue's providers each state a bound in their offer, and the check reads it there. A new
provider that is told its consumers and says nothing keeps the old 20: safe, and visible in review.
- A consumer left out of the grants holds a binding and credential its provider never created. It
fails to authenticate; that is reported by name in `status` until a slug fixes it, rather than
hidden behind a machine nobody could push.
- The check's default machine-name length is a fact about one mesh carried in code. A mesh that
names a longer machine must raise it, or pass its own to `module check`; nothing reminds it to.
- Old controllers refuse the new field (manifests are parsed strictly), so the controller ships before
the catalogue that states bounds.
- ADR 0049's consequence "`identityLimit` becomes 20 … `CheckIdentity` refuses at `module add` /
assignment" now holds only for a provision that keeps 20, and is checked before merge and reported
at composition rather than refused at assignment. ADR 0049 carries a note saying so.
**How each rule is checked.**
- *Rule 1:* catalogue unit tests — an offer's stated bound is the one applied; an unstated one is 20
for a provider told its consumers and none for one told nothing; `false` is none; the field parses
strictly and a bound without `in`, or shorter than any identity, is refused when the definition is
parsed. Over the real catalogue: the resolver provision bounds nothing and the object store 20.
- *Rule 2:* `module check` refuses a module whose identity overflows what it requires, naming the
slug length, and passes a long name requiring a keyless provision; a test runs the same judgement
over every manifest in the real catalogue on the default machine-name length, so the catalogue's
own pull request fails on an overflow.
- *Rule 3:* a controller test against a real store reproduces the night it was found — the network
manager, no slug, on a six-character machine, requiring the resolver provision, beside a consumer
that overflows an object store: the provider's declaration composes, the network manager is granted,
the overflowing consumer is left out, named by the push, carried by `status` and its document, and
the mesh is not called well.
- *Rule 4:* a test that a DNS-label identity under a bound over 63 is refused when the definition is
parsed.
## References
- [Issue 263](../04-ISSUES/263-every-consumer-pays-for-the-tightest-backends-name-limit/00-report.md) —
the observation.
- [ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md) — the bound this refines; its
option C.
- [ADR 0202](0202-a-provider-declares-what-it-derives-for-each-consumer.md) —
a provider declares what it derives; the bound is declared beside it.
- `mesh-controller` `internal/catalogue/identity.go` (`IdentityBound`, `CheckIdentityWithin`,
`IdentityProblems`, `Resolution.Overflowing`), `manifest.go` (`Offer.Identity`,
`IdentityBoundOf`), `cmd/mesh-controller/plan.go` (`grantsFor`), `check.go`, `status.go`.
- `mesh-catalog` — each provider's offer states its bound.
+1
View File
@@ -323,6 +323,7 @@ python3 00-META/checks/index.py fail if stale
- **0215** — [The machine's message bus is a node seat, and it is never restarted live](0215-the-machines-message-bus-is-a-node-seat-and-is-never-restarted-live.md)
- **0216** — [The agent's configuration is registered through its module, at three scopes, and served as one plugin](0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
- **0220** — [What a machine asks needs its uplink held, and the retired resolver pieces go](0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)
- **0225** — [A consumer's identity is bounded by the provision it requires, judged before merge, and never refuses its provider](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md)
### How it is built
@@ -2,8 +2,9 @@
layer: to-be
status: in-progress
code: [mesh-controller internal/catalogue]
updated: 2026-10-02
updated: 2026-10-06
decisions:
- 02-DECISIONS/0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md
- 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
- 02-DECISIONS/0115-one-assignment-of-a-module-per-node.md
@@ -250,7 +251,11 @@ value in a container's environment is refused when the definition is parsed, wit
**A module is assigned at most once to a node**, and that pair is the assignment's identity
([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md)). Its directories,
containers, login, broker account and settings are keyed by it, as today, and a login still fits the
tightest backend ([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)).
backend that keeps it ([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)):
the bound is the one the offer of each provision it requires states, a keyless provision states none,
and an overflow is refused by the catalogue check before merge and left out of the provider's grants,
reported, at composition — never a refusal of the provider's machine
([ADR 0225](../../02-DECISIONS/0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md)).
**A module may run on many nodes, and one assignment may hold a seat**
([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))). The definition
@@ -1,7 +1,7 @@
---
status: open
status: located
opened: 2026-10-06
located-in: []
located-in: [mesh-controller internal/catalogue, mesh-controller cmd/mesh-controller, mesh-catalog modules]
fixed-by:
amended-design:
---
@@ -52,6 +52,22 @@ the remedy. That has two costs:
3. **Never refuse a provider's whole declaration** for one consumer's identity. Leave that consumer's
grant out, say so in `status`, and keep the provider's machine pushable.
## Where it lives
- **The one bound** is `identityLimit` in the controller's `internal/catalogue/identity.go`, applied to
every consumer whatever it requires. Nothing in a provider's definition could say otherwise.
- **The refusal** was raised in `grantsFor` (`cmd/mesh-controller/plan.go`), while composing the
*provider's* declaration: an error there fails the whole composition, so the anchor could not be
pushed for a module on another machine. No assignment, module check or test judged it before.
- **The facts** are in each provider's code in the catalogue: the object store keeps the identity as
an access key (20), the databases as roles and database names (63, 128), the identity provider as a
client id (255); the resolver and the route providers keep no name of their consumers.
Decided in [ADR 0225](../../02-DECISIONS/0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md),
which takes ADR 0049's option C and adds the two placements the issue asks for: each offer states its
bound, `module check` judges every identity on the longest machine name before merge, and a provider
leaves an overflowing consumer out of its grants and says so, in `status` too, instead of refusing.
## How it is checked (once fixed)
- A catalogue test: a module requiring a keyless provision composes with a long name.