Keep a consumer bound where its data is; only a pin moves it (hq ADR 0232, issue 273) #86

Merged
mesh-admin merged 1 commits from fix/a-stateful-binding-moves-only-by-a-person into main 2026-10-06 13:22:42 +00:00
Contributor

Why

PR #59 (issue 258) let a mesh seat's holder elsewhere answer before this machine's own provider. That is right for the resolver, but the same rule also applied to the store's seat. On 2026-10-05 19:14 it re-bound all five postgres-database consumers on the home server to the store on the control node. That store made each of them a fresh, empty database, and the apps ran on empty data for about 20 h with nothing warning. The live fix was a pin.

What

  • Offer property keeps-consumer-data (Offer.KeepsConsumerData, Manifest.KeepsConsumerData, catalogue.KeepsConsumerData):
    • If an offer leaves it out, the provider keeps data when it grants each consumer a credential. That covers postgres, mongodb, mssql, minio, keycloak, mailu, redis, umami, influxdb, mosquitto, the vault, the npm registry and cloudflare-dns.
    • It does not cover the resolver, the CAs, route, the artifact store, git or the bus.
    • It is decided per name, like brokering: if any mesh-scope provider keeps data, the name keeps data.
  • Where 258's rule applies. answeredElsewhere still lets a pin elsewhere win. For a provision that keeps data, the seat's holder elsewhere no longer overrules a provider beside the consumer. The resolver is unchanged, and the 258 tests still pass.
  • Sticky bindings:
    • The new binding table (migration 0071) records where each consumer of such a provision was last sent. It is written in overTheBus.declare after recordSent, on the send path only.
    • World.Bound is read in planFor.
    • keepBound runs as a post-pass after perConsumer. If a recorded consumer would be bound elsewhere, the recorded provider is kept and the move goes into Resolution.Kept. A pin that names the new provider moves it.
    • If the recorded provider no longer provides the provision, that is refused, naming the pin. It is never answered by another provider.
    • The provider is stored by name, not by foreign key, so a machine leaving the mesh does not erase where the data is.
  • Said:
    • A push prints every kept move and raises an urgent binding-kept condition at once.
    • The new self-check probe D12 raises every run:
      • binding-kept (urgent): "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)"
      • binding-moving (warning): a pinned move not yet sent.
      • binding-moved (urgent): any unasked move that slips past.

Tests

  • internal/catalogue/bound_test.go reproduces the incident: a machine running its own store and five consumers, with the seat held by the store elsewhere. The consumers stay local, and the resolver still follows its seat on the same machine. It also covers:
    • a record
    • a pin that moves
    • a holder change that is kept and said
    • a gone provider that is refused
    • a local store that is unassigned and refused
    • the first pass
    • offer parsing and the default
    • an offer saying it keeps nothing, which follows the seat
  • cmd/mesh-controller/bindings_test.go runs the incident through the real stores and planFor: the store beside the consumer, recording, D12, refusal, a pin and the moving warning, then sent and clear. It also unit-tests the findings and covers the push raising a condition.
  • internal/inventory/bindings_test.go checks recording, moved_from, and that a binding survives its provider's node being deleted.
  • The incident tests fail without the fix.
  • The full suite passed with -mod=vendor and GOPROXY=off against a throwaway postgres:17 and nats:2.11. The one exception was TestNatsAReportTheStoreCannotTakeIsHeldByTheServerAndComesBack, which timed out once and passed on rerun; it is a timing flake and not touched here.
  • -race passed on the new code, and vet and gofmt are clean.

Rollout

  • No catalogue change is needed: the default derives from grants.
  • The first push after deploy records each machine's current bindings. Today every postgres-database consumer resolves to its own machine; the home server's are pinned.
  • Run plan per machine before pushing. D12 says anything kept.

Decision: novox/hq ADR 0232. Incident: novox/hq issue 273.

