mesh-knowledge: refresh repairs records the knowledge base drafted, keeping their clauses while their source is unchanged; ADR 0114 and 0148 as fixtures (review of #213)
mesh/merge-gate pass: builds mesh-knowledge → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@1b7f79f76177 (merged as 73367192 into main, walk plan-17917161183…

This commit is contained in:
2026-10-11 12:44:08 +02:00
parent 1cc0ab369f
commit 156fda4b33
5 changed files with 592 additions and 25 deletions
+10 -6
View File
@@ -115,12 +115,16 @@ no counter moves. A second run changes nothing and says so. `dry-run` writes not
`mesh-knowledge dry-run <hq checkout>` gives the same dry run over a checkout on disk, against an empty knowledge
base.
**`refresh`** (the import's third argument) repairs what an earlier import lost or misread (hq issue 487). For a record held whose every version
was written by the import or a refresh (no one has changed it since, and nothing waits over it), it reads the
record again at the commit and, when what it holds differs, writes it as a new version: "refreshed from hq at
<commit>: <path>", naming each field that differs. A record anyone changed after its import (drafted, accepted
over, corrected) is never touched, and its row says so. A record read again and found the same is left as it is,
so a second refresh changes nothing. With `dry-run` it says, record by record, what it would change.
**`refresh`** (the import's third argument) repairs what an earlier import lost or misread (hq issue 487). It
takes a record held whose every version was written by the import, a refresh, or the knowledge base's own
drafting and confirming of its clauses (nothing waits over it), reads it again at the commit and, when what it
holds differs, writes it as a new version: "refreshed from hq at <commit>: <path>", naming each field that
differs. It keeps what the knowledge base made of the record: written clauses with their statuses when hq writes
the same ones, drafted or confirmed clauses (and their drafting) while the text they were drafted from is what hq
says (else they are drafted again), and the clause a change of hq's names once judged. A record anyone changed
after its import (returned, corrected, accepted over) is never touched, and its row says so. A record read again
and found the same is left as it is, so a second refresh changes nothing. With `dry-run` it says, record by
record, what it would change.
A decision whose Decision section's own top-level list writes numbered statements with a bold lead gets them as
`written` clauses. A numbered list inside a `###` subsection of the Decision section is that subsection's prose,
@@ -24,7 +24,7 @@ func imported487(t *testing.T, s Store) *world {
if err != nil {
t.Fatal(err)
}
if out.Refused != 0 || out.Created != 13 {
if out.Refused != 0 || out.Created != 15 {
t.Fatalf("%s %+v", out.Said, out.Rows)
}
return w
@@ -107,7 +107,7 @@ func TestTheImportHoldsEveryFieldOfARecord(t *testing.T) {
func TestClausesComeOnlyFromTheDecisionSectionsOwnList(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
imported487(t, s)
for _, n := range []int{259, 272, 273, 278, 288, 40} {
for _, n := range []int{259, 272, 273, 278, 288, 40, 114, 148} {
r := mustGet(t, s, Key{KindDecision, n}).Current
if len(r.Clauses) != 0 || r.Drafting == nil || r.Drafting.State != DraftWaiting {
t.Errorf("ADR %04d: %d clause(s) %v, drafting %+v", n, len(r.Clauses), r.Clauses, r.Drafting)
@@ -254,12 +254,11 @@ func TestARefreshRepairsOnlyRecordsNobodyChangedSinceTheImportAndChangesNothingT
t.Fatal(err)
}
}
// ADR 0085 was drafted since its import: someone changed it, and the refresh never touches it.
// ADR 0085 was corrected by someone since its import, and the refresh never touches it.
e85 := mustGet(t, s, Key{KindDecision, 85})
d := e85.Current.Clone()
d.Clauses = []Clause{{N: 1, Text: "**Secret-holding is a module.** It is.", Status: ClauseInForce, Source: SourceDrafted}}
d.Drafting = &Drafting{State: DraftDrafted}
if err := s.Save(ctx, Version{By: "the agent on test (sonnet)", At: at, Why: "1 clauses drafted", Record: d}, true, false, e85.Revision); err != nil {
d.Title = "A secret is a provision, corrected"
if err := s.Save(ctx, Version{By: "someone@g14", At: at, Why: "a correction by hand", Record: d}, true, false, e85.Revision); err != nil {
t.Fatal(err)
}
before := countVersions(t, s)
@@ -269,7 +268,7 @@ func TestARefreshRepairsOnlyRecordsNobodyChangedSinceTheImportAndChangesNothingT
if err != nil {
t.Fatal(err)
}
if countVersions(t, s) != before || dry.Refreshed != 12 || dry.ChangedSince != 1 || dry.Created != 0 {
if countVersions(t, s) != before || dry.Refreshed != 14 || dry.ChangedSince != 1 || dry.Created != 0 {
t.Fatalf("dry run: %s", dry.Said)
}
says := map[string]ImportRow{}
@@ -290,7 +289,7 @@ func TestARefreshRepairsOnlyRecordsNobodyChangedSinceTheImportAndChangesNothingT
if err != nil {
t.Fatal(err)
}
if out.Refreshed != 12 || out.ChangedSince != 1 || countVersions(t, s) != before+12 {
if out.Refreshed != 14 || out.ChangedSince != 1 || countVersions(t, s) != before+14 {
t.Fatalf("refresh: %s", out.Said)
}
e := mustGet(t, s, Key{KindDecision, 259})
@@ -308,7 +307,7 @@ func TestARefreshRepairsOnlyRecordsNobodyChangedSinceTheImportAndChangesNothingT
if r := mustGet(t, s, Key{KindResearch, 21}).Current; len(r.Touches) != 4 {
t.Fatalf("research 021 not repaired: %v", r.Touches)
}
if e := mustGet(t, s, Key{KindDecision, 85}); e.Versions != 2 || len(e.Current.Clauses) != 1 {
if e := mustGet(t, s, Key{KindDecision, 85}); e.Versions != 2 || e.Current.Title != "A secret is a provision, corrected" {
t.Fatalf("0085, changed since its import, was touched: %d versions", e.Versions)
}
// Run again: nothing changes, and it says so.
@@ -317,22 +316,81 @@ func TestARefreshRepairsOnlyRecordsNobodyChangedSinceTheImportAndChangesNothingT
if err != nil {
t.Fatal(err)
}
if again.Refreshed != 0 || again.Unchanged != 12 || countVersions(t, s) != after || !strings.Contains(again.Said, "Nothing changed") {
if again.Refreshed != 0 || again.Unchanged != 14 || countVersions(t, s) != after || !strings.Contains(again.Said, "Nothing changed") {
t.Fatalf("a second refresh: %s", again.Said)
}
// Once drafted, a repaired record is someone's: a later refresh leaves it alone.
draftUntil(t, w, Key{KindDecision, 259}, DraftDone)
// Drafted and confirmed since, by the knowledge base itself: still the import's side. A later refresh keeps
// the drafted clauses, since the Decision section they come from is unchanged, and changes nothing.
r259 := draftUntil(t, w, Key{KindDecision, 259}, DraftDone)
third, _ := w.k.Import("import@test", ImportAsk{Refresh: true}, at487())
changed := false
for _, row := range third.Rows {
changed = changed || (row.Record == "ADR 0259" && row.Action == ImportChangedSince)
if row.Record == "ADR 0259" {
t.Fatalf("0259 in a third refresh: %+v", row)
}
}
if third.Refreshed != 0 || !changed || mustGet(t, s, Key{KindDecision, 259}).Current.Drafting.State != DraftDone {
if got := mustGet(t, s, Key{KindDecision, 259}).Current; third.Refreshed != 0 || renderJSON(got.Clauses) != renderJSON(r259.Clauses) {
t.Fatalf("a third refresh: %s", third.Said)
}
})
}
// Live, ADR 0002 has versions 2 and 3 drafted and confirmed by the knowledge base after its import. Those are the
// import's side: a refresh repairs it, keeping its clauses while the Decision section they were drafted from is
// what hq says, and queuing it to be drafted again when it is not.
func TestARefreshRepairsARecordTheKnowledgeBaseDraftedAndKeepsItsClauses(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
ctx := context.Background()
w := newWorld(t, s, at487())
commit, _ := at487().Standing()
p2 := DecisionsFolder + "/0002-nodes-communicate-over-a-broker.md"
p4 := DecisionsFolder + "/0004-a-node-and-how-it-joins.md"
r2 := legacy(t, p2, commit)
// 0004 has no Decision section; the import before #487 queued nothing, so a hand-made drafting stands in for
// one made from a text that is no longer what hq says.
r4 := legacy(t, p4, commit)
r4.Drafting = &Drafting{State: DraftWaiting}
r4.Sections = append(r4.Sections, Section{Name: "Decision", Text: "Nodes join by a token. That is decided here."})
for _, c := range []struct {
path string
r *Record
}{{p2, r2}, {p4, r4}} {
if err := s.Create(ctx, Version{By: Unproven, Why: "imported from hq at " + short(commit) + ": " + c.path, Record: c.r}); err != nil {
t.Fatal(err)
}
}
drafted2 := draftUntil(t, w, Key{KindDecision, 2}, DraftDone)
draftUntil(t, w, Key{KindDecision, 4}, DraftDone)
if mustGet(t, s, Key{KindDecision, 2}).Versions != 3 {
t.Fatal("0002 was not drafted and confirmed as live")
}
out, err := w.k.Import("import@test", ImportAsk{Refresh: true}, at487())
if err != nil {
t.Fatal(err)
}
if out.ChangedSince != 0 {
t.Fatalf("drafting counted as a change: %s %+v", out.Said, out.Rows)
}
e2 := mustGet(t, s, Key{KindDecision, 2})
if e2.Versions != 4 || e2.Current.Reconstructed == nil || !*e2.Current.Reconstructed ||
renderJSON(e2.Current.Clauses) != renderJSON(drafted2.Clauses) || e2.Current.Drafting == nil || e2.Current.Drafting.State != DraftDone {
t.Fatalf("0002 after the refresh: %d versions, %+v, drafting %+v", e2.Versions, e2.Current.Clauses, e2.Current.Drafting)
}
// 0004's clauses were drafted from a text hq does not hold: taken back, and drafted again from its sections.
e4 := mustGet(t, s, Key{KindDecision, 4}).Current
if len(e4.Clauses) != 0 || e4.Drafting == nil || e4.Drafting.State != DraftWaiting || !strings.Contains(e4.Drafting.Reason, "no Decision section") {
t.Fatalf("0004 after the refresh: %+v %+v", e4.Clauses, e4.Drafting)
}
// A record someone returned is someone's change: left alone.
if _, err := w.k.Return("operator@shanks", Key{KindDecision, 2}, "clause 2 says more than the record"); err != nil {
t.Fatal(err)
}
again, _ := w.k.Import("import@test", ImportAsk{Refresh: true}, at487())
if again.ChangedSince != 1 {
t.Fatalf("a returned record refreshed: %s", again.Said)
}
})
}
// A refresh without a commit's records refuses, and an import without refresh never rewrites what it holds.
func TestAnImportWithoutRefreshLeavesAHeldRecordAsItIs(t *testing.T) {
w := newWorld(t, newMemStore(), at487())
@@ -314,21 +314,79 @@ func (k *KB) Import(caller string, a ImportAsk, records Records) (*Imported, err
return out, nil
}
// importedOnly says whether every version of a record was written by the import or a refresh of it: nobody has
// changed it since it was read from hq.
// draftedHere is the why of a version the knowledge base wrote itself while drafting and confirming clauses, or
// judging which clause a change of hq's names (draft.go): the import's side of a record, not anyone's change.
var draftedHere = regexp.MustCompile(`^(?:\d+ clauses drafted by |\d+ of \d+ drafted clauses confirmed by |drafting failed: |` +
`confirming failed: |no Decision section|which clause of .+ it changes drafted: |that it changes .+ (?:was not confirmed: |confirmed by )|` +
`§\d+ changed by .+ \(as hq said, its clause judged and confirmed\)$)`)
// importedOnly says whether every version of a record was written by the import, a refresh of it, or the
// knowledge base's own drafting and confirming of its clauses: nobody has changed it since it was read from hq. A
// return, a correction, an acceptance over it or an insight is someone's change.
func importedOnly(vs []Version) bool {
if len(vs) == 0 {
return false
}
for _, v := range vs {
if v.Insight || v.Record == nil || !strings.HasPrefix(v.Record.Source, "hq@") ||
!(strings.HasPrefix(v.Why, "imported from hq at ") || strings.HasPrefix(v.Why, "refreshed from hq at ")) {
if v.Insight || v.Record == nil || !strings.HasPrefix(v.Record.Source, "hq@") {
return false
}
imported := strings.HasPrefix(v.Why, "imported from hq at ") || strings.HasPrefix(v.Why, "refreshed from hq at ")
drafted := draftedHere.MatchString(v.Why) && (v.By == "the knowledge base" || strings.HasPrefix(v.By, "the agent on "))
if !imported && !drafted {
return false
}
}
return true
}
// carry keeps in a record read again (now) what the knowledge base made of the one held: its clauses with their
// statuses when hq writes the same ones; its drafted or confirmed clauses and their drafting while the text they
// were drafted from is what hq says (else they are drafted again); and the clause a change of hq's names, once
// judged.
func carry(held, now *Record) {
switch {
case len(now.Clauses) > 0:
if len(held.Clauses) == len(now.Clauses) {
same := true
for i := range now.Clauses {
same = same && held.Clauses[i].N == now.Clauses[i].N && held.Clauses[i].Text == now.Clauses[i].Text
}
if same {
now.Clauses = held.Clauses
}
}
case now.Drafting != nil && held.Drafting != nil && (len(held.Clauses) > 0 || held.Drafting.State != DraftWaiting):
a, _ := draftSource(held)
b, _ := draftSource(now)
if a == b {
now.Clauses, now.Drafting = held.Clauses, held.Drafting
}
}
judged := map[Ref][]Link{}
for _, l := range held.Links {
if l.Kind == LinkChanges && l.Source != LinkPending {
if to, ok := l.To.Record(); ok {
judged[Ref{Kind: to.Kind, Number: to.Number}] = append(judged[Ref{Kind: to.Kind, Number: to.Number}], l)
}
}
}
var links []Link
for _, l := range now.Links {
target := Ref{Kind: l.To.Kind, Number: l.To.Number}
if l.Kind == LinkChanges && l.Source == LinkPending && len(judged[target]) > 0 {
for _, j := range judged[target] {
j.Unresolved = l.Unresolved
links = append(links, j)
}
delete(judged, target)
continue
}
links = append(links, l)
}
now.Links = links
}
// refresh reads a held record again (r, as hq has it at the commit) and, when only the import ever wrote it and
// what it holds differs, writes r as its new version naming what changed and why. A record changed here since
// its import is never touched; a second refresh finds the same and changes nothing.
@@ -349,6 +407,7 @@ func (k *KB) refresh(ctx context.Context, a ImportAsk, e *Entry, r *Record, path
out.ChangedSince++
return
}
carry(e.Current, r)
diffs := differences(e.Current, r)
if len(diffs) == 0 {
row.Action = ImportHeld
@@ -0,0 +1,270 @@
---
topic: what runs on it
status: accepted
date: 2026-09-26
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0113-the-vault-makes-every-secret.md
---
# 114. A credential two parties hold rotates over two credentials; one a single party holds rotates in place, staged; and retiring a credential never removes what it reached
## Context
[ADR 0113](0113-the-vault-makes-every-secret.md) decides who asks for a rotation (an operator, or the
vault's policy) and who makes the new value (the vault). It leaves open how old and new change over.
[Research 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md) read every
credential provider in the catalogue against its code. There are nine:
- **all nine re-apply a password in place**, on the same login, every time they run. The controller's
`rotate` command relies on that, and states the window it leaves: between the provider applying the
new value and the consumer restarting with it, the consumer cannot authenticate;
- **eight of nine name the consumer's resource after its login**: a database, a bucket, a virtual host,
a key prefix, a topic prefix, a mailbox. Only the forge's npm registry keeps them apart, because an
organisation owns the packages;
- **five of nine destroy the consumer's data when they remove its login**: postgres, mssql and mongodb
drop the database, lavinmq drops the virtual host with its queued messages, and mailu deletes the
mailbox with its mail. In those adapters, *retire a login* and *delete the consumer's data* are one
call. minio drops a bucket only if it is empty. The provisioner harness makes it worse: a consumer
whose derived login changed is removed under the old login and created under the new one, in one pass;
- **one backend holds two passwords on one login** (redis), and two hold several tokens beside one
password (the forge and mailu);
- **eight of nine can give two logins the same rights over one resource**. mailu cannot, because a mail
user *is* its mailbox. It can give one user several tokens. In postgres, a second login is not enough
on its own: objects belong to whichever login created them, so the resource must be owned by a role of
its own;
- **an administrative credential has one party and a fixed name.** The provider module both applies it
and reads it. Five backends take it only at first initialisation: postgres, mssql, mongodb, mosquitto
and lavinmq. Their credential file is mounted directly into both the server and the provisioner, so
replacing the file recreates the provisioner holding only the new value, which the backend does not
know yet. The provisioner is then locked out;
- **no module watches a secret.** Every reader reads at start, and the host recreates a container when
a file it read at creation changes
([issue 103](../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md)).
A first draft of 0113 chose to overlap old and new "through the adapter's existing create and
remove". In five providers, that remove deletes the consumer's data. The mechanism has to be chosen on
what the providers do, and the danger has to be closed whichever mechanism is chosen.
## Considered Options
**1. In place for everything, as today.** Works with every provider unchanged. Rejected for credentials
two parties hold. The window cannot be closed, only shortened, and the two ends are on different
machines with nothing ordering them. For an administrative credential, it locks the provisioner out.
**2. Two secrets on one login.** Rejected as the mechanism. It works for three providers out of nine,
and using it there and something else elsewhere would put the difference in the mesh instead of in the
adapter.
**3. Two logins over one resource.** Rejected as the mechanism. It works for eight of nine, and not for
mailu.
**4. Two credentials over one resource, with the adapter choosing what a credential is.** A credential
is what a consumer presents, a login and a secret. The mesh alternates between two of them. Each adapter
makes the second one the way its backend can: a second login for eight providers, a second token on the
same login for mailu. A credential a single party holds is staged in place instead, and retiring a
credential is separated from removing a consumer before either is used. Chosen.
## Decision
### Retiring a credential never removes what it reached
**A provider's adapter keeps two things apart that today are one:** the consumer's *resource* (its
database, bucket, virtual host, key or topic prefix, mailbox) and a *credential* that reaches it.
They get separate operations:
- **ensure the resource**, named after the consumer;
- **ensure a credential** with a value, holding the consumer's rights over its resource;
- **retire a credential**. Anything it owns moves first to the resource's owner, and any session it has
open is ended. Then the credential is removed, and nothing else;
- **remove the consumer**, which is what removes the resource, and retires every credential it has.
**Remove the consumer runs only when the consumer no longer requires the provision from this provider.**
That happens when its assignment goes, when its definition drops the requirement, or when re-resolution
sends it to another provider. It never runs because a login or a value changed. The harness keys what it
applied by the consumer, not by the login, so a changed login is a credential change and never a removal.
What removing a resource does with the data in it stays
[ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)'s, and re-resolving to another provider
moves no data.
**The resource is named after the consumer, and owned by the resource, not by a login.** A consumer's
identity is derived from its assignment ([ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md)),
and today its login is that same string, so **no existing resource is renamed**. Where a backend makes
whatever a login creates the login's own, as postgres does, the resource is owned by a role that cannot
log in, and each credential works as that role. Ownership of an existing resource moves to it once. A
credential is retired by handing what it owns to that role, never by dropping what it owns.
### A credential two parties hold rotates over two credentials
**Two parties** means an applier and a reader that are different modules, or a module and a node's
host. The vault's custody copy does not count, because the vault holds every secret. So this covers a
credential between a consumer and a provider, and every bus account: a module's or a host's, applied by
the broker's provisioner and read by its owner. **Each consumer has two credentials, one in use at a
time**, both holding the same rights over the one resource. For eight providers the second is a second
login, derived by the mesh as the consumer's identity with a short fixed suffix. For mailu it is a
second token on the same login.
**The vault drives each rotation and records every step durably.** A provisioner learns which
credentials to hold from what it receives: both of them, for as long as a rotation is under way. It
never learns them from its own memory, so a provisioner restarted mid-rotation resumes from the step the
vault has recorded.
1. **The vault makes the new value.**
2. **Each applier ensures the unused credential with it**, with the consumer's rights, and leaves the
one in use untouched. It verifies that the new credential authenticates and the old one still does,
and confirms. It repeats the confirmation on every reconcile pass until the vault acknowledges it, so
a lost message costs one pass.
3. **Only then does the vault release the new credential to the readers.** The mesh delivers it and the
value together, and the host recreates each reader, because a file it read at creation changed. A
node's host is its own reader: it reconnects to the bus with the new login, and confirms over it.
4. **Each reader confirms by authenticating with the new credential.** It shows this through its
health check, where its definition declares one, or the applier sees the new credential in use,
where its backend reports that. A reader for which neither is possible is confirmed by an operator.
It is never assumed from the reader having restarted.
5. **Only when every reader has confirmed is the old credential retired**, as above, and verified to no
longer authenticate.
**A reader that goes away leaves the rotation.** A reader unassigned, or re-resolved to another
provider, is no longer waited for. A consumer removed mid-rotation has both of its credentials retired
with it.
**A rotation can be abandoned until the old credential is retired.** An operator abandons it. Readers
that moved are given the old credential back, and recreated. The new credential is retired. Nothing is
lost, because the old one was never removed.
`status` shows a rotation as waiting on whichever applier or reader has not moved, and it is not done
until the old credential is gone. A reader that cannot be reached keeps working on the old credential
until it can, and the rotation waits for it. That wait is shown, never hidden.
**Queues and permissions belong to the consumer, not to a login.** A module's queue on the bus is named
for the module on its node, and both of its logins get the same permissions over it
([ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)). An MQTT client
identifier is chosen by the consumer and is independent of its login. A reader recreated with a new
login keeps it, and the broker hands the session over.
### A credential a single party holds rotates in place, staged
This covers a provider's administrative credential and a module's own secret, which only that module
reads. The vault makes the new value, and the one party takes it:
- **applied**: the vault delivers the new value **staged, beside the current one**, and the current file
is left as it is. The party's provisioner changes the backend using the current value, verifies the
new one, and confirms. Only then does the vault make the new value current. This is the only form for
a backend that takes its administrative credential only at first initialisation. Replacing the file
first would lock the provisioner out;
- **read at start**: the vault delivers the new value as current, and the host recreates the party.
There is no window between two parties, because there is only one. Where neither form can change the
value, the requirement is marked not rotatable by the mesh, and a rotation is refused, saying why
([ADR 0113](0113-the-vault-makes-every-secret.md)).
### One rule decides which
**The number of parties decides, never the provider.** The resolver knows it from the requirement's
recipients, leaving out the vault's custody copy, so no definition declares it.
> **Progressive insight — 2026-10-01.** The number of parties is the resolver's to know; *which form*
> a single party's credential takes is not, and cannot be: whether a module reads its secret when it
> starts or applies it once to a backend is a fact about the software, visible nowhere in the graph.
> So the definition declares that half — `taken: at-start` or `taken: applied` on an own secret — and
> a secret that declares neither is not rotated, refused with the word to write (issue 180). The
> read-at-start form is built; the staged form for an applied credential is not. The decision stands;
> the sentence above was one fact short.
### Until an adapter can
**An adapter that cannot yet ensure a second credential says so.** The two-party credentials it applies
rotate in place, as today, and the window is stated when the rotation is asked for. So does a
two-party credential whose backend has one fixed name and no second credential for it. These are listed
by a check, and the list is meant to shrink. Separating *retire a credential* from *remove the
consumer*, and keying the harness by consumer, come first. They close a data-loss path that exists
today, whatever rotation does.
## What this changes in earlier records
On acceptance, each of these is amended by this record, not edited:
- [ADR 0113](0113-the-vault-makes-every-secret.md): the changeover it left open is decided here.
- [ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md): a consumer's identity leaves room
for the second login's suffix within the tightest backend it reaches, and both logins are checked
against it.
- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md): a module's broker
account is two logins with the same permissions over the same queue, one in use at a time. Its scoping
is unchanged.
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md), already superseded by 0113:
a provider now ensures and retires credentials over a resource it owns separately.
- [To-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md): rotation of a two-party
credential is no longer all-or-nothing with a window. It overlaps, with each step confirmed.
A single-party credential is staged, not replaced.
## Accepted, 2026-09-30, and not scheduled
Accepted as written. The separation it draws — a consumer's *resource* and a *credential that reaches
it* are different things with different lifecycles — is the part that had to be settled, because the
alternative is what the record was written against: retiring a credential taking the data it reached
with it. That is a data-loss shape, and a record that names it should not sit unresolved while the
code that could hit it is being written.
**It is not built, and accepting it does not schedule it.** The SDK's provisioner adapter is still
`create` / `remove` / `holds` rather than the four operations above, and no provider implements the
two-credential rotation. Accepted-and-not-built is an ordinary state here — 0141 and 0142 are both in
it — and it is the honest one: leaving this `proposed` made it invisible to anyone reading what the
mesh has decided, while changing nothing about what runs.
The work it implies belongs with the provisioner contract, beside
[issue 124](../04-ISSUES/124-a-consumer-cannot-be-told-what-its-provider-derived/00-report.md).
## Consequences
- **Every credential provider's adapter changes**, in two steps. The first separates *retire a
credential* from *remove the consumer*. It names and owns the resource after the consumer, which
keeps the name it has but moves ownership once in postgres and mssql, and the harness is keyed by
consumer. The second ensures a second credential with the same rights.
- **The vault gains rotation state**: each rotation's step, per applier and reader, recorded durably.
Staged delivery is added for single-party secrets. The SDK harness carries the alternation and the
repeated confirmation, so no adapter implements them.
- **No consumer module changes.** It reads one credential at start, as today, and is recreated by the
host when it changes. The exception is a reader that has neither a health check nor a backend that
reports use: its rotations wait for an operator until it declares one.
- **The derived identity is two characters tighter** in the tightest backend, a minio access key of 20
characters.
- **What got harder:**
- a provider briefly holds two credentials per consumer;
- a rotation lasts until its slowest reader moves, so an unreachable reader keeps the old credential
valid until it is reached;
- an adapter has four operations where it had two;
- retiring a login in mssql has to end its sessions first.
## How it is checked
| Rule | Checked by |
|---|---|
| Retiring a credential never removes a resource | A provider test per credential provider: retiring one of a consumer's credentials leaves its resource and data intact, reachable through the other. |
| What a retired login owned survives it | A postgres and an mssql test: objects created under login A, tables included, are still there and alterable under login B after A is retired. |
| A changed login is not a removal | A harness test: changing a consumer's derived login ensures a credential and never calls remove. |
| Remove runs only when the requirement goes | Harness tests: unassigning, dropping the requirement and re-resolving each remove the consumer once; a rotation and a login change never do. |
| No existing resource is renamed | A provider test: a consumer created before the change keeps its resource, with ownership moved to the resource's own role where the backend needs one. |
| Both credentials hold the same rights | A provider test per credential provider: data and structure created under one credential are read, changed and altered under the other. |
| Readers move only after the applier confirms | A rotation test: readers receive nothing until both credentials authenticate at every applier. |
| A reader confirms by authenticating | A rotation test: a reader recreated but failing to authenticate with the new credential does not confirm, and the old credential is not retired. |
| The old credential is retired only after every reader confirms | A rotation test with one reader's node unreachable: it keeps authenticating with the old credential, the rotation shows waiting on it, and it completes when the reader returns and confirms. |
| A reader that goes away leaves the rotation | A rotation test: unassigning a waiting reader lets the rotation complete; removing the consumer mid-rotation retires both credentials. |
| A rotation can be abandoned | A rotation test: abandoning after readers moved gives them the old credential back and retires the new one. |
| Rotation state survives a restart | A test restarting the applier's provisioner, and then the vault, between steps: the rotation resumes from the recorded step. |
| A single-party applied secret is staged | A rotation test on a first-initialisation administrative credential: the provisioner receives the new value beside the current one, applies it, and only then does the new value become current. At no point does it lose its connection. |
| The number of parties decides | A resolution test: a secret with an applier and a reader in different parties is marked for two credentials, and one held by one module for in place. The vault's copy is not counted. |
| Both logins fit the tightest backend | A controller test: both derived logins for the longest node and module names fit the limit ADR 0049 sets. |
| A host rotates its bus login | A rotation test on a node's bus account: the host reconnects with the new login and confirms over the bus before the old one is retired. |
| What still rotates in place is listed | A catalogue test lists every adapter that cannot yet ensure a second credential, and every two-party credential with one fixed name. A rotation of these states its window. |
## References
- [Research 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md): the survey this
rests on, provider by provider
- [ADR 0113](0113-the-vault-makes-every-secret.md): who asks and who makes
- [ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md), [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md),
[ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md): identity, bus accounts, and data outliving
its declaration
- [To-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md): rotation as implemented
- [Issue 103](../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md):
why a reader's restart can be derived
@@ -0,0 +1,176 @@
---
topic: the tiers
status: accepted
date: 2026-09-30
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0066-public-routing-is-name-agnostic.md
---
# 148. The mesh's names are resolved, not copied into every container
## Context
The mesh gives every container it declares the whole roster of mesh names as entries written into
the container's own hosts file at creation
([design 08 §2](../03-DESIGN/01-to-be/08-connectivity.md),
[ADR 0066](0066-public-routing-is-name-agnostic.md)). A container takes those entries once and never
looks again.
Three issues are the same fact arriving three times.
**A container keeps the address it was made with.** Adopting the predecessor's tunnel moved the hub's
private address; the declaration followed it within one push and nothing on the machine did. The
forge's container held the old address, lost its database, reported healthy while its existing
connections lasted, and then the public name went down
([issue 109](../04-ISSUES/109-a-container-keeps-the-address-it-was-made-with/00-report.md)).
**The same fault, four days later, undetected for five days.** One container had restarted 2286 times
against a database it could no longer find, while the mesh reported the machine as doing what it was
told. Inside it, `novox.internal` was an address that had not existed for five days
([issue 135](../04-ISSUES/135-a-containers-mesh-names-are-not-compared/00-report.md)). Forty-eight
other containers were current, none of them corrected — each had been recreated for some other
reason and picked up the roster on the way.
135 was fixed by putting the roster into the digest the host compares a container against, so a
container whose names moved is recreated like one whose image moved. **That made the roster part of
every container's identity**, which is the third arrival:
**One name moving replaces every container in the mesh.** Migrating one small module on one machine
took four routine actions; each changed the roster, and each replaced every container on the control
node — its own store, the registry, the edge proxy, the forge, the directory, mail. The control plane
was unreachable twice while its own store came back through crash recovery
([issue 151](../04-ISSUES/151-a-new-name-recreates-every-container-in-the-mesh/00-report.md)). None of
the replaced containers had anything to do with the module being migrated, or with its machine.
The blast radius of a name is now every container that carries the list, which is all of them. The
node-by-node migration ahead adds names one module at a time — on one machine alone that is around
twenty-five — and each would be a full restart of every service on the hub.
## Considered Options
**1. Keep the roster in every container and accept the churn.** Rejected. It is not a cost that can
be paid down: the mesh gets more names as it grows, and every name costs a restart of everything.
A rollback costs another.
**2. Scope each container's entries to the names it actually binds.** A container is given the names
of the things it declared a requirement on, so a name's blast radius is its consumers. Tidy, needs no
new mechanism, and keeps 135's guarantee exactly.
Rejected, and this is the close one. It contradicts the standing intent that **anything on the mesh
can call anything on it** — three cases, same machine, the private network, the public network, and
no fourth. Scoping resolution to declared couplings makes a name reachable only where the mesh was
told in advance that it would be wanted, and a person debugging inside a container would find names
missing that exist everywhere else on the machine. It also leaves the roster in the digest, so the
churn returns the moment a widely-bound name moves — smaller, not gone.
**3. Resolve at lookup time through the machine's resolver, and copy nothing.** Chosen.
## Decision
**A container resolves the mesh's names through its machine's resolver, at the moment it asks. No
mesh name and no mesh address is written into a container, and none is part of a container's
identity.**
The three consequences that make this worth doing:
- **Staleness stops being possible**, rather than being detected. 109 and 135 are not bugs that were
fixed; they are a shape that no longer exists. A name that moves is answered differently by the next
lookup, in every container, with nothing recreated and nothing restarted.
- **A name's blast radius becomes nothing.** Assigning a module on one machine does not touch a
container on another.
- **Anything can still call anything**, which option 2 gave up. The resolver answers every mesh name to
every asker on the machine, exactly as it answers the machine itself.
**The resolver is a machine-level process, not a container** — one of the modules that is not a
container at all — so a container depending on it is not the circularity it would be if the mesh's
own store had to resolve a name through something the store's own runtime had to start first.
**What a module declares for itself is untouched.** Entries a manifest asks for are the module's own,
stay in the container, and stay in its identity: they are part of what the module *is*, they do not
move when the mesh's roster does, and the mesh does not know what they mean.
**The machine's own roster file is untouched.** It is a file, rewritten in place, read by processes and
people; nothing restarts when it changes. It is only the *copy into each container* that this ends.
### The order this lands in, which is not a preference
**Nothing may stop copying names until resolution works from a container.** Removing the copy first
reintroduces 109 and 135 — silently, and on a live mesh, which is exactly how both were found.
1. **A container on any network can reach the resolver.** Today a container on the runtime's default
network asks from an address the converged filter drops, so it has no DNS at all
([issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md));
and on two of four machines the resolver binds loopback only, so the runtime hands containers a
public resolver instead. Both are prerequisites, not related work.
> **Progressive insight — 2026-09-30, later the same day. The loopback claim was wrong.** The
> resolver bound the private address on all four machines; on two the runtime had never been told
> to use it, and on all four the resolver discarded a query that arrived on the runtime's bridge.
> The step stands; the facts under it were those. Both fixed the same day
> ([issue 110's resolution](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/01-resolution.md)),
> and step 3 landed after them.
2. **The runtime is told which resolver to use, per machine, as a file** — not per container as a
creation-time argument, or the resolver's address is back in every container's identity and the
problem has only got smaller.
3. **Then, and only then, the roster leaves the declaration and the digest.**
Until step 3 the mesh keeps copying, and keeps comparing. 151 stays open until step 3 lands; it is not
closed by this record, only answered by it.
## How this is checked
- **A container resolves a name that moved, without being recreated.** Move a name the mesh serves;
from a container that was running before the move and has not been touched since, the name answers
with the new address. This is the one 109 and 135 would both have failed.
- **A name's blast radius is nothing.** Add a routed name on one machine; no container on any other
machine is recreated. The apply report on each machine says nothing changed. This is 151.
- **Anything calls anything.** From a container on any machine, every `<node>.internal` name and every
routed name the mesh serves resolves — including names the module never declared a requirement on,
which is the guarantee option 2 would have given up.
- **On every network the runtime offers.** The first three hold for a container on the runtime's
default network as well as one on a declared network, because the default network is the case that
has no DNS today.
- **No mesh name is in a container's spec.** A test asserts the digest a host computes for a container
does not move when the mesh's roster does, and does move when the module's own declared entries do.
## Consequences
- **The resolver becomes load-bearing for every container**, where before it was load-bearing for the
machine. This is a real cost and is accepted: a resolver that is down is a machine that cannot
resolve, which is already true of the machine itself, and is a smaller event than a roster change
destroying and recreating every container on the machine.
- **Design 08's "a file rather than a resolver" no longer describes containers.** It was written when
the mesh had no resolver and it gave the right answer then. The reasoning it rested on — every Linux
has a hosts file, no package needed — was already overtaken by names a hosts file cannot express:
service names and wildcards under `<node>.internal`, which is why the resolver was built.
- **ADR 0066's mesh-wide propagation is kept and its mechanism changes.** A routed name still reaches
every asker in the mesh; it reaches them through the resolver rather than by being written into each
container. The consequence 0066 records — that an internal issuer's challenge needs the routed name
resolvable inside the mesh — holds unchanged and by the same means the machine already uses.
- **Issue 110 stops being a container-DNS inconvenience and becomes a prerequisite** for the mesh not
restarting itself whenever it learns a name.
- **A container that names a resolver of its own has opted out of the machine's**, and the copy this
record removes was the only reason such a container could reach anything by a mesh name.
> **Progressive insight — 2026-09-30, the afternoon this landed. Found the hard way.** The mail
> system's admin, behind Mailu's own resolver, lost its database the moment the copy went
> ([issue 171](../04-ISSUES/171-a-modules-own-resolver-knows-no-mesh-name/00-report.md)). A `dns` on
> a container is a decision about whether mesh names exist inside it, not a preference; the module
> was corrected, and whether the controller should refuse the contradiction is that issue's open
> question.
- **A container started by hand gets the mesh's names too**, where before only declared containers did.
Design 08 drew that boundary deliberately, on the grounds that reaching into every container is what
a nameserver would be for. This record accepts that consequence rather than working around it: a
person debugging in a hand-started container resolving the same names as everything else is the
behaviour worth having, and it is what "anything can call anything" means.
## References
- [issue 151](../04-ISSUES/151-a-new-name-recreates-every-container-in-the-mesh/00-report.md) — one name replaces every container; the question this answers
- [issue 135](../04-ISSUES/135-a-containers-mesh-names-are-not-compared/00-report.md) — the roster put into the digest
- [issue 109](../04-ISSUES/109-a-container-keeps-the-address-it-was-made-with/00-report.md) — the first arrival
- [issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md) — the prerequisite
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — routed names propagate mesh-wide; extended here
- [design 08 §2](../03-DESIGN/01-to-be/08-connectivity.md) — the file-not-resolver reasoning this narrows