--- status: resolved opened: 2026-09-01 located-in: [mesh-control] fixed-by: mesh-control 0af3ea1 amended-design: --- # 022 — A credential belongs to a node and a provision, so a second consumer refuses ## Symptom A node running more than one module that wants the same provision **cannot be planned at all**: ``` anchor has 3 modules asking for "postgres-database" and they would share one credential: gitea, keycloak, umami ``` **And that is only the loud half.** The consuming node does not refuse at all. Three modules wanting one database produce **one** need: ``` modules=3 needs=1 name=postgres-database from=anchor for=gitea ``` So the first module gets a credential, the other two get no file at all, and each starts and fails to authenticate with nothing anywhere saying why — the shape of [`021`](../021-a-consumer-on-the-providers-machine-is-given-no-credential/00-report.md), on a different axis. The refusal that reads like a decision is on the provider; the silence is on the consumer. The refusal is correct about what it says. They *would* share one credential, and sharing one is worse than refusing — a login that opens three databases is not three credentials. But the arrangement being refused is the ordinary one. **The node this mesh exists to take over runs eight modules against one database server.** ## Where it comes from A credential is keyed by *(provision, consumer node, provider node)*: ```go func (i *Inventory) SecretFor(ctx context.Context, name, consumer, provider string) (Secret, error) ``` `consumer` is a **node**. Everything downstream inherits that granularity: `Grant.Consumer` is a node, the grant's file is named after a node, and the provisioner names the role it creates after one — `role := mark + c.Node`. So the refusal in `ContributionsTo` is not a check that found a problem. It is the only honest thing that function can do, given a key that cannot tell two consumers apart. ## Why it was not noticed **Every scenario so far had one consumer per node.** That is the natural shape of a small test — a consumer here, a provider there — and it is the shape of every lab scenario written to date. A node with two modules wanting a database is not an edge case discovered by fuzzing; it is what a real machine looks like, and nothing had modelled a real machine yet. The refusal also reads as deliberate. It names the modules, explains the consequence, and refuses rather than picking — the house rule everywhere else. It looks like a decision. It is a limit. ## Why the granularity is wrong The same argument that closed [`021`](../021-a-consumer-on-the-providers-machine-is-given-no-credential/00-report.md). There, the machine was treated as a trust boundary and containers made that untrue. Here, the machine is treated as an *identity* — as though "who is asking" is answered by naming a host. **Two modules on one node are as separate as two on different nodes.** They run as different containers, on different networks, with different data. A key that cannot distinguish them means the mesh cannot express the thing it is for. It also silently weakens what the provisioner does. `mesh_` is one role. Had the refusal not been there, gitea's login would have opened keycloak's database — and nothing anywhere would have said so, because from the provisioner's side it created exactly what it was asked to create. ## What a fix has to keep - **The refusal, where it is still right.** Two modules wanting one provision must not silently share a credential. After a fix they do not share one, so there is nothing to refuse — but a genuine collision must still refuse rather than pick. - **A credential per consuming module**, sealed to the node that holds it. Both facts are needed: the module is who it is for, the node is what it is sealed to. - **The provisioner names what it creates after the module**, so a login is traceable to the thing using it, and so withdrawing one consumer does not remove another's. - **Withdrawal still works.** A module unassigned must lose its login while the others keep theirs — which is precisely what one role per node cannot do. - **Existing single-consumer nodes keep working**, since that is every scenario that exists. ## Scope This crosses the control plane, the grant file naming, and every provisioner that names something after `Consumer`. It is not a local fix, and it is the last thing between the current state and a node that looks like a real one. ## Fixed Needs fan out per consuming module in one place, after the resolution walk. The credential's key gains the consuming module, the grant file is named after both halves of the consumer, needs are matched by provision *and* module, and the provisioners name the role and the access key after the module rather than the machine. The refusal is gone because there is nothing left to refuse. Existing credentials are discarded rather than backfilled: they cannot say which module they were for, and one is remade and delivered to both ends on the next push, so it costs one rotation. A guard was added for PostgreSQL's 63-byte identifier limit, which truncates with a notice rather than an error — two consumers whose role names agree that far would otherwise become one login, which is this same fault at a length nobody would think to test. What it did **not** fix is [`023`](../023-a-consumer-cannot-build-a-connection-string/00-report.md): a consumer now receives its own password and still cannot build a connection string, because the user name is the provisioner's invention and the bound values cannot reach a configuration file.