## Why PR #59 (issue 258) let a mesh seat's holder elsewhere answer before this machine's own provider. That is right for the resolver, but the same rule also applied to the store's seat. On 2026-10-05 19:14 it re-bound all five `postgres-database` consumers on the home server to the store on the control node. That store made each of them a fresh, empty database, and the apps ran on empty data for about 20 h with nothing warning. The live fix was a pin. ## What - **Offer property `keeps-consumer-data`** (`Offer.KeepsConsumerData`, `Manifest.KeepsConsumerData`, `catalogue.KeepsConsumerData`): - If an offer leaves it out, the provider keeps data when it **grants** each consumer a credential. That covers postgres, mongodb, mssql, minio, keycloak, mailu, redis, umami, influxdb, mosquitto, the vault, the npm registry and cloudflare-dns. - It does not cover the resolver, the CAs, route, the artifact store, git or the bus. - It is decided per name, like brokering: if any mesh-scope provider keeps data, the name keeps data. - **Where 258's rule applies.** `answeredElsewhere` still lets a pin elsewhere win. For a provision that keeps data, the seat's holder elsewhere **no longer** overrules a provider beside the consumer. The resolver is unchanged, and the 258 tests still pass. - **Sticky bindings:** - The new `binding` table (migration **0071**) records where each consumer of such a provision was last sent. It is written in `overTheBus.declare` after `recordSent`, on the send path only. - `World.Bound` is read in `planFor`. - `keepBound` runs as a post-pass after `perConsumer`. If a recorded consumer would be bound elsewhere, the recorded provider is kept and the move goes into `Resolution.Kept`. A pin that names the new provider moves it. - If the recorded provider no longer provides the provision, that is **refused**, naming the `pin`. It is never answered by another provider. - The provider is stored by name, not by foreign key, so a machine leaving the mesh does not erase where the data is. - **Said:** - A push prints every kept move and raises an urgent `binding-kept` condition at once. - The new self-check probe **D12** raises every run: - `binding-kept` (urgent): "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)" - `binding-moving` (warning): a pinned move not yet sent. - `binding-moved` (urgent): any unasked move that slips past. ## Tests - `internal/catalogue/bound_test.go` reproduces the incident: a machine running its own store and five consumers, with the seat held by the store elsewhere. The consumers stay local, and the resolver still follows its seat on the same machine. It also covers: - a record - a pin that moves - a holder change that is kept and said - a gone provider that is refused - a local store that is unassigned and refused - the first pass - offer parsing and the default - an offer saying it keeps nothing, which follows the seat - `cmd/mesh-controller/bindings_test.go` runs the incident through the real stores and `planFor`: the store beside the consumer, recording, D12, refusal, a pin and the moving warning, then sent and clear. It also unit-tests the findings and covers the push raising a condition. - `internal/inventory/bindings_test.go` checks recording, `moved_from`, and that a binding survives its provider's node being deleted. - The incident tests fail without the fix. - The full suite passed with `-mod=vendor` and `GOPROXY=off` against a throwaway postgres:17 and nats:2.11. The one exception was `TestNatsAReportTheStoreCannotTakeIsHeldByTheServerAndComesBack`, which timed out once and passed on rerun; it is a timing flake and not touched here. - `-race` passed on the new code, and `vet` and `gofmt` are clean. ## Rollout - No catalogue change is needed: the default derives from `grants`. - The first push after deploy records each machine's current bindings. Today every `postgres-database` consumer resolves to its own machine; the home server's are pinned. - Run `plan` per machine before pushing. D12 says anything kept. Decision: novox/hq ADR 0232. Incident: novox/hq issue 273.
mesh-admin added 1 commit 2026-10-06 13:20:08 +00:00
Issue 258's fix let a mesh seat's holder elsewhere answer before this machine's own provider. Right
for the resolver, which any provider answers alike; for the store's seat it re-bound every database
consumer on a machine running its own store to the holder on another, each was given a fresh, empty
database there, and nothing said so for twenty hours.

- An offer says whether it keeps its consumers' data (`keeps-consumer-data`); unsaid, a provider
  that grants each consumer a credential does. For such a provision the seat's holder no longer
  overrules a provider beside the consumer; a pin still does.
- Where each such consumer was sent is recorded (migration 0071). A resolution that would bind it
  elsewhere keeps the recorded provider and says the move; one whose provider is gone is refused,
  never answered by another.
- A push says a kept move and raises it as an urgent condition at once; the self-check's D12 raises
  it every run, with a pinned move not yet sent as a warning and any unasked move as urgent.
mesh-admin merged commit c988d6d7be into main 2026-10-06 13:22:42 +00:00
mesh-admin deleted branch fix/a-stateful-binding-moves-only-by-a-person 2026-10-06 13:22:42 +00:00
Author
Contributor

Delivery novox/mesh-controller@c988d6d7bef2 — held since 2026-10-06T23:05:28Z

Held for a person: merged without a passing check: only a person decides that it goes on — mesh-delivery.release with why lets it go on.

Delivery plan — builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step

  • tier 0: mesh-controller
  • tier 1: build-agent
  • tier 2: route-proxy
  • ace: receives build-agent, route-proxy
  • g14: receives build-agent
  • novox: receives mesh-controller, build-agent, route-proxy
  • shanks: receives build-agent
  • build-agent keeps data (ADR 0233): what it holds is backed up before it moves
  • route-proxy provides route: its consumers are sent again after it
  • route-proxy keeps data (ADR 0233): what it holds is backed up before it moves

Transitions

  • 2026-10-06 23:05 (new) → proposed (announced): novox/mesh-controller#86 merged; its head was not heard by this owner, so it is made from the forge's word
  • 2026-10-06 23:05 proposed → held (merged-unchecked): merged without a passing check — a verdict known with it is taken first: only a person decides that it goes on

The commit's note under refs/notes/mesh-plan keeps every transition: git log --notes=mesh-plan.

<!-- mesh-delivery:view --> **Delivery** `novox/mesh-controller@c988d6d7bef2` — **held** since 2026-10-06T23:05:28Z Held for a person: merged without a passing check: only a person decides that it goes on — `mesh-delivery.release` with why lets it go on. **Delivery plan** — builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step - tier 0: mesh-controller - tier 1: build-agent - tier 2: route-proxy - ace: receives build-agent, route-proxy - g14: receives build-agent - novox: receives mesh-controller, build-agent, route-proxy - shanks: receives build-agent - build-agent keeps data (ADR 0233): what it holds is backed up before it moves - route-proxy provides route: its consumers are sent again after it - route-proxy keeps data (ADR 0233): what it holds is backed up before it moves **Transitions** - 2026-10-06 23:05 (new) → proposed (announced): novox/mesh-controller#86 merged; its head was not heard by this owner, so it is made from the forge's word - 2026-10-06 23:05 proposed → held (merged-unchecked): merged without a passing check — a verdict known with it is taken first: only a person decides that it goes on The commit's note under `refs/notes/mesh-plan` keeps every transition: `git log --notes=mesh-plan`.
This repo is archived. You cannot comment on pull requests.
No Reviewers
1 Participants
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-controller#86