Merge pull request 'Issue 273 and ADR 0232: a binding to a consumer's data moves only by a person' (#137) from issues/273-a-rule-for-the-resolver-moved-a-machines-databases into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on

This commit was merged in pull request #137.
This commit is contained in:
2026-10-06 13:22:42 +00:00
4 changed files with 258 additions and 0 deletions
@@ -0,0 +1,136 @@
---
topic: what runs on it
status: accepted
date: 2026-10-06
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md
---
# 232. A binding to a consumer's data moves only by a person
## Context
**A rule written for the resolver moved a machine's databases.** [Issue 258](../04-ISSUES/258-every-machine-bound-the-resolver-to-itself/00-report.md)
found that a machine running its own provider of a mesh-wide provision bound its consumers to that
provider, even when the mesh's seat for the provision was held elsewhere. Its fix made the seat's
holder, or a pin naming another machine, win over the local provider. That is right for the mesh's
resolver: every resolver gives the same answer, so a consumer moved between two loses nothing.
The fix applied to every seat that delivers a provision. The store's seat delivers the relational
database. On the home server, which runs its own store with five applications' databases in it, the
fix's first push re-bound all five to the store on the control node, which holds the seat. That store
did what a provider does for a new consumer: it made each one an empty database. The applications
started, ran their first migrations, and served empty data for about twenty hours. Nothing warned
([issue 273](../04-ISSUES/273-a-rule-for-the-resolver-moved-a-machines-databases/00-report.md)).
Nothing was lost, only because the old store kept everything.
The incident has two parts:
1. **Issue 258's rule applies to two kinds of provision as if they were one.** It does not say which
kind it is for.
2. **Nothing in the mesh knows where a consumer's data is.** A resolution answers one question:
*which provider would I choose now?* Every input to that answer can change under a consumer without
anybody meaning to move it: the seat's holder (a handover, ADR 0131), a pin added or removed, a
provider assigned or unassigned. Only the pair secrets in the store held a trace of the old
binding, and only because a new pair was minted beside it.
## Considered Options
1. **Revert issue 258's fix.** Rejected. It was correct for the resolver, and the resolver would
break again.
2. **Make the store's seat not deliver its provision.** Rejected. That fixes one seat, and the next
seat that delivers a provision which keeps data repeats the incident.
3. **Refuse any resolution that changes a binding.** Rejected. It would refuse the resolver's moves,
which are the point of 258, and a person moving a database on purpose would have no way to do it.
4. **Know which provisions keep their consumers' data. For those, keep each consumer where it was
last sent, say every move the mesh would have made, and let only a pin move it.** Chosen.
## Decision
**1. An offer says whether it keeps its consumers' data.** The field is `keeps-consumer-data` on
`provides`.
- If unsaid, a provider that **grants** each consumer a credential of its own keeps that consumer's
data. The grant makes an account (a role and its database, a key and its bucket, a client), and
what the consumer writes under it stays with that provider.
- A provider that grants nothing keeps nothing of anybody's. Examples: the resolver, a CA, the
artifact store, a route.
- The property belongs to the provision's name, as brokering does (ADR 0009): if any provider says
the provision keeps data, it keeps data.
**2. Issue 258's rule is for provisions that keep nothing.** For a provision that keeps data, the
seat's holder on another machine does **not** overrule a provider beside the consumer. A pin naming
another machine still does, because a pin is a person.
**3. Where each such consumer was sent is recorded, and a resolution keeps it there.** One row per
consumer and provision is written when a declaration carrying the binding is sent. The provider is
recorded by name, so a provider's machine leaving the mesh does not erase where the data is.
- **Recorded binding, different provider chosen, no pin naming the new one:** the recorded provider
keeps answering. The move is said.
- **Recorded provider no longer provides the provision:** the machine's set is **refused**, naming the
pin that would confirm the move. It is never answered by the provider chosen instead. This is ADR
0009's stance, already applied to a pin naming a provider that is gone.
- **New consumer with no record:** it binds as resolved, and is recorded when first sent.
**4. Only a pin moves it.** The pin names the provider, for that machine and provision. The send
that carries the move records the new provider and keeps where it was. The data is moved by a person
before the push; the mesh does not move data.
**5. Nothing is silent.** A kept move is printed by the push that composed it and raised at once as
an **urgent** condition:
> would move X's P from A to B — its data is on A; kept there. `pin …` to confirm a move (and move
> the data first)
A self-check probe ([to-be 45](../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md) §4)
checks every machine every run. It raises:
- a kept move, as urgent;
- a pinned move not yet sent, as a **warning**, so the data goes first;
- any consumer about to be sent another provider than the one on record with no pin naming it, as
**urgent**. The resolver makes this impossible; the probe exists for the day it is not.
## Consequences
- **The first push after rollout records what each machine is bound to then.** A consumer moved
silently before the rollout and never moved back would be recorded where it was moved to. The
rollout therefore starts by checking that every binding to data points where the data is. On
2026-10-06 every one did: the five moved consumers had already been pinned back.
- **A pin is per machine and provision, not per consumer.** Pinning one consumer of a machine
elsewhere pins all its consumers of that provision. That is how pins have always worked, and the
warning names every consumer that would move.
- **Unassigning a store beside its consumers now refuses their machine** until a person pins them
elsewhere. Before this, they moved silently to whichever store answered.
- **A retired consumer's binding stays on record** ([ADR 0230](0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md)).
Assigned again, the consumer returns to the provider that kept its retired data.
- **No module definition changes.** Every provider in the catalogue that keeps data already grants
credentials, and none that keeps nothing does. A definition states the field only when the default
is wrong for it.
## How it is checked
| What | Checked by |
|---|---|
| the incident: a machine running its own store and its consumers, the store's seat held elsewhere — the consumers stay on their own store, and the resolver beside them still follows its seat | mesh-controller `internal/catalogue/bound_test.go`, and `cmd/mesh-controller/bindings_test.go` through the real stores; both fail without the fix |
| a recorded consumer is kept when the seat's holder changes, and the move is said with its pin | `bound_test.go` |
| a pin moves it; a gone provider, or a store beside it unassigned, is refused and never answered elsewhere | `bound_test.go`, `bindings_test.go` |
| an offer's `keeps-consumer-data` is read and written, and unsaid follows the grant | `bound_test.go` |
| a binding is recorded on send, a move keeps where it was, and a provider's machine leaving keeps the record | `internal/inventory/bindings_test.go` |
| a push raises a kept move at once; the probe says kept (urgent), pinned and not yet sent (warning), and unasked (urgent) | `bindings_test.go` |
| live, after rollout | the self-check passes its binding probe on every run, and `conditions` holds no `binding-kept` on a mesh nobody is changing |
## References
- [Issue 273](../04-ISSUES/273-a-rule-for-the-resolver-moved-a-machines-databases/00-report.md):
the incident.
- [Issue 258](../04-ISSUES/258-every-machine-bound-the-resolver-to-itself/00-report.md): the rule
this record limits.
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md): a seat's holder answers. This
record extends it, because the holder now answers only where nothing is kept.
- [ADR 0009](0009-modules-and-the-graph.md): refuse rather than guess.
- [ADR 0230](0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md):
the provider keeps a consumer's data until a person deletes it.
- mesh-controller PR #86: `catalogue/bound.go`, the `binding` table (migration 0071), and the
self-check's binding probe.
+1
View File
@@ -330,6 +330,7 @@ python3 00-META/checks/index.py fail if stale
- **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)
- **0228** — [A value given by hand lives only until its module's first good start](0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md)
- **0232** — [A binding to a consumer's data moves only by a person](0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md)
### How it is built
@@ -46,6 +46,14 @@ This is checked by three resolve tests in mesh-controller:
The tests fail without the fix.
**The fix broke something else** ([issue 273](../273-a-rule-for-the-resolver-moved-a-machines-databases/00-report.md)).
It applied to every seat that delivers a provision, and the store's seat delivers the relational
database. Its first push re-bound every database consumer on a machine running its own store to the
store holding the seat. Each was made an empty database there, and nothing said so for twenty hours.
[ADR 0232](../../02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md) limits
this rule to provisions that keep nothing of their consumers'. For one that keeps data, only a pin
moves a consumer away from a provider beside it.
## Verified
2026-10-05, after the fix rolled out: all four machines' resolver configuration names the anchor's
@@ -0,0 +1,113 @@
---
status: located
opened: 2026-10-06
located-in: [mesh-controller]
fixed-by: novox/mesh-controller PR #86
amended-design:
---
# 273. A rule for the resolver moved a machine's databases
## Symptom
On 2026-10-06 the five applications on the home server were found serving empty data. Each is a
consumer of the relational database provision and has its own database. The home server runs its
own store, and that is where their data is: one application's hundreds of tables, another's
hundred-and-seventy thousand rows, a workflow engine's workflows, an agent server's agents and
messages, and a game's players.
Their binding files named the store on the control node, which holds the mesh's store seat. That
store had each application's database, made new and empty on 2026-10-05 at 19:16 UTC. The first
application ran its first migration there at 19:17. All five then ran against the empty databases
for about twenty hours.
Nothing warned:
- the push that moved them reported success;
- `status` read them as applied and current;
- no condition was raised.
The real data stayed untouched on the home server's store. Pinning the home server to its own store
for the provision, then pushing, put every application back on its data. **No data was lost.**
The evidence is in the store's pair secrets: each of the five consumers holds one credential from
the home server, minted in the days before, and a second from the control node. All five second
credentials were minted in the same second, 19:16:21 UTC on 2026-10-05, two minutes after the
controller carrying [issue 258](../258-every-machine-bound-the-resolver-to-itself/00-report.md)'s fix
was merged and rolled out.
## Cause
Issue 258's fix made the holder of a provision's mesh seat on another machine, or a pin naming
another machine, answer **before** a provider on the consumer's own machine. It was written for the
resolver, which every holder answers alike. But the check ran for every provision a seat delivers,
and the store's seat delivers the relational database. On a machine running its own store beside its
consumers, every consumer was re-bound to the seat's holder. The holder did what a provider does for
a consumer it has not seen: it made an empty database.
Two gaps let this happen, and only the first was the change:
1. **The rule did not know which provisions it was for.** Nothing in a module definition, or in the
resolver, separated a provision every provider answers alike from one whose provider keeps what
the consumer wrote.
2. **Nothing knew where a consumer's data was.** A resolution chooses a provider from inputs that
change: seat holders, pins, assignments. A consumer bound to its data was re-bound like any other,
with nothing to compare against and nothing to say. Before this incident, a handover of the
store's seat, or a pin removed, could have done the same.
## Other machines
All four machines were checked on 2026-10-06, read-only. Each machine's planned binding files were
compared with the pair secrets in the store and with where each database is. **No other machine's
consumers were moved.**
- Every other database consumer, and every consumer of the document store, object store, mail,
identity provider, package registry and broker topics, has one credential, from the provider it is
bound to now.
- The control node's consumers were never at risk: the control node holds the seat itself.
Three findings remain on the home server:
- **A sixth consumer there** first asked for a database after the change, at 00:28 on 2026-10-06. It
was bound to the control node from its first send. Its data, made before the mesh bound it, is on
the home server, and it is bound there now through the pin.
- **The control node's store still holds the six databases made for the home server's consumers.**
Four of them grew past an empty database's size while the applications ran against them. What was
written there in those twenty hours should be reviewed before they are retired
([ADR 0230](../../02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md)
makes that a person's act).
- **The control-node pair credentials for those consumers are still on record**, though nothing is
sent them.
## Fix
The fix is mesh-controller PR #86, decided by
[ADR 0232](../../02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md):
- An offer says whether it keeps its consumers' data. If it does not say, a provider that grants
each consumer a credential does.
- For such a provision, issue 258's seat rule no longer overrules a provider beside the consumer. A
pin still does.
- Where each such consumer was sent is recorded. A resolution that would bind it elsewhere keeps it
where it is, and raises an urgent condition naming the move and the pin that would confirm it. If
the recorded provider is gone, the machine's set is refused; the consumer is never moved to another
provider.
- A self-check probe raises:
- every kept move;
- every pinned move not yet sent, so its data is moved first;
- any move that slips past the resolver.
## How it is checked
The resolver's tests reproduce the incident: a machine running its own store and its consumers, with
the store's seat held on another machine. Every consumer stays on its own store, and the resolver
beside them still follows its seat. A second test runs the same incident through the controller's
real stores. It checks:
- the binding is recorded on send;
- the probe is silent;
- the store taken away is refused;
- a pin moves the binding and is said as a warning;
- once sent, it is quiet again.
Both tests fail without the fix. The rest of the checks are listed in ADR 0232.