ADR 0230: ten minutes as well as five passes, and mark-only where a provider cannot disable

Five passes are 25 s, shorter than a real hiccup; and a TypeScript provider's
remove can destroy, so retiring never calls it. Every TypeScript provider is
mark-only until it gains a retire that disables.
This commit is contained in:
jochen
2026-10-06 14:40:40 +02:00
parent 3904ef88bf
commit da136ab40e
5 changed files with 55 additions and 28 deletions
@@ -63,9 +63,9 @@ has forgotten it.
> **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.
> it is *retired*, after five passes and ten minutes, 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
@@ -137,9 +137,9 @@ an hour while the operator is told — and withdrawal destroys no data since iss
> **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.
> once the same result holds for five passes and ten minutes; 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,
@@ -62,28 +62,49 @@ there.
- **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.
provider's ordinary create re-enables it at once, as it was, and clears the mark. A provider that
has no non-destructive way to disable does **not** fall back on its removal: it is **mark-only** —
the consumer keeps its access, is marked retired and needing deletion, and is said and announced (§2).
- **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:
**2. What "disable" means, per provider kind** — each the smallest reversible switch that stops the
consumer reaching its data, keeping everything else; or, where a provider has none, nothing at all:
| 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 TypeScript provider whose adapter has `retire` (and, if its create does not undo it, `reenable`) | what its `retire` disables | what its `retire` keeps | what its `retire` writes |
| a TypeScript provider without `retire` — **mark-only** | **nothing changes in the backend: the consumer keeps its access** | everything | the loop's own record, "retired, access kept, needs deletion", said loudly, announced, shown by `cleanup list` |
**`remove` is never called to retire.** A TypeScript adapter's `remove` is what it did on withdrawal, and
several destroy something a person has not decided to lose — the secrets vault unlinks the ledger file
holding the secret, the public DNS provider deletes the record, the message brokers delete the user. So
an adapter without a non-destructive `retire` is mark-only, and `remove` is reached only as the delete of
a provider without one of its own, through a person's `cleanup delete`. Today **every TypeScript provider
is mark-only**, because none has a `retire` yet: redis, mosquitto, minio, umami, mssql, mongodb,
influxdb, the secrets vault (mesh-vault), mailu, gitea and cloudflare-dns. Each leaves mark-only by
adding a `retire` that disables — the SQL Server, document-store, object-store, mail and forge providers
already have such a switch in their `remove` (disable the login, strip the roles, revoke the key,
disable the mailbox, prohibit the login), moved into `retire`.
A mark-only retirement is kept in the provider's process, not its backend: a restart forgets it, and the
consumer, still reachable, is no longer listed. That is the cost of a provider that cannot disable, and
the reason to give each a `retire`.
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.**
consumers no longer asked for **both** in **five consecutive passes** that read the contributions file
**and** for at least **ten minutes** since the first of them (the constant `StableFor` in the Go loop,
`STABLE_FOR_MS` in the SDK's). At the loop's pass interval of five seconds, five passes alone are
twenty-five seconds — shorter than a controller restart, a store reconnecting or a file half written —
so the ten minutes are what a real hiccup has to outlast, and the five passes keep one slow pass from
counting as agreement. A pass that could not read the file is not a result and starts both again; so does
a different set. **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
@@ -152,11 +173,12 @@ every consumed kind's contract — stands unchanged.
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 TypeScript providers get the stable count, the ten minutes, the threshold and mark-only** from
the SDK's loop on their next build (every catalogue provider's range accepts it). Mark-only is safe —
nothing is destroyed — and weak: the consumer keeps its access, and a restart forgets the mark. 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. Until each adds a `retire` and that wiring, they are covered for safety and not
for disabling or 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.
@@ -168,12 +190,13 @@ every consumed kind's contract — stands unchanged.
| 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` |
| a transient empty list for four passes retires nothing; five passes in twenty-five seconds retire nothing, the same set held ten minutes does; ten minutes in fewer than five passes do not; 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 |
| an adapter without `retire` is mark-only: `remove` is not called on retirement, the consumer is marked with its access kept and said loudly, and `remove` runs only on a person's delete; `reenable` runs before create for a retired consumer asked for again | the SDK's `retire.test.ts` and `sdk.test.ts` |
| 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 |
+4 -2
View File
@@ -252,8 +252,10 @@ failing word per provider, machine and consumer, and `status` names each one unt
A consumer is active, retired or deleted
([ADR 0230](../../02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md)).
The provider's loop retires one only after the same set has gone unasked in five consecutive passes
that read the contributions file; retiring disables its access reversibly and marks it, in the
provider's own backend, with when and why; asked for again, the ordinary create re-enables it. A set of
that read the contributions file and for ten minutes; retiring disables its access reversibly and marks
it, in the provider's own backend, with when and why — or, for a provider with no way to disable, only
marks it, its access kept, and never calls its removal; asked for again, the ordinary create re-enables
it. A set of
more than three, or of more than half of those held, waits for a person. Every provider serves four
tools for this — `provisioner_retirement`, `provisioner_retire_approve`, `provisioner_retire_reject`,
`provisioner_delete` — and says `provisioner.retirement`, permitted like the two events above. The
@@ -460,7 +460,7 @@ bound corrected in the table.
| mesh-controller | the lease and epoch (§6); plans written by compare-and-set; a report kept by sequence; abandoned calls marked; S12, S13, D5; the writers table enforced at grant composition; contract tests for every consumed subject and the check listing them; the empty-on-error lint |
| mesh-host | one apply queue; the report sequence kept on disk; epoch refusal reported; the `report` verb; the contract tests and lint |
| mesh-tools | refusing an unreadable or unknown input by name; the lint |
| mesh-catalog, mesh-sdk | retirement in the providers' loop (ADR 0230, replacing ADR 0229's brake): a consumer no longer asked for is retired — disabled, marked, its data kept — only 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 and raises a condition; the four retirement tools on every provider |
| mesh-catalog, mesh-sdk | retirement in the providers' loop (ADR 0230, replacing ADR 0229's brake): a consumer no longer asked for is retired — disabled, marked, its data kept; or, by a provider that cannot disable, only marked, its access kept — only once the same result holds for five passes and ten minutes; `remove` is never called to retire; a set of more than three, or more than half of those held, waits for a person and raises a condition; the four retirement tools on every provider |
| mesh-lab | R1, R2, R6 |
**Done when:** R1 ends with nothing from the stale epoch applied, R2 with one report of the newest
@@ -476,12 +476,14 @@ brake is said in its journal only.
**Amended 2026-10-06** ([ADR 0230](../../02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md),
the operator's decision): the brake's hourly release is replaced by retirement — three states (active,
retired, deleted), five stable passes before a consumer is retired, a person for more than three or more
retired, deleted), five stable passes and ten minutes before a consumer is retired, a provider that
cannot disable marking only (every TypeScript provider today), a person for more than three or more
than half, `retire approve|reject`, `cleanup list|delete`, `retirement` events and D11. Built on branches
in mesh-catalog (both Go providers), mesh-sdk (0.1.12) and mesh-controller, not yet merged. **Not yet:**
the TypeScript providers passing their module name and an announcer to the loop, without which a set
over the bound there waits with no way to approve it, and adapters that list and delete what they
retired.
the TypeScript providers giving their adapters a `retire` that disables (until then each is mark-only:
its retired consumers keep their access), passing their module name and an announcer to the loop,
without which a set over the bound there waits with no way to approve it, and adapters that list and
delete what they retired.
### Phase 3 — Healers