Issue 008 — sharpen: the provisioner's seal model is orphaned, not just undelivered

A cross-repo trace showed nothing writes the provisioner's grant-request files,
nothing reads its sealed credentials, and no consumer unseals — while the mesh
already mints and delivers provider/consumer credentials asymmetrically with no
shared key. The fix is to drop the symmetric seal and have providers consume the
mesh-minted password, a breaking provider-contract change that wants an ADR.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-04 23:40:20 +02:00
parent 47b0ca5c90
commit 332c334767
@@ -1,67 +1,94 @@
--- ---
status: open status: open
opened: 2026-09-04 opened: 2026-09-04
located-in: [] located-in: [mesh-sdk, mesh-catalog]
fixed-by: fixed-by:
amended-design: amended-design:
--- ---
# A provider module's runtime needs a seal key the mesh cannot deliver # A provider's provisioner seals with a key the mesh has no way to deliver — and does not need to
## What was observed ## What was observed
Building the vertical slice for the module runtime (the module runs its own code as its Building the vertical slice for the module runtime (the module runs its own code as its own
own process under its own account), a **provider** module — one that stands up a per-consumer process under its own account), a **provider** module — one that stands up a per-consumer
resource and hands back a credential — was assigned to a node and run as a broker-bound resource and hands back a credential — was assigned to a node and run as a broker-bound
runtime. The runtime hosts the module's provisioner, and the provisioner's reconcile harness runtime. The runtime hosts the module's provisioner (the sdk's `runProvisioner`), and the
opens with: harness opens by reading a **seal key** from `$MESH_SEAL_KEY`, failing immediately without
one. Every credential it produces for a consumer is sealed to that key with the sdk's
symmetric `seal()` (AES-256-GCM, `mesh-sdk/src/primitives/index.ts`) before being written.
- it requires a **seal key**, read from the environment, and fails immediately without one; Nothing in the mesh sets `$MESH_SEAL_KEY`. It is read in exactly two places in the sdk and
- every credential it produces for a consumer is **sealed to that key** before it is written. set nowhere — no manifest, no control-plane code, no host code. So a provider runtime, as
delivered, aborts at start-up. The slice proved the mechanism only by setting a lab-local key
in the manifest by hand.
Nothing in the control plane delivers such a key to a module's runtime. The mesh delivers a ## What a trace of the credential path turned up
module its **broker account** (a sealed, machine-bound credential) and its **own-secrets**,
and it seals those deliveries with a key of its own — but there is no provision for handing a
provider the key it must seal *its own* outputs with, nor for a consumer to receive the
matching key to unseal them.
The consequence has two faces, both bad: The seal key is not a missing delivery. **The whole symmetric-seal provisioner is orphaned,
and it duplicates — badly — a job the mesh already does.**
- **A provider runtime cannot start.** The harness treats the missing key as a hard, - `runProvisioner` reads request files named `*.grant.json`. **Nothing writes those.**
up-front failure — correctly, because a provider that silently sealed to nothing would be - It writes sealed credential files named `<consumer>.<resource>.credential`. **Nothing reads
worse. So a provider module, assigned and pushed, comes up dead until a key is supplied those** — not the host, not the control plane. The host reports applied-resource digests
out of band. upward and never ships credentials; the control plane has no reference to that filename.
- **Supplying one out of band is not a fix.** A key set by hand on the provider is a key the - No consumer ever calls the symmetric `unseal()`. Consumers receive **plaintext**.
consumer has no principled way to obtain. The seal is symmetric; the two ends must share
it, and there is nothing that makes them share it.
The slice proved the mechanism only by setting a lab-local key in the manifest by hand — an Meanwhile the mesh already carries a provider→consumer credential across nodes, with **no
admission, not a solution. The test that does so says as much in its header. shared key anywhere**:
- The control plane mints the password once (`secrets.Make`) and seals it **twice,
asymmetrically** — `ForConsumer` to the consumer node's X25519 public key, `ForProvider` to
the provider node's (`mesh-control/internal/secrets/seal.go`, `mesh-host/internal/identity/
sealing.go`, NaCl box).
- Each host opens its own copy with its own private key on the machine; the plaintext exists
only for the length of one function call (`mesh-host/internal/apply/apply.go`, the
`${secret:name}` substitution — ADR 0024's "the host is the only thing that ever holds
both").
- `serves` carries no credential and says so; `receives`/`bound` tell each side *where* its
sealed secret is, never the value.
The two models also **contradict** each other. The sdk's `seal()` comment says the key is "a
per-node passphrase the host holds"; the host holds no such passphrase — it holds an X25519
private key, and the control plane's own code refuses a shared symmetric key on principle:
"a key both ends hold is a key the mesh would have to distribute, which is this problem again
one level down" (`secrets/seal.go`). A symmetric `MESH_SEAL_KEY` shared between a provider
node and a consumer node is exactly the thing the mesh was built not to have.
And the provisioner's model is wrong in a second way: its adapter **generates its own
password** (`generatePassword()`) and creates the resource with it — a different password from
the one the mesh mints and hands the consumer. Even with a seal key delivered, a consumer
would authenticate with the mesh's password against a resource created with the provisioner's.
## Why it matters beyond this instance ## Why it matters beyond this instance
The module-runtime design (the ADR that says a module runs its own code under its own The module-runtime model is what every provider now follows — a cache, an object store, a
account) is the model every provider now follows — a cache, an object store, a database, database. As written, each carries a provisioner that cannot start (no key), and that, if it
each stands up per-consumer resources and returns sealed credentials. The delivery the mesh did, would create resources with the wrong password and seal them for a reader that does not
already does for a broker account is exactly the shape a seal key needs, so the gap is not exist. The rule the design states — "a consumer receives a sealed credential and unseals it" —
that the idea is hard; it is that one required secret in the provider/consumer handshake was is enforced by nothing, because no consumer unseals and no shared key exists to unseal with.
never given an owner. Left open, every provider converted to the runtime model inherits a
module that cannot come up as delivered, and the failure lands at assignment time on whoever
is standing up the node — far from the design decision that caused it.
There is also a **rule-with-no-check** here: the design states that a consumer receives a ## The fix this points to
sealed credential and unseals it. Nothing today establishes the key that makes "unseal"
possible, so the rule is, at present, enforced by nothing. Not "deliver the seal key." **Align the provider with the mesh's existing credential path and
delete the symmetric seal:**
- The provisioner should stop generating a password and stop sealing. It should read the
per-consumer password the mesh already mints and delivers to the provider (`ForProvider`,
unsealed onto the machine by the host), and *create the resource with that password*.
- The consumer already receives the matching password as plaintext its own host wrote — no
change needed there.
- `runProvisioner`'s `sealKey`, `seal()`, `writeSealedCredential`, and the `*.grant.json` /
`*.credential` file dance come out; what replaces them is a reconcile driven by the
contributions file the mesh already writes to the provider's `receives` path.
## Open questions ## Open questions
- Whose secret is the seal key — the mesh's, the node's, or the specific provider/consumer - Does the mesh mint and deliver `ForProvider` for a *served interface* today (redis-cache,
grant's? The answer decides who generates it and who it is delivered to. postgres-database), or only for the own-secret case the trace followed? Confirm the provider
- Is it one key the mesh holds and delivers to both ends of every grant, or a per-grant key actually receives each consumer's password before reworking the adapter around it.
minted when a grant is made? A per-grant key confines a leak to one consumer; a mesh-wide - What is the adapter contract after the change — `create(consumer, password)` rather than
key is one thing to deliver and rotate. `create(grant) -> Credential`? That is a breaking change to the four providers (redis,
- Should it be delivered the way the broker account already is — a sealed, machine-bound postgres, minio, umami) and wants an ADR, since it changes what a provider module *is*.
own-secret file the runtime reads — so a provider needs no new delivery channel, only a new - Is any of the symmetric `seal()`/`unseal()` primitive still used for anything legitimate, or
named secret? does it leave with the provisioner?
- Does rotation of this key have to re-seal every outstanding credential, and if so, is that
the provisioner's reconcile loop's job or the control plane's?