ADR 0230: a consumer the mesh stops asking for is retired, and deleted only by a person

The operator's decision replaces ADR 0229's hourly release: the mesh no longer
finishes alone what its bound exists to question. To-be 45 and the module
protocol amended; 0229 and 0224 point to where their mechanism moved.
This commit is contained in:
jochen
2026-10-06 14:40:40 +02:00
parent 4cdee43f69
commit 3904ef88bf
6 changed files with 252 additions and 7 deletions
@@ -60,6 +60,13 @@ has forgotten it.
- **No secret travels.** The error is the provider's own text with the consumer's password removed, as
its log line already is.
> **The mechanism changed — 2026-10-06, by [ADR 0230](0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md).**
> What stands: a consumer the mesh stopped asking for is announced recovered. What moved: a consumer the
> provider still holds is no longer withdrawn on the first pass that misses it; it is said recovered when
> it is *retired*, after five passes or a person's approval. A provider says what it retires, approves,
> re-enables and deletes on an event of its own, `provisioner.retirement`, permitted the same way as the
> two events here.
**2. Every provider may say it, whatever its manifest lists.** The permission to publish the two events
is derived for every module that receives contributions; no manifest declares them. A provider whose
manifest forgot them would otherwise have its announcement refused by the bus and fail as silently as
@@ -134,6 +134,13 @@ let go every hour, said. A consumer asked for again is kept. **The brake stops t
intention**: an unassignment of many completes without a hand, a mistaken one costs at most one consumer
an hour while the operator is told — and withdrawal destroys no data since issue 241.
> **Replaced in part — 2026-10-06, by [ADR 0230](0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md).**
> The paragraph above no longer stands; the operator decided against releasing one consumer an hour.
> A consumer the mesh stops asking for is now *retired* — disabled, reversibly, and marked to delete —
> once the same result holds for five passes; a set of more than three, or more than half of those held,
> waits for a person's `retire approve`; and only `cleanup delete` deletes. Every other decision in this
> record stands. The *how it is checked* row for the brake is replaced by 0230's.
**What Phase 1 decided while building** — [issue 270](../04-ISSUES/270-phase-1-watches-signals-that-come-later-and-hears-only-the-controllers-faults/00-report.md)'s
decisions 1 to 6: the condition history as a bucket of its own, the events' shape, a key's last token,
the probe DW, which deleted consumers are said, and the bounds the design left to the build — is taken
@@ -0,0 +1,194 @@
---
topic: the mesh
status: accepted
date: 2026-10-06
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0229-the-cores-order-is-a-lease-the-store-remembers-and-an-epoch-a-machine-is-sent-once-it-reads-one.md
---
# 230. A consumer the mesh stops asking for is retired, and deleted only by a person
## Context
**This is the operator's decision, taken on 2026-10-06**, the day the withdrawal brake of
[ADR 0229](0229-the-cores-order-is-a-lease-the-store-remembers-and-an-epoch-a-machine-is-sent-once-it-reads-one.md)
was built. The model below — the three states, the stable result, the threshold that hands the act to a
person, the cleanup verbs, the thirty-day reminder — is the operator's direction, recorded here as given.
What the record adds is the evidence it rests on and the facts the build had to settle.
**What the brake did.** A provider's loop ([ADR 0224](0224-a-provider-that-keeps-failing-a-consumer-is-a-problem-the-controller-reports.md),
in the Go providers and the SDK's) that would withdraw more than one consumer in a pass, or more than
half of those it held, withdrew nothing and announced each kept consumer as failing; then, while the mesh
went on not asking, it let one go every hour, with no person involved. So a mistaken unassignment of
seven consumers still completed by itself in seven hours, and a person who did not read `status` in that
time lost all seven.
**What withdrawal does today**, since [issue 241](../04-ISSUES/241-one-unreadable-grants-file-dropped-every-database-on-the-control-node/00-report.md):
nothing a provider withdraws is destroyed in the providers that issue names — the relational database
locks the role and keeps the database; the SQL Server provider disables the login; the document store
strips the user's roles; the object store revokes the key and keeps the bucket; the mail server disables
the mailbox; the forge prohibits the login; the analytics site is kept. But three things were still
wrong:
- **The identity provider deleted the client.** Its withdrawal was a `DELETE` of the consumer's client,
outside issue 241's list; the secret, redirects and mappers went with it.
- **Nothing recorded that a withdrawal happened, or when.** A locked role is indistinguishable from one
an operator locked by hand. On the home server one consumer's login has been locked by an earlier
withdrawal, its database kept, with no record of when or why; on the control node six databases set
aside during issue 241's recovery sit under a dated name, which is the only record of them.
- **A restart forgot.** The loop withdrew only what *this process* had made. A consumer unassigned while
its provider was down was never withdrawn at all — its login stayed open, indefinitely.
**And nothing ever deleted anything**, so withdrawn data accumulates with no surface that says it is
there.
## Considered Options
1. **Keep the hourly release.** Rejected by the operator: it lets the mesh finish, unattended, an act the
bound exists to question. Many changes at once means a person is at work, and that person can confirm
once.
2. **Delete after a grace period.** Rejected: a timer is the mesh acting alone again, only later; the
loss in issue 241 was data nobody had decided to lose.
3. **Withdraw at once, below the bound, as before.** Rejected: one pass is one read of one file, and
issue 241 was one bad read. A result has to hold before it is acted on.
4. **Three states — active, retired, deleted — where retiring is reversible and automatic only when
stable and small, and deleting is always a person's act through the controller, executed by the
provider.** Chosen.
## Decision
**1. A consumer is ACTIVE, RETIRED or DELETED.**
- **Retired**: the mesh stopped asking for it. The provider **disables its access** — reversibly — and
**marks its login and data "to delete", with when and why**. Nothing is deleted. Asked for again, the
provider's ordinary create re-enables it at once, as it was, and clears the mark.
- **Deleted**: only through `cleanup delete`, after a person approved it.
**2. What "disable" means, per backend** — each the smallest reversible switch that stops the consumer
reaching its data, keeping everything else:
| Provider | Retired is | Kept | Mark |
|---|---|---|---|
| postgres | the role set `NOLOGIN`, its open sessions ended | the database, its owner and grants, every byte; `CONNECT` is not revoked and the database still accepts connections, so the nightly dump still backs it up | the role's comment, a JSON object naming the consumer's machine, when and why |
| keycloak, the identity provider | the client `enabled: false` — the server refuses its authorization and token requests | its secret, redirects, mappers and every other setting | the client's attributes `mesh.retired` and `mesh.retired-why` |
| the TypeScript providers | what their `remove` already does since issue 241 (a key revoked, a login disabled, roles stripped, a mailbox disabled), until each adds a `retire` of its own | as issue 241 lists | none yet: a TypeScript provider does not list or delete retired consumers until its adapter can |
A database renamed aside — by postgres's `postgres_retire_database` tool or by hand, the
`<name>_deleted_<date>` form — is listed beside the retired consumers, retired since that date, so a
person sees it and can delete it.
**3. Stable removals.** A provider retires a consumer only after it has seen **the same set** of
consumers no longer asked for in **five consecutive passes** that read the contributions file. A pass
that could not read it is not a result and starts the count again; so does a different set. At the loop's
pass interval of **five seconds**, retirement comes **twenty seconds** after the first pass that saw the
set — twenty to twenty-five seconds after the file changed. **Additions and changes to an existing
consumer — create, rotate — act on the first pass and are never delayed.**
**4. Too many is a person.** A stable set of **more than three consumers, or of more than half of those
the provider holds where it holds more than one**, retires nothing. The provider **waits**: it says so in
its journal and announces it, the controller raises an **urgent** condition listing what would be
retired, and it waits for `retire approve <node> <provider-module>` or `retire reject <node>
<provider-module>`. **An unassignment the controller itself made goes through the same threshold** — the
operator's words: *too many changes means a human is actively working on it*, so one confirmation. The
bound is the old brake's half, with three in place of one: one, two or three consumers of a larger
provider go without a hand; two of two do not.
- **Approve** retires exactly the set waiting — the provider refuses any other set, so a person approves
what they were shown.
- **Reject** keeps the set active; the provider does not ask about it again while the set stays the same.
The rejection is held by the provider's process: a restart asks again, loudly. A rejected set can still
be approved later.
- **Settled**: a waiting or rejected set ends without retiring when the mesh asks for any of it again or
the set changes; a new stable set starts the count over.
**5. The backend remembers, not the process.** Each provider lists from its own backend what the mesh
made, active and retired, with the mark. So a restart forgets nothing; a consumer the backend holds
active and the mesh no longer asks for is retired by the same rules after a restart as before one; and a
consumer **found disabled without the mark** — withdrawn before this record — is **adopted** as retired:
marked, said, announced, its clock starting then. What the mesh made is known by the mark, or, before
the mark existed, by its shape (postgres: a role valid until *infinity*, which only the
mesh's role statement sets, owning a database of its own name; keycloak: the
`mesh.provisioned` attribute it already had). Anything else is somebody else's and is never listed,
retired or deleted.
**6. Cleanup is the controller's verbs, executed by the provider.**
- `cleanup list` — every retired consumer per provider: its age, its size where the backend can say,
and why.
- `cleanup delete <node> <provider-module> <consumer> --why` — one.
- `cleanup delete --older-than <days> --why` — lists what it would delete; deletes only with the
operator's explicit `--confirm`.
- **The provider deletes**, because it owns its backend: the controller asks the provider's tool on that
machine through the mesh, as any module's tool is asked, and never touches a backend itself. A provider
refuses to delete anything active or asked for, and anything not retired.
- **Every deletion is written to the hand-act log**, as is every approval and rejection — including one
made by asking a provider's tool directly rather than through the controller.
**7. Every provider serves the same four tools**, the protocol between the verbs and the providers:
`provisioner_retirement` (what is held, waiting, rejected and retired), `provisioner_retire_approve`,
`provisioner_retire_reject` and `provisioner_delete`, each act requiring a why. And says one event,
`provisioner.retirement`, whose `change` is `waiting`, `settled`, `approved`, `rejected`, `retired`,
`reenabled`, `deleted` or `adopted`; the controller derives the permission to publish it for every module
that receives contributions, as ADR 0224 does the standing events.
**8. Nothing is silent.** Retire, approve, reject, re-enable and delete are each announced and said in the
provider's journal; waiting is an urgent condition and a rejection a warning one, both naming the
provider and its machine; and a self-check probe raises a **low-severity `cleanup-waiting` condition**
for any provider holding something retired **more than thirty days**.
**9. The withdrawal brake paragraph of ADR 0229 is replaced by this record.** The rest of 0229 — the
lease, the epoch, the report's order, one writer at composition, the plan by compare-and-set, the lint,
every consumed kind's contract — stands unchanged.
## Consequences
- **The first rollout surfaces what is already there.** On the home server the consumer locked by an
earlier withdrawal is adopted as retired; the control node's six set-aside databases are listed, and
raise `cleanup-waiting` thirty days after their date; any consumer a backend holds open that the mesh no
longer asks for becomes a retirement — waiting for a person if there are more than three. Nothing is
deleted by the rollout.
- **Retired data is still backed up and still takes space** until a person deletes it. That is the
point; the thirty-day condition is what keeps it from being forgotten.
- **A person who rejects must come back.** A rejected set is kept active and said as a warning until the
mesh asks for it again or someone approves it.
- **The TypeScript providers get the stable count and the threshold** from the SDK's loop on their next
build (the SDK's provisioner, every catalogue provider's range accepts it), but a set over the bound
there waits with no way to approve it until each provider passes its module name and an announcer to
the loop, and they cannot list or delete what they retired until their adapters can. Until then they
are covered for safety and not for cleanup, and the record says so rather than claiming otherwise.
- **The Go loop is still two identical copies**, in postgres and keycloak,
held together by a test — now over three files. Its home is the Go SDK ([ADR 0039](0039-what-the-sdk-holds-and-refuses.md));
moving it there means a tagged SDK release before the catalogue can use it, a separate change.
- **The rollout order**: the controller first (it derives the permission to publish the new event and
hears it; an older controller makes the provider's announcement a refusal the provider logs), then the
catalogue's two Go providers, then the SDK's release and each TypeScript provider as it is rebuilt.
## How it is checked
| What | Checked by |
|---|---|
| a transient empty list for four passes retires nothing; five stable passes retire; an unreadable pass and a changed set restart the count; additions and changes are not delayed | the catalogue's shared `retirement_test.go` (identical in both Go providers) and the SDK's `retire.test.ts` |
| over the bound waits, is announced and said, again every fifteen minutes; approve takes only the exact set; reject keeps it and is not asked again; a set asked for again settles | the same tests |
| retired is disabled with data intact; asked for again it is enabled as it was | postgres's `live_test.go` and keycloak's `live_retire_test.go`, against throwaway servers |
| delete removes only that consumer, never an active or asked one | the same live tests, and `retirement_test.go` |
| a restart retires what the backend holds unasked and adopts what it finds disabled | `retirement_test.go`, the provider's inventory tests |
| the conditions, verbs, hand acts and the thirty-day probe | the controller's tests of the event, the verbs against a fake provider on a real bus, and the probe |
| the two Go copies agree | `harness_same_test.go` in each provider, over the three files |
| live, after rollout | `cleanup list` names the adopted and set-aside entries; `conditions` shows no `retire-waiting` on a mesh nobody is changing |
## References
- [ADR 0229](0229-the-cores-order-is-a-lease-the-store-remembers-and-an-epoch-a-machine-is-sent-once-it-reads-one.md)
— replaced in part: its withdrawal brake paragraph. Everything else in it stands.
- [ADR 0227](0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md) rule 4,
*nothing dropped silently* — which this keeps: a stable result over the bound stops and raises a
condition.
- [ADR 0224](0224-a-provider-that-keeps-failing-a-consumer-is-a-problem-the-controller-reports.md)
— the provider's events and their derived permission, which the retirement event follows.
- [Issue 241](../04-ISSUES/241-one-unreadable-grants-file-dropped-every-database-on-the-control-node/00-report.md)
— what withdrawal destroyed, and what it stopped destroying.
- [To-be 45](../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md) §2, §4, §7 and Phase 2, and
[the module protocol](../03-DESIGN/01-to-be/19-the-module-protocol.md), amended alongside.
- mesh-catalog `modules/postgres` and `modules/keycloak` (`retirement.go`, `retire_pg.go`, `oidc.go`);
mesh-sdk `src/provisioner` 0.1.12; mesh-controller's `retire` and `cleanup` verbs and its probe.
+1
View File
@@ -201,6 +201,7 @@ python3 00-META/checks/index.py fail if stale
- **0224** — [A provider that keeps failing a consumer is a problem the controller reports](0224-a-provider-that-keeps-failing-a-consumer-is-a-problem-the-controller-reports.md)
- **0227** — [The core holds nine rules, each checked, and is built to them in six phases](0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md)
- **0229** — [The core's order is a lease the store remembers, and an epoch a machine is sent once it reads one](0229-the-cores-order-is-a-lease-the-store-remembers-and-an-epoch-a-machine-is-sent-once-it-reads-one.md)
- **0230** — [A consumer the mesh stops asking for is retired, and deleted only by a person](0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md)
- **0231** — [A healer acts on what observation raised, and only observation says it worked](0231-a-healer-acts-on-what-observation-raised-and-only-observation-says-it-worked.md)
### Its tiers, from the bottom